Compare commits
423 Commits
da079b2bc6
...
v1.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 8c8f8c4d42 | |||
| e9f836db87 | |||
| 23b5c3b5cc | |||
| e7c9d301ba | |||
| ec435c0e7f | |||
| da6665b967 | |||
| 90eaf4964b | |||
| 6537082397 | |||
| d99705645f | |||
| dcd8de8b00 | |||
| 01485d8fc6 | |||
| 3a640a17a4 | |||
| 4c4beace85 | |||
| cf66ed7e26 | |||
| 1012fb47a0 | |||
| 695508042a | |||
| b7d469a06e | |||
| 8b83543632 | |||
| 1d795e0acf | |||
| acbb2d8254 | |||
| 2d82c75f0b | |||
| d733ce3119 | |||
| 539e24fba1 | |||
| f8f7d39364 | |||
| 4c475f2b0e | |||
| 1b7b767576 | |||
| 8ec71a6816 | |||
| 1e7da5925a | |||
| 10d48884ac | |||
| 3312130483 | |||
| 52a0e3160d | |||
| 92a4de3bf4 | |||
| 2c96c19aac | |||
| 38be7fea96 | |||
| 03347c5a91 | |||
| 5b2e95d3c0 | |||
| f2f91ae7e6 | |||
| 7a48327ab6 | |||
| 0284c9b83a | |||
| 65689b903f | |||
| 453ddc5e94 | |||
| 19ae8f04a2 | |||
| 408da3f201 | |||
| 104eb2a978 | |||
| 85baabe4ba | |||
| 12fc6e498e | |||
| bfa8797f8c | |||
| 571bc8c9f2 | |||
| 751e74b14f | |||
| f36f3df748 | |||
| b422ff1159 | |||
| 6d0dd4fb14 | |||
| 7bd461bfc7 | |||
| ab2e42883a | |||
| eeaad62b10 |
@@ -0,0 +1,6 @@
|
||||
# Keep Docker build contexts small (the connector builds from the repo root).
|
||||
.git
|
||||
**/node_modules
|
||||
ui/dist
|
||||
ui/test-results
|
||||
ui/playwright-report
|
||||
@@ -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,56 +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/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- '.gitea/workflows/go-unit.yaml'
|
||||
- '!**/*.md'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- '.gitea/workflows/go-unit.yaml'
|
||||
- '!**/*.md'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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/...
|
||||
|
||||
- name: build
|
||||
run: go build ./backend/...
|
||||
|
||||
- name: test
|
||||
# -count=1 disables the test cache so a green run never depends on a
|
||||
# previous runner's cached state.
|
||||
run: go test -count=1 ./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"
|
||||
+10
@@ -10,6 +10,16 @@
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
# Playwright MCP scratch output (snapshots / screenshots written during inspection)
|
||||
.playwright-mcp/
|
||||
|
||||
# Local, unstaged env overrides
|
||||
**/.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,82 +1,97 @@
|
||||
# scrabble-game — project guide
|
||||
|
||||
Multiplatform Scrabble game. Read this first every session. The owner drives the
|
||||
project **one stage per session** (tariff constraint), so the repository — not
|
||||
conversation memory — is the source of continuity. Keep it that way.
|
||||
Multiplatform Scrabble game, **in production** at `https://erudit-game.ru`. Read this
|
||||
first every session. The repository — not conversation memory — is the source of
|
||||
continuity; keep it that way.
|
||||
|
||||
## Sources of truth (read before changing behaviour)
|
||||
|
||||
- [`PLAN.md`](PLAN.md) — staged plan + **stage tracker** + per-stage *open
|
||||
details to interview*.
|
||||
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport,
|
||||
security, the decision record. Always describes current state.
|
||||
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)
|
||||
mirror) — per-domain user stories. English authoritative.
|
||||
- [`docs/TESTING.md`](docs/TESTING.md) — test layers + the per-stage CI gate.
|
||||
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport, security,
|
||||
the decision record. Always describes the current state.
|
||||
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md) mirror)
|
||||
— per-domain user stories. English authoritative.
|
||||
- [`docs/TESTING.md`](docs/TESTING.md) — test layers + the CI gate.
|
||||
- [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md) — the `ui` visual/interaction design system.
|
||||
- [`deploy/README.md`](deploy/README.md) — the deploy contour + the production
|
||||
rollout / rollback runbook.
|
||||
|
||||
## Mandatory per-stage workflow
|
||||
## How we work
|
||||
|
||||
**Start of a stage**
|
||||
1. Read `PLAN.md` (the stage's scope + *open details*) and the relevant `docs/`.
|
||||
2. Analyse what the stage actually requires against the current code.
|
||||
3. **Interview the owner** on every open detail and any fork not already fixed
|
||||
in the plan — do not silently pick borderline decisions. Offer options with
|
||||
brief pros/cons.
|
||||
4. Only then implement, strictly within the stage's scope.
|
||||
|
||||
**End of a stage**
|
||||
1. Bake every new agreement back into `PLAN.md`, `docs/ARCHITECTURE.md`,
|
||||
`docs/FUNCTIONAL.md` (+ `_ru`), the affected service `README`, and Go Doc
|
||||
comments — in the **same** PR. Correct earlier stages' docs/code if a new
|
||||
decision changes them.
|
||||
2. Update the stage tracker; add a line under *Refinements logged during
|
||||
implementation* for any plan deviation.
|
||||
3. Get CI green, then mark the stage done.
|
||||
|
||||
(The `stage-implementation` skill encodes this same loop and can be invoked.)
|
||||
- Inspect the relevant code path and the docs above before changing behaviour.
|
||||
- **Interview the owner on every fork** — do not silently pick borderline decisions;
|
||||
offer options with brief pros/cons.
|
||||
- Smallest correct diff. Prefer compact code; reuse before adding; do not add deps,
|
||||
seams or knobs until they are needed.
|
||||
- **Update or add tests for every functional change**, at the layers
|
||||
`docs/TESTING.md` calls out.
|
||||
- **Bake docs in the same PR**: update `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md`
|
||||
(+`_ru`), the affected service `README` and Go Doc comments alongside the change.
|
||||
- Document added packages, types, funcs, consts and vars with Go Doc comments.
|
||||
|
||||
## Conventions
|
||||
|
||||
- All code, comments, identifiers, commits, docs, filenames in **English**.
|
||||
- Chat with the owner follows the user-level `~/.claude/CLAUDE.md` (Russian,
|
||||
the agreed persona and translation rules).
|
||||
- Mirror every point edit of `docs/FUNCTIONAL.md` into `docs/FUNCTIONAL_ru.md`
|
||||
in the same patch (translate only the touched paragraphs).
|
||||
- Prefer compact code; do not add deps, seams or knobs until a stage needs them.
|
||||
Reuse before adding. Document added packages/types/funcs with Go Doc comments.
|
||||
- Update or add tests for every functional change.
|
||||
- Chat with the owner follows the user-level `~/.claude/CLAUDE.md` (Russian, the
|
||||
agreed persona and translation rules).
|
||||
- Mirror every point edit of `docs/FUNCTIONAL.md` into `docs/FUNCTIONAL_ru.md` in the
|
||||
same patch (translate only the touched paragraphs).
|
||||
|
||||
## Branching & CI
|
||||
## Branching, CI & production
|
||||
|
||||
- Trunk is **`master`** (owner preference). From Stage 1, work on `feature/*`
|
||||
and merge via PR with a green CI gate. The genesis commit (Stage 0) lands on
|
||||
`master` by necessity (an empty branch has nothing to PR into).
|
||||
- After any push, watch the run to green before declaring a stage done — use the
|
||||
ready-made watcher, never an inline poll loop:
|
||||
`python3 ~/.claude/bin/gitea-ci-watch.py` (background). It reads `$GITEA_URL`
|
||||
/ `$GITEA_TOKEN`; `gitea.iliadenisov.ru` is allow-listed in
|
||||
`.claude/settings.json`. Remote: `origin git@gitea.iliadenisov.ru:developer/scrabble-game.git`.
|
||||
- **Two long-lived branches**: **`development`** is the integration branch; **`master`**
|
||||
is the production trunk. Cut `feature/*` from `development` and PR back into it;
|
||||
promote `development → master` via PR when ready to release. Both branches require
|
||||
one approval + the `CI / gate` check.
|
||||
- A commit to a `feature/*` branch triggers nothing. The single workflow
|
||||
`.gitea/workflows/ci.yaml` runs the full suite (`unit` + `integration` + `ui`) on a
|
||||
PR into `development` or `master`, and the gated **`deploy`** job auto-rolls the
|
||||
**test contour** on a PR into — or a push to — `development`
|
||||
(`docker compose up -d --build` on the runner host + landing/SPA/backend probes). A
|
||||
PR into `master` is test-only.
|
||||
- **Production is live on two hosts** (main + the Telegram bot host) and deploys
|
||||
**only manually** (`workflow_dispatch`), never automatically:
|
||||
- **`.gitea/workflows/prod-deploy.yaml`** (`confirm=deploy`, from `master`) builds +
|
||||
pushes the images to the registry, then SSH-deploys both hosts — rolling per
|
||||
service in dependency order, health-gated, **auto-rollback to the previous tag**;
|
||||
a schema migration adds a maintenance window + a consistent `pg_dump`. Four visible
|
||||
jobs: build → deploy-main → deploy-bot → verify.
|
||||
- **`.gitea/workflows/prod-rollback.yaml`** (`confirm=rollback`) re-deploys a prior
|
||||
release (blank `target_version` = the previous deployed version) — image-only,
|
||||
rolling, health-gated.
|
||||
- **Releases are git tags `vX.Y.Z` on `master`**; the deploy stamps `git describe
|
||||
--tags` into the image tag, every binary (`pkg/version` via `-ldflags` → the
|
||||
`service.version` telemetry attribute) and the SPA About screen. Tag the release
|
||||
before deploying.
|
||||
- Hosts are provisioned idempotently by **`deploy/ansible/`**. Per-contour
|
||||
secrets/variables use the `TEST_` / `PROD_` prefix (Gitea 1.26 has no deployment
|
||||
environments). Migrations must be **expand-contract** (backward-compatible) so
|
||||
image rollback stays DB-safe. Full runbook + variable list in `deploy/README.md`.
|
||||
- After any push, merge or deploy, **watch the run to green** before declaring done —
|
||||
use the ready-made watcher (run it in the background), never an inline poll loop:
|
||||
`python3 ~/.claude/bin/gitea-ci-watch.py`. It reads `$GITEA_URL` / `$GITEA_TOKEN`;
|
||||
`gitea.iliadenisov.ru` is allow-listed in `.claude/settings.json`. Remote:
|
||||
`origin git@gitea.iliadenisov.ru:developer/scrabble-game.git`.
|
||||
|
||||
## Stack
|
||||
|
||||
Go 1.26.3, `go.work` monorepo, module paths `scrabble/<name>`. Dependencies are
|
||||
added **when first used** (incremental): backend currently uses only `gin` +
|
||||
`zap`; pgx/goose/jet/OTel arrive with Stage 1+. Client↔gateway is Connect-RPC +
|
||||
FlatBuffers (h2c); gateway↔backend is REST/JSON + `X-User-ID` plus a gRPC
|
||||
server-stream for live events. UI is pure HTML5/CSS on plain Svelte + Vite,
|
||||
packaged to native with Capacitor. Likely no Redis.
|
||||
Go 1.26.3, `go.work` monorepo, module paths `scrabble/<name>`. Backend uses `gin` +
|
||||
`zap` + `pgx`/`go-jet`/`goose`/OTel. Client↔gateway is Connect-RPC + FlatBuffers
|
||||
(h2c); gateway↔backend is REST/JSON + `X-User-ID` plus a gRPC server-stream for live
|
||||
events. UI is pure HTML5/CSS on plain Svelte + Vite, packaged to native with
|
||||
Capacitor. No Redis.
|
||||
|
||||
## Reused engine: `../scrabble-solver` (module `scrabble-solver`, Go 1.26.3)
|
||||
|
||||
Embedded **in-process as a library** — there is no per-game container. Public
|
||||
API to reuse (do not reimplement):
|
||||
Embedded **in-process as a library** (`replace scrabble-solver => ../scrabble-solver`
|
||||
in `go.work`; CI checks out the sibling from
|
||||
`https://gitea.iliadenisov.ru/.../scrabble-solver.git`). There is no per-game
|
||||
container. Public API to reuse (do not reimplement):
|
||||
|
||||
- `scrabble.NewSolver(rs, finder)` → `GenerateMoves(b, r, mode)` (ranked,
|
||||
highest score first), `ValidatePlay(b, dir, tiles)`, `ScorePlay(...)`;
|
||||
`scrabble.Apply(b, m)`; types `Move/Word/Placement/Direction/Mode`
|
||||
- `scrabble.NewSolver(rs, finder)` → `GenerateMoves(b, r, mode)` (ranked, highest
|
||||
score first), `ValidatePlay(b, dir, tiles)`, `ScorePlay(...)`; `scrabble.Apply(b, m)`;
|
||||
types `Move/Word/Placement/Direction/Mode`
|
||||
(`scrabble-solver/scrabble/{solver,move,apply}.go`).
|
||||
- `rules.English() / RussianScrabble() / Erudit()`
|
||||
(`scrabble-solver/rules/rules.go`).
|
||||
- `rules.English() / RussianScrabble() / Erudit()` (`scrabble-solver/rules/rules.go`).
|
||||
- `board.New / Parse / Clone / Transpose`; `rack.New / Add / Remove / Clone`;
|
||||
`selfplay.NewBag / Draw / Len` (bag pattern).
|
||||
- Load committed dictionaries with `dawg.Load(path)` from
|
||||
@@ -85,26 +100,35 @@ API to reuse (do not reimplement):
|
||||
|
||||
Constraints:
|
||||
- Words/tiles are **alphabet-index bytes**, meaningful only with the matching
|
||||
`rules.Ruleset` (`Alphabet.Decode`); blank flag carried separately. **Decode
|
||||
`rules.Ruleset` (`Alphabet.Decode`); the blank flag is carried separately. **Decode
|
||||
to real characters before persisting history** (history must be
|
||||
dictionary-independent — see `docs/ARCHITECTURE.md` §9.1).
|
||||
- The solver's `internal/*` is NOT importable from this sibling module.
|
||||
- **GCG is test-only** in the solver (no public writer) — we ship our own.
|
||||
- Wiring: add `replace scrabble-solver => ../scrabble-solver` to `go.work` in
|
||||
**Stage 2** (when `internal/engine` first imports it), and make CI check out
|
||||
the solver sibling (`https://gitea.iliadenisov.ru/.../scrabble-solver.git`).
|
||||
It uses published `github.com/iliadenisov/{alphabet,dafsa}` (no local replace).
|
||||
- The solver uses published `github.com/iliadenisov/{alphabet,dafsa}` (no local replace).
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
go.work # use the existing modules; grows per stage
|
||||
go.work # the go.work monorepo
|
||||
backend/ # module scrabble/backend
|
||||
cmd/backend/ # main: boots HTTP listener
|
||||
internal/config/ # env config
|
||||
internal/server/ # gin engine, /healthz, /readyz, lifecycle
|
||||
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
|
||||
gateway/ ui/ pkg/ platform/ # added by their stages
|
||||
cmd/backend/ # main: telemetry -> db+migrate -> cache -> server
|
||||
cmd/jetgen/ # dev tool: regenerate go-jet code (throwaway container)
|
||||
internal/config/ # env config (composes postgres + telemetry)
|
||||
internal/telemetry/ # OTel providers + request-timing middleware
|
||||
internal/postgres/ # pgx/database-sql pool, goose migrations/, jet/ (generated)
|
||||
internal/account/ # durable accounts + identities (store)
|
||||
internal/session/ # opaque tokens, sessions store, cache, service
|
||||
internal/server/ # gin engine, /api/v1 groups, X-User-ID, probes
|
||||
internal/inttest/ # //go:build integration Postgres-backed tests
|
||||
gateway/ # module scrabble/gateway: Connect-RPC edge, embeds the SPA
|
||||
ui/ # Svelte + Vite SPA + landing (Node project, not in go.work)
|
||||
pkg/ # shared: telemetry, version, wire/FlatBuffers, proto, mtls
|
||||
platform/telegram/ # Telegram side-service: cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link)
|
||||
loadtest/ # module scrabble/loadtest: the load/stress harness
|
||||
docs/ .gitea/workflows/ CLAUDE.md README.md
|
||||
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless; gateway/Dockerfile has the `landing` target, platform/telegram/Dockerfile has `validator`+`bot` targets
|
||||
deploy/ # docker-compose (+ prod overlay + bot host) + ansible provisioning + caddy + landing + otelcol (OTLP + docker_stats) + prometheus/tempo/grafana + node_exporter + postgres_exporter; prod-deploy.sh
|
||||
```
|
||||
|
||||
## Build & test
|
||||
@@ -114,5 +138,19 @@ go build ./backend/... # per module ('./...' from the root won't span t
|
||||
go vet ./backend/...
|
||||
gofmt -l . # must print nothing
|
||||
go test -count=1 ./backend/...
|
||||
go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram validator + bot
|
||||
go run ./backend/cmd/backend # /healthz, /readyz on :8080
|
||||
|
||||
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI
|
||||
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`; it is the `ui` job of
|
||||
the single `.gitea/workflows/ci.yaml`. Committed edge codegen under `ui/src/gen/`
|
||||
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
|
||||
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
# Scrabble Game — implementation plan
|
||||
|
||||
Living plan and **stage tracker**. Each stage is implemented in its own session;
|
||||
the rules for starting and finishing a stage are in [`CLAUDE.md`](CLAUDE.md).
|
||||
The architecture/decision record is [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md);
|
||||
behaviour is [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md). When a stage produces a
|
||||
decision, bake it back here **and** into the affected docs/code in the same PR.
|
||||
|
||||
## Context
|
||||
|
||||
Greenfield multiplatform Scrabble. Players arrive from a platform (Telegram
|
||||
first; later VK/MAX/iOS/Android) or standalone web (email / guest). Three
|
||||
executables — `gateway`, `backend`, `ui` — plus per-platform side-services.
|
||||
Deliberately simpler than the sibling `../galaxy-game` (idea donor, not a
|
||||
template). The `../scrabble-solver` engine is embedded in-process as a library.
|
||||
|
||||
## Locked decisions (recap — full record in docs/ARCHITECTURE.md)
|
||||
|
||||
Stack: `go.work` monorepo, modules `scrabble/<name>`, Go 1.26.x, backend
|
||||
gin+pgx+Postgres(schema `backend`)+goose+zap+OTel (deps added when first used).
|
||||
Wire: Connect-RPC + FlatBuffers (client↔gateway), REST/JSON + `X-User-ID`
|
||||
(gateway↔backend), gRPC server-stream for live events. Auth: platform-native,
|
||||
thin opaque session token, no Ed25519/signing, likely no Redis. UI: pure
|
||||
HTML5/CSS, plain Svelte + Vite, Capacitor for native. MVP surfaces: Telegram +
|
||||
web (email + ephemeral guest) + link/merge. Variants: ru/en/Эрудит.
|
||||
Legality: validate-at-submit. End: empty bag+rack / 6 scoreless / 24h timeout.
|
||||
Hint: top-1. Word-check: unlimited + complaint. Robot: P(win)≈0.40, margin
|
||||
targeting, [2,90]min skewed timing, sleep 00:00–07:00 opp-tz, nudge logic.
|
||||
Dictionary: pin per game. History: structured + GCG export, dictionary-
|
||||
independent (see ARCHITECTURE §9.1).
|
||||
|
||||
## Stage tracker
|
||||
|
||||
| # | Stage | Status |
|
||||
|---|-------|--------|
|
||||
| 0 | Scaffolding (go.work, backend skeleton, docs, CI) | **done** |
|
||||
| 1 | Backend foundation (config, server, Postgres+goose, sessions, accounts) | todo |
|
||||
| 2 | Engine package over scrabble-solver | todo |
|
||||
| 3 | Game domain (lifecycle, rules, hint, word-check, history+GCG, stats) | todo |
|
||||
| 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | todo |
|
||||
| 5 | Robot opponent | todo |
|
||||
| 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | todo |
|
||||
| 7 | UI (plain Svelte + Vite, board, lobby, chat, i18n) | todo |
|
||||
| 8 | Telegram integration (bot side-service, deep-link, push) | todo |
|
||||
| 9 | Admin & dictionary ops (complaint review, version reload) | todo |
|
||||
| 10 | Account linking & merge | todo |
|
||||
| 11 | Polish (observability, perf with evidence, deploy) | todo |
|
||||
|
||||
Scaffolding is incremental: `go.work` lists only existing modules; each stage
|
||||
adds the modules it needs.
|
||||
|
||||
## Stages
|
||||
|
||||
Each stage: read this plan + relevant docs, **interview the owner on the open
|
||||
details below**, implement within scope, then update plan/docs/code and get CI
|
||||
green before marking done.
|
||||
|
||||
### Stage 0 — Scaffolding *(done)*
|
||||
Scope: `go.work` (Go 1.26.3, `use ./backend`); minimal runnable `backend`
|
||||
(gin, zap, `/healthz`, `/readyz`, env config); docs skeleton; `PLAN.md`;
|
||||
`CLAUDE.md`; `.gitea/workflows/go-unit.yaml`; README; `.gitignore`.
|
||||
Acceptance: `go build ./backend/...` + `go vet` + gofmt clean +
|
||||
`go test ./backend/...` green; CI green on push.
|
||||
|
||||
### Stage 1 — Backend foundation
|
||||
Scope: config/server route groups (`/api/v1/{public,user,internal,admin}`,
|
||||
probes), Postgres (pgx) + embedded goose migrations + schema `backend`,
|
||||
telemetry (OTel) wiring, in-memory cache scaffolding, thin sessions + accounts +
|
||||
platform identities.
|
||||
Open details: Postgres version + DSN/`search_path` convention; jet vs
|
||||
sqlc/sqlx (default jet); migration naming; exact session-token shape (opaque
|
||||
random length, TTL, revocation); account/identity table shape; whether the
|
||||
admin bootstrap lands here or in Stage 9.
|
||||
|
||||
### Stage 2 — Engine package
|
||||
Scope: `backend/internal/engine` over scrabble-solver — versioned DAWG
|
||||
load/registry, GenerateMoves/ValidatePlay/ScorePlay wrappers, bag/rack, the
|
||||
**dictionary-independent** game-state model + decode helpers. Add
|
||||
`replace scrabble-solver => ../scrabble-solver` to `go.work` here and solve the
|
||||
CI sibling-checkout (clone `gitea.iliadenisov.ru/.../scrabble-solver`).
|
||||
Open details: how CI obtains the solver (clone sibling vs publish/tag the
|
||||
solver module); in-memory game-state representation; how blanks and exchanges
|
||||
are modelled; Эрудит specifics to verify against the solver.
|
||||
|
||||
### Stage 3 — Game domain
|
||||
Scope: create/join, turn order, submit play/pass/exchange/resign,
|
||||
validate-at-submit, scoring, end-conditions, 24h timeout/auto-resign, hint,
|
||||
word-check + complaint capture, structured history + GCG writer, stats on
|
||||
finish.
|
||||
Open details: GCG dialect details (blanks, exchanges, notation); exact stats
|
||||
edge cases; turn-timeout scheduler mechanism (cron vs per-game timer);
|
||||
complaint payload shape.
|
||||
|
||||
### Stage 4 — Lobby & social
|
||||
Scope: matchmaking pool, friends, block, per-game chat, profile + email
|
||||
confirm-code, nudge.
|
||||
Open details: pool fairness/keying confirmation; deep-link format per platform;
|
||||
chat length limit + retention; friend-request lifecycle; email-code provider
|
||||
(SMTP relay choice).
|
||||
|
||||
### Stage 5 — Robot opponent
|
||||
Scope: human-like player — balance ~0.40, margin targeting, skewed [2,90]min
|
||||
timing + sleep + nudge logic, friend/DM blocking, name pool.
|
||||
Open details: exact delay distribution + parameters; margin band; name pool
|
||||
source; how the scheduler drives robot moves; metrics for tuning balance.
|
||||
|
||||
### Stage 6 — Gateway edge
|
||||
Scope: Connect/gRPC-Web (h2c), Telegram initData validation → session →
|
||||
`X-User-ID`, in-memory rate-limit, admin Basic-Auth passthrough, FlatBuffers
|
||||
transcoding, in-app push stream bridging backend `push` gRPC stream, email +
|
||||
ephemeral-guest paths.
|
||||
Open details: FlatBuffers schema layout + message_type catalog; rate-limit
|
||||
classes/limits; admin surface routing; session cache shape at the gateway.
|
||||
|
||||
### Stage 7 — UI
|
||||
Scope: plain Svelte + Vite static; Connect-web + FlatBuffers client; lobby (my
|
||||
games, profile tabs); board (HTML5/CSS grid, drag-n-drop, no assets); chat;
|
||||
hint/word-check; in-app stream; i18n en/ru; in-memory session (+IndexedDB if
|
||||
available); Capacitor-ready structure.
|
||||
Open details: detailed game-board UX (deferred by the owner to this stage);
|
||||
client routing; offline/refresh behaviour; design system / theming.
|
||||
|
||||
### Stage 8 — Telegram integration
|
||||
Scope: bot side-service, deep-link invites, platform push (your-turn / nudge),
|
||||
Mini App launch/auth; backend↔platform internal API.
|
||||
Open details: bot framework/library; deep-link scheme; push message templates;
|
||||
internal API contract; Mini App hosting/origin.
|
||||
|
||||
### Stage 9 — Admin & dictionary ops
|
||||
Scope: admin endpoints (users, games, complaint review queue, dictionary
|
||||
versions + reload), complaint→dictionary update pipeline.
|
||||
Open details: whether a server-rendered console is wanted or JSON-only; the
|
||||
dictionary rebuild/deploy pipeline; complaint resolution workflow.
|
||||
|
||||
### Stage 10 — Account linking & merge
|
||||
Scope: link-via-confirm; merge-into-A (stats sum, transfer games/friends,
|
||||
dedupe). High blast-radius — focused regression tests.
|
||||
Open details: conflict resolution (active games on both, duplicate friends,
|
||||
display-name collisions); irreversibility/audit; confirm-flow per platform.
|
||||
|
||||
### Stage 11 — Polish
|
||||
Scope: observability dashboards, evidence-based performance work, prod
|
||||
build/deploy.
|
||||
Open details: deployment target/host; dashboards; load expectations.
|
||||
|
||||
## Refinements logged during implementation
|
||||
|
||||
- **Stage 0**: solver `replace` deferred to Stage 2 (nothing imports it yet;
|
||||
adding the path now would break CI, which checks out only this repo). Docker /
|
||||
compose deferred to a stage that has something to deploy. Trunk is `master`
|
||||
(owner preference); `feature/*` + PR from Stage 1; the genesis commit lands on
|
||||
`master` by necessity.
|
||||
@@ -8,13 +8,13 @@ supports English Scrabble, Russian Scrabble and Эрудит.
|
||||
|
||||
- **`gateway`** — the only public ingress: anti-abuse, platform authentication
|
||||
(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
|
||||
embeds the [`scrabble-solver`](../scrabble-solver) engine library in-process.
|
||||
- **`ui`** — pure-HTML5 client (plain Svelte + Vite), embeddable in platform
|
||||
webviews and packageable to native via Capacitor. *(added in a later stage)*
|
||||
- **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite) over Connect-RPC
|
||||
+ FlatBuffers, embeddable in platform webviews and packageable to native via
|
||||
Capacitor. See [`ui/README.md`](ui/README.md).
|
||||
- **`platform/*`** — per-platform side-services (e.g. the Telegram bot).
|
||||
*(added in a later stage)*
|
||||
|
||||
## Documentation (sources of truth)
|
||||
|
||||
@@ -22,24 +22,80 @@ supports English Scrabble, Russian Scrabble and Эрудит.
|
||||
security, cross-service contracts.
|
||||
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)) —
|
||||
per-domain user stories.
|
||||
- [`docs/TESTING.md`](docs/TESTING.md) — test layers and the per-stage CI gate.
|
||||
- [`PLAN.md`](PLAN.md) — the staged implementation plan and stage tracker.
|
||||
- [`CLAUDE.md`](CLAUDE.md) — project guide and the mandatory per-stage workflow.
|
||||
- [`docs/TESTING.md`](docs/TESTING.md) — test layers and the CI gate.
|
||||
- [`CLAUDE.md`](CLAUDE.md) — project guide and development workflow.
|
||||
|
||||
## Build & test
|
||||
|
||||
```sh
|
||||
go build ./backend/... # per module (the workspace spans several modules)
|
||||
go vet ./backend/...
|
||||
go build ./backend/... ./pkg/... ./gateway/... # per module (the workspace spans several)
|
||||
go vet ./backend/... ./pkg/... ./gateway/...
|
||||
gofmt -l . # must print nothing
|
||||
go test -count=1 ./backend/...
|
||||
go test -count=1 ./backend/... ./pkg/... ./gateway/... # unit tests
|
||||
go test -tags=integration -count=1 -p=1 ./backend/... # + Postgres (needs Docker)
|
||||
```
|
||||
|
||||
The `integration`-tagged tests start a throwaway `postgres:17-alpine` container
|
||||
via testcontainers-go and require a reachable Docker daemon; they live in the
|
||||
`backend` module. The wire contracts in `pkg` and the Connect edge in `gateway`
|
||||
have committed generated code (regenerate dev-time with `make -C pkg gen` /
|
||||
`make -C gateway gen`).
|
||||
|
||||
## Run the backend locally
|
||||
|
||||
The backend now owns persistence, so it needs Postgres and applies its embedded
|
||||
migrations at startup:
|
||||
|
||||
```sh
|
||||
go run ./backend/cmd/backend # serves /healthz and /readyz on :8080
|
||||
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
|
||||
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
|
||||
go run ./backend/cmd/backend # HTTP API + probes on :8080, push gRPC on :9090
|
||||
```
|
||||
|
||||
Configuration is read from the environment: `BACKEND_HTTP_ADDR` (default
|
||||
`:8080`), `BACKEND_LOG_LEVEL` (`debug|info|warn|error`, default `info`).
|
||||
## Run the gateway locally
|
||||
|
||||
The gateway is the public edge; point it at a running backend:
|
||||
|
||||
```sh
|
||||
GATEWAY_BACKEND_HTTP_URL=http://localhost:8080 \
|
||||
GATEWAY_BACKEND_GRPC_ADDR=localhost:9090 \
|
||||
go run ./gateway/cmd/gateway # Connect/h2c edge on :8081
|
||||
```
|
||||
|
||||
Key environment: `BACKEND_HTTP_ADDR` (default `:8080`), `BACKEND_LOG_LEVEL`
|
||||
(`debug|info|warn|error`, default `info`), `BACKEND_POSTGRES_DSN` (**required**).
|
||||
The full configuration surface and the go-jet regeneration step live in
|
||||
[`backend/README.md`](backend/README.md).
|
||||
|
||||
## Run the UI locally
|
||||
|
||||
```sh
|
||||
cd ui && pnpm install
|
||||
pnpm start # mock mode: lobby -> game with no backend, on http://localhost:5173
|
||||
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
|
||||
smoke vs the mock), `pnpm build` (static bundle). Details — including the committed
|
||||
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"]
|
||||
@@ -0,0 +1,292 @@
|
||||
# backend
|
||||
|
||||
Internal-only domain service for the Scrabble platform (module `scrabble/backend`).
|
||||
It owns identity/sessions, accounts, the lobby, game runtime, robot, chat, history
|
||||
and administration. Its only network consumers are the `gateway` and the platform
|
||||
side-services; it is never exposed publicly.
|
||||
|
||||
The backend provides the foundation: configuration, the HTTP listener with the
|
||||
`/api/v1` route-group skeleton and probes, the Postgres pool with embedded goose
|
||||
migrations, OpenTelemetry wiring, an in-memory session cache, and the durable
|
||||
accounts / identities / sessions data model. The session and account REST
|
||||
endpoints live in the `gateway`; the backend ships the store/service layer they
|
||||
call.
|
||||
|
||||
`internal/engine` is the in-process bridge to the `scrabble-solver`
|
||||
library: a versioned dictionary registry, a deterministic tile bag, and a pure
|
||||
rules `Game` (legal plays, passes, exchanges, resignations and end-condition
|
||||
detection) that emits dictionary-independent move records. It is a library only;
|
||||
the game domain wires it into the process.
|
||||
|
||||
`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
|
||||
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
|
||||
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
|
||||
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
|
||||
the engine it is a service/store layer; the HTTP surface lives in the `gateway`.
|
||||
|
||||
The lobby and social fabric. `internal/lobby` runs **auto-match** — `Enqueue` opens a
|
||||
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
|
||||
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
|
||||
messages are length-capped, content-filtered (no links/emails/phone numbers,
|
||||
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
|
||||
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
|
||||
(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
|
||||
but their live delivery, and all REST endpoints, live in the `gateway`; the
|
||||
services are exposed via `Server` accessors for those handlers.
|
||||
|
||||
The robot opponent (`internal/robot`). A pool of durable accounts —
|
||||
each a `kind='robot'` identity, provisioned at startup with chat and friend
|
||||
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
|
||||
`internal/engine` imports the solver): it decides once per game whether to play to
|
||||
win (≈ 40%), targets a small score margin — with an occasional off-strategy move that tapers to
|
||||
none as the bag empties — and times its moves with a move-number-aware
|
||||
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
|
||||
behaviour — all derived deterministically from the game seed, so it keeps no extra
|
||||
state. In a dead-drawn endgame — the last two journal moves are both passes, so the robot is bound
|
||||
to pass again — it shortens that delay to a `[0.8, 1.5]×` band around the human's last-move think
|
||||
time (the gap between the last two moves), clamped to `[30 s, 8 min]` and `min`-ed with the normal
|
||||
delay, so a decided game is not dragged out while the robot never moves slower than usual. A background **reaper** seats a pooled robot (matching the game's language) in any open
|
||||
game whose wait window — a fixed **90 s** plus a random **0–90 s** (so **90–180 s**) — has
|
||||
elapsed, and the waiting starter is told an opponent took the seat by an in-app
|
||||
**opponent_joined** push (carrying their refreshed game state) that fills the opponent card and
|
||||
re-enables resign and chat in place.
|
||||
|
||||
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
|
||||
`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a
|
||||
slice of authenticated `/api/v1/user` operations (profile, submit play, game
|
||||
state, lobby enqueue, chat). The social/account/history operations under
|
||||
`/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
|
||||
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
|
||||
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`,
|
||||
`stats`, and `games/:id/gcg` (finished-only). The `internal/notify` hub feeds a
|
||||
second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
|
||||
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
|
||||
gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's
|
||||
Telegram `external_id`, language and `notifications_in_app_only` flag) lets the gateway
|
||||
route out-of-app push to the Telegram bot over the gateway bot-link; the Telegram login
|
||||
seeds a new account's language and display name from the launch fields, and the
|
||||
`accounts.notifications_in_app_only` flag (default true).
|
||||
The gateway-only `POST /api/v1/internal/chat-access` resolves a Telegram identity (the
|
||||
bot's join-time query) or an account id (a `chat_access_changed` event) to its
|
||||
**moderated-chat write eligibility** — `registered AND NOT suspended AND NOT chat_muted`.
|
||||
That event is emitted on an admin block/unblock, a `chat_muted` role grant/revoke, or — via
|
||||
the `account.SuspensionSweeper` started in `cmd/backend` — a temporary block lapsing;
|
||||
`chat_muted` is an `account.KnownRoles` entry, a chat-only mute distinct from the game
|
||||
suspension (which dominates it).
|
||||
`accounts.is_guest` marks an ephemeral guest — a durable row
|
||||
with no identity, excluded from statistics. The server-rendered
|
||||
**admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`;
|
||||
the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the
|
||||
**complaint resolution** lifecycle (the `complaints` `disposition`/`resolution_note`/
|
||||
`resolved_at`/`applied_in_version` columns + the `status` CHECK) feeding a dictionary-change
|
||||
pipeline, the online **dictionary update** (upload the `scrabble-dawg-vX.Y.Z.tar.gz` release
|
||||
archive, preview the per-variant word diff, then install + activate — `internal/dictadmin` +
|
||||
`engine.DiffWords` / `Registry.LoadAvailable`, written to per-version subdirectories of the
|
||||
`BACKEND_DICT_DIR` volume with the active version persisted in `dictionary_state`), and operator **broadcasts** via a
|
||||
backend client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) that calls the gateway's
|
||||
**bot-link relay** — each broadcast renders through the bot in an operator-chosen language
|
||||
and the relay awaits the bot's delivery ack. There is one bot,
|
||||
so `/internal/push-target` returns the recipient's `preferred_language` as the render
|
||||
language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` +
|
||||
`/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional
|
||||
window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches
|
||||
the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0
|
||||
&& !no_banner` role, the message language picked by `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.
|
||||
|
||||
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
||||
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
|
||||
the two are merged in one transaction (`internal/accountmerge`) — stats and the hint
|
||||
wallet summed, `paid_account` ORed, identities/games/chat/complaints transferred,
|
||||
friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (so a
|
||||
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
|
||||
durable owner — then the durable account wins and a fresh session is minted for it.
|
||||
The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the
|
||||
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
|
||||
|
||||
```
|
||||
cmd/backend/ # entrypoint: telemetry -> db+migrate -> registry -> cache -> game+sweeper -> robot pool+driver -> lobby+social -> server
|
||||
cmd/jetgen/ # dev tool: regenerate go-jet code from a throwaway container
|
||||
internal/config/ # env configuration (composes postgres + telemetry + game config)
|
||||
internal/telemetry/ # OpenTelemetry providers + per-request timing middleware
|
||||
internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations
|
||||
migrations/ # embedded *.sql (goose), schema `backend`
|
||||
jet/ # generated go-jet models + table builders (committed)
|
||||
internal/account/ # durable accounts + platform/email identities (store) + email/identity link primitives
|
||||
internal/accountmerge/ # single-transaction merge of a secondary account into a primary
|
||||
internal/link/ # link/merge orchestrator over account + accountmerge + session
|
||||
internal/session/ # opaque tokens, sessions store, write-through cache, service (incl. RevokeAllForAccount)
|
||||
internal/server/ # gin engine, route groups, X-User-ID middleware, probes
|
||||
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
|
||||
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/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/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
|
||||
internal/ads/ # advertising banner: campaigns + bilingual messages + display timings, weighted-rotation feed (ActiveSet)
|
||||
internal/connector/ # backend gRPC client to the 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)
|
||||
|
||||
| Variable | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `BACKEND_HTTP_ADDR` | `:8080` | HTTP (REST) listen address. |
|
||||
| `BACKEND_GRPC_ADDR` | `:9090` | gRPC listen address for the live-event push stream to the gateway. |
|
||||
| `BACKEND_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error`. |
|
||||
| `BACKEND_POSTGRES_DSN` | — | **Required.** pgx/libpq URL; must pin `search_path=backend`. |
|
||||
| `BACKEND_POSTGRES_MAX_OPEN_CONNS` | `25` | Pool max open connections. |
|
||||
| `BACKEND_POSTGRES_MAX_IDLE_CONNS` | `5` | Pool max idle connections. |
|
||||
| `BACKEND_POSTGRES_CONN_MAX_LIFETIME` | `30m` | Max connection lifetime. |
|
||||
| `BACKEND_POSTGRES_OPERATION_TIMEOUT` | `5s` | Connect attempt + `/readyz` ping bound. |
|
||||
| `BACKEND_SERVICE_NAME` | `scrabble-backend` | OpenTelemetry `service.name`. |
|
||||
| `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from the standard `OTEL_EXPORTER_OTLP_*`). |
|
||||
| `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp`. |
|
||||
| `BACKEND_DICT_DIR` | — | **Required.** Directory of committed `.dawg` dictionaries. |
|
||||
| `BACKEND_DICT_VERSION` | `v1` | Version label for the flat dictionary dir. Recorded in a `.seed_version` marker on first boot and authoritative after: on a seeded volume a changed value is ignored (it seeds only a fresh volume) — the seed-drift guard (ARCHITECTURE.md §5). |
|
||||
| `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. |
|
||||
| `BACKEND_GAME_CACHE_TTL` | `24h` | Idle window before a live game is evicted from cache. |
|
||||
| `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. |
|
||||
| `BACKEND_LOBBY_REAPER_INTERVAL` | `1s` | How often the substitution reaper scans for over-waited players. |
|
||||
| `BACKEND_ROBOT_DRIVE_INTERVAL` | `30s` | How often the robot driver scans for due robot turns. |
|
||||
| `BACKEND_SMTP_HOST` | — | Email relay host. **Empty selects the development log mailer** (the confirm-code is logged, not sent). |
|
||||
| `BACKEND_SMTP_PORT` | `587` | Email relay port. |
|
||||
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. |
|
||||
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
|
||||
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. |
|
||||
| `BACKEND_CONNECTOR_ADDR` | — | the gateway bot-link relay gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
|
||||
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
|
||||
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
|
||||
| `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged. |
|
||||
| `BACKEND_HIGHRATE_FLAG_WINDOW` | `10m` | The rolling window those rejections accumulate over. |
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
|
||||
# DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg):
|
||||
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.3.0/scrabble-dawg-v1.3.0.tar.gz | tar xz -C /tmp/dawg
|
||||
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
|
||||
BACKEND_DICT_DIR=/tmp/dawg \
|
||||
GOPRIVATE='gitea.iliadenisov.ru/*' \
|
||||
go run ./cmd/backend
|
||||
```
|
||||
|
||||
On boot the backend opens the pool, creates the `backend` schema if needed,
|
||||
applies the embedded migrations, loads the dictionaries into the engine registry
|
||||
(a hard dependency — a missing dictionary aborts the boot), warms the session
|
||||
cache and starts the game turn-timeout sweeper. `GET /healthz` reports liveness;
|
||||
`GET /readyz` reports 200 only when the database answers and the session cache is
|
||||
warmed.
|
||||
|
||||
## Migrations & generated code
|
||||
|
||||
Migrations are plain goose SQL under `internal/postgres/migrations` (sequential
|
||||
`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):
|
||||
|
||||
```sh
|
||||
go run ./cmd/jetgen # rewrites internal/postgres/jet against a temp container
|
||||
```
|
||||
|
||||
## Engine & dictionaries
|
||||
|
||||
`internal/engine` consumes `scrabble-solver` in-process as a **published, versioned
|
||||
module** (`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `go.mod`). Set
|
||||
`GOPRIVATE=gitea.iliadenisov.ru/*` so go fetches it directly from this Gitea (skipping
|
||||
the public proxy/checksum DB); no sibling checkout or `go.work` replace is needed (for
|
||||
local solver co-development you may add a temporary replace — see `go.work`).
|
||||
`github.com/iliadenisov/dafsa` (the DAWG loader) is a direct dependency. The dictionaries
|
||||
(`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)
|
||||
repo (one semver per set); the engine loads them by `(variant, dict_version)` from
|
||||
`BACKEND_DICT_DIR`. The backend loads them at startup as a hard dependency
|
||||
(a missing dictionary aborts the boot). The flat directory is the seed version,
|
||||
labelled `BACKEND_DICT_VERSION`; uploaded versions live in `<version>/`
|
||||
subdirectories the admin console writes and a restart re-loads. Because the DAWGs
|
||||
carry no embedded version, the first boot records the seed in a `.seed_version`
|
||||
marker that is authoritative after: on a seeded volume a changed `BACKEND_DICT_VERSION`
|
||||
is ignored (it seeds only a fresh volume) — the seed-drift guard — so a live contour's
|
||||
dictionary is changed through the console, never by bumping the build seed
|
||||
(ARCHITECTURE.md §5).
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
go test -count=1 ./... # unit tests (no Docker)
|
||||
go test -tags=integration -count=1 -p=1 ./... # Postgres-backed (needs Docker)
|
||||
```
|
||||
|
||||
Integration tests are guarded by the `integration` build tag and run against a
|
||||
throwaway `postgres:17-alpine` container; they fail loudly when Docker is absent
|
||||
rather than skipping. The `internal/engine` tests load the DAWGs from
|
||||
`BACKEND_DICT_DIR` (CI sets it to the extracted dictionary release artifact; locally it
|
||||
defaults to a `scrabble-solver/dawg` sibling checkout) and fail loudly when that directory
|
||||
is absent. `GOPRIVATE=gitea.iliadenisov.ru/*` is needed for go to fetch the pinned solver
|
||||
module.
|
||||
+249
-6
@@ -1,20 +1,48 @@
|
||||
// Command backend is the Scrabble platform's internal domain service. At this
|
||||
// stage it boots the HTTP listener with the infrastructure probes only; the
|
||||
// domain modules described in PLAN.md are added by later stages.
|
||||
// Command backend is the Scrabble platform's internal domain service. It boots
|
||||
// the OpenTelemetry runtime, opens the Postgres pool and applies migrations,
|
||||
// loads the dictionaries into the engine registry, warms the session cache,
|
||||
// constructs the game domain and starts its turn-timeout sweeper, constructs the
|
||||
// lobby and social domains, then serves the HTTP listener with the infrastructure
|
||||
// probes and the /api/v1 route group, behind which the domains expose their HTTP
|
||||
// endpoints to the gateway.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/accountmerge"
|
||||
"scrabble/backend/internal/ads"
|
||||
"scrabble/backend/internal/banview"
|
||||
"scrabble/backend/internal/config"
|
||||
"scrabble/backend/internal/connector"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/feedback"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/link"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/postgres"
|
||||
"scrabble/backend/internal/pushgrpc"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/robot"
|
||||
"scrabble/backend/internal/server"
|
||||
"scrabble/backend/internal/session"
|
||||
"scrabble/backend/internal/social"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
)
|
||||
|
||||
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
|
||||
const telemetryShutdownTimeout = 5 * time.Second
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
@@ -30,12 +58,227 @@ func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
srv := server.New(cfg.HTTPAddr, logger)
|
||||
if err := srv.Run(ctx); err != nil {
|
||||
logger.Fatal("backend: server terminated", zap.Error(err))
|
||||
if err := run(ctx, cfg, logger); err != nil {
|
||||
logger.Fatal("backend: terminated", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// run wires the process dependencies in order — telemetry, database (with
|
||||
// migrations), engine dictionaries, session cache, game domain (with its
|
||||
// turn-timeout sweeper), the robot opponent (pool + move driver) and the
|
||||
// matchmaking reaper, HTTP server — and blocks until ctx is cancelled.
|
||||
func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
// A cancellable child context so the first server (or signal) to stop tears
|
||||
// the rest down — the HTTP and gRPC listeners and every background worker
|
||||
// share it.
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
tel, err := telemetry.New(ctx, cfg.Telemetry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init telemetry: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), telemetryShutdownTimeout)
|
||||
defer cancel()
|
||||
if err := tel.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Warn("telemetry shutdown", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
if err := tel.StartRuntimeMetrics(); err != nil {
|
||||
logger.Warn("telemetry: start runtime metrics", zap.Error(err))
|
||||
}
|
||||
|
||||
db, err := postgres.Open(ctx, cfg.Postgres,
|
||||
postgres.WithTracerProvider(tel.TracerProvider()),
|
||||
postgres.WithMeterProvider(tel.MeterProvider()),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
if err := postgres.ApplyMigrations(ctx, db); err != nil {
|
||||
return fmt.Errorf("apply migrations: %w", err)
|
||||
}
|
||||
logger.Info("database migrations applied")
|
||||
|
||||
registry, err := engine.OpenWithVersions(cfg.Game.DictDir, cfg.Game.DictVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load dictionaries: %w", err)
|
||||
}
|
||||
defer func() { _ = registry.Close() }()
|
||||
logger.Info("dictionaries loaded",
|
||||
zap.String("dir", cfg.Game.DictDir),
|
||||
zap.String("version", cfg.Game.DictVersion))
|
||||
|
||||
// Admin console: an optional backend client to the Telegram connector
|
||||
// side-service for operator broadcasts. Unset (BACKEND_CONNECTOR_ADDR empty)
|
||||
// leaves broadcasts disabled — the console shows a "not configured" notice.
|
||||
var conn *connector.Client
|
||||
if cfg.ConnectorAddr != "" {
|
||||
conn, err = connector.New(cfg.ConnectorAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial connector: %w", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
logger.Info("connector client ready", zap.String("addr", cfg.ConnectorAddr))
|
||||
}
|
||||
|
||||
sessions := session.NewService(session.NewStore(db), session.NewCache())
|
||||
if err := sessions.Warm(ctx); err != nil {
|
||||
return fmt.Errorf("warm session cache: %w", err)
|
||||
}
|
||||
logger.Info("session cache warmed")
|
||||
|
||||
// The in-process live-event hub fans domain intents out to the gRPC push
|
||||
// stream. It is installed on every emitting service before any background
|
||||
// worker starts so robot moves and timeout sweeps also emit.
|
||||
hub := notify.NewHub(0)
|
||||
|
||||
accounts := account.NewStore(db)
|
||||
accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
|
||||
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.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
|
||||
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
|
||||
logger.Info("game turn-timeout sweeper started",
|
||||
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
|
||||
|
||||
// Reap abandoned guest accounts (no game seat, account age past
|
||||
// the retention window). Dependent rows fall away via ON DELETE CASCADE.
|
||||
guestReaper := account.NewGuestReaper(accounts, cfg.GuestRetention, logger)
|
||||
go guestReaper.Run(ctx, cfg.GuestReapInterval)
|
||||
logger.Info("guest reaper started",
|
||||
zap.Duration("interval", cfg.GuestReapInterval),
|
||||
zap.Duration("retention", cfg.GuestRetention))
|
||||
|
||||
// Re-evaluate moderated-chat write access when a temporary block self-expires:
|
||||
// no operator action fires then, so the sweeper emits the chat-access-changed
|
||||
// event for lapsed blocks and the gateway re-pushes the chat-gate command.
|
||||
chatSweeper := account.NewSuspensionSweeper(accounts, func(id uuid.UUID) {
|
||||
hub.Publish(notify.ChatAccessChanged(id))
|
||||
}, logger)
|
||||
go chatSweeper.Run(ctx)
|
||||
logger.Info("suspension expiry sweeper started", zap.Duration("interval", chatSweeper.Interval()))
|
||||
|
||||
// Lobby & social domains. Their REST and stream surface lives in the gateway,
|
||||
// so they are handed to the server (like the route groups) for the handlers.
|
||||
mailer := newMailer(cfg.SMTP, logger)
|
||||
emails := account.NewEmailService(accounts, mailer)
|
||||
// Account linking & merge: the orchestrator over the account, merge and
|
||||
// session layers. Wired to the /api/v1/user/link REST surface below.
|
||||
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
|
||||
socialSvc := social.NewService(social.NewStore(db), accounts, games)
|
||||
socialSvc.SetNotifier(hub)
|
||||
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)
|
||||
|
||||
// Robot opponent: provision its durable account pool (a hard startup
|
||||
// dependency, like the dictionaries) and start its move driver. The matchmaker
|
||||
// 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)
|
||||
if err := robots.EnsurePool(ctx); err != nil {
|
||||
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)
|
||||
logger.Info("robot driver started", zap.Duration("interval", cfg.Robot.DriveInterval))
|
||||
|
||||
matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, cfg.Lobby.RobotWaitJitter, logger)
|
||||
matchmaker.SetNotifier(hub)
|
||||
matchmaker.SetBlocker(socialSvc)
|
||||
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
|
||||
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
|
||||
invitations.SetNotifier(hub)
|
||||
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{
|
||||
Logger: logger,
|
||||
DB: db,
|
||||
PingTimeout: cfg.Postgres.OperationTimeout,
|
||||
SessionsReady: sessions.Ready,
|
||||
Sessions: sessions,
|
||||
Accounts: accounts,
|
||||
Games: games,
|
||||
Feedback: feedbackSvc,
|
||||
Social: socialSvc,
|
||||
Matchmaker: matchmaker,
|
||||
Invitations: invitations,
|
||||
Emails: emails,
|
||||
Links: links,
|
||||
Registry: registry,
|
||||
DictDir: cfg.Game.DictDir,
|
||||
Connector: conn,
|
||||
RateWatch: rateWatch,
|
||||
BanView: banView,
|
||||
Ads: adsSvc,
|
||||
Notifier: hub,
|
||||
})
|
||||
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
||||
|
||||
// Run the HTTP and gRPC push listeners together; the first to stop (a listen
|
||||
// error, or ctx cancellation on signal) tears down the other through cancel.
|
||||
logger.Info("servers starting",
|
||||
zap.String("http_addr", cfg.HTTPAddr),
|
||||
zap.String("grpc_addr", cfg.GRPCAddr))
|
||||
errc := make(chan error, 2)
|
||||
go func() { errc <- pushSrv.Run(ctx) }()
|
||||
go func() { errc <- srv.Run(ctx) }()
|
||||
err = <-errc
|
||||
cancel()
|
||||
<-errc
|
||||
return err
|
||||
}
|
||||
|
||||
// newMailer builds the confirm-code mailer: an SMTP relay when a host is
|
||||
// configured, otherwise the development log mailer (the code is logged, not sent).
|
||||
func newMailer(cfg account.SMTPConfig, logger *zap.Logger) account.Mailer {
|
||||
if cfg.Host == "" {
|
||||
logger.Info("email: using log mailer (BACKEND_SMTP_HOST unset)")
|
||||
return account.NewLogMailer(logger)
|
||||
}
|
||||
logger.Info("email: using SMTP relay", zap.String("host", cfg.Host))
|
||||
return account.NewSMTPMailer(cfg)
|
||||
}
|
||||
|
||||
// newLogger builds a production JSON logger at the given level.
|
||||
func newLogger(level string) (*zap.Logger, error) {
|
||||
var lvl zap.AtomicLevel
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Command jetgen regenerates the go-jet/v2 query-builder code under
|
||||
// backend/internal/postgres/jet against a transient PostgreSQL instance.
|
||||
//
|
||||
// Invoke as `go run ./cmd/jetgen` from inside the backend module. The tool is
|
||||
// not part of the runtime binary and requires a reachable Docker daemon.
|
||||
//
|
||||
// Steps:
|
||||
//
|
||||
// 1. start a postgres:17-alpine container via testcontainers-go
|
||||
// 2. open it with search_path=backend and apply the embedded goose migrations
|
||||
// 3. drop goose's bookkeeping table so jet does not generate a model for it
|
||||
// 4. run jet's PostgreSQL generator for schema=backend into internal/postgres/jet
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
jetpostgres "github.com/go-jet/jet/v2/generator/postgres"
|
||||
testcontainers "github.com/testcontainers/testcontainers-go"
|
||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
|
||||
"scrabble/backend/internal/postgres"
|
||||
)
|
||||
|
||||
const (
|
||||
postgresImage = "postgres:17-alpine"
|
||||
superuserName = "scrabble"
|
||||
superuserPassword = "scrabble"
|
||||
superuserDatabase = "scrabble_backend"
|
||||
backendSchema = "backend"
|
||||
containerStartup = 90 * time.Second
|
||||
jetOutputDirSuffix = "internal/postgres/jet"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(context.Background()); err != nil {
|
||||
log.Fatalf("jetgen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context) error {
|
||||
outputDir, err := jetOutputDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
container, err := tcpostgres.Run(ctx, postgresImage,
|
||||
tcpostgres.WithDatabase(superuserDatabase),
|
||||
tcpostgres.WithUsername(superuserName),
|
||||
tcpostgres.WithPassword(superuserPassword),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("database system is ready to accept connections").
|
||||
WithOccurrence(2).
|
||||
WithStartupTimeout(containerStartup),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start postgres container: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if termErr := testcontainers.TerminateContainer(container); termErr != nil {
|
||||
log.Printf("jetgen: terminate container: %v", termErr)
|
||||
}
|
||||
}()
|
||||
|
||||
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve container dsn: %w", err)
|
||||
}
|
||||
scopedDSN, err := dsnWithSearchPath(baseDSN, backendSchema)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg := postgres.DefaultConfig()
|
||||
cfg.DSN = scopedDSN
|
||||
|
||||
db, err := postgres.Open(ctx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open scoped pool: %w", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
if err := postgres.ApplyMigrations(ctx, db); err != nil {
|
||||
return fmt.Errorf("apply migrations: %w", err)
|
||||
}
|
||||
|
||||
// jet's generator wipes <outputDir>/<schema> on every run; ensure the
|
||||
// parent exists so the first run on a fresh checkout does not fail.
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
return fmt.Errorf("ensure jet output dir: %w", err)
|
||||
}
|
||||
|
||||
// Drop goose's bookkeeping table so jet does not generate code for it. The
|
||||
// container is never reused, so this only affects generation.
|
||||
if _, err := db.ExecContext(ctx, "DROP TABLE IF EXISTS goose_db_version"); err != nil {
|
||||
return fmt.Errorf("drop goose_db_version: %w", err)
|
||||
}
|
||||
|
||||
if err := jetpostgres.GenerateDB(db, backendSchema, outputDir); err != nil {
|
||||
return fmt.Errorf("jet generate: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("jetgen: generated jet code into %s (schema=%s)", outputDir, backendSchema)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dsnWithSearchPath rewrites the connection string so every new connection pins
|
||||
// search_path to the named schema and disables TLS for the local container.
|
||||
func dsnWithSearchPath(baseDSN, schema string) (string, error) {
|
||||
parsed, err := url.Parse(baseDSN)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse base dsn: %w", err)
|
||||
}
|
||||
values := parsed.Query()
|
||||
values.Set("search_path", schema)
|
||||
if values.Get("sslmode") == "" {
|
||||
values.Set("sslmode", "disable")
|
||||
}
|
||||
parsed.RawQuery = values.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
// jetOutputDir returns the absolute path jet writes into, anchored to the
|
||||
// backend module via runtime.Caller so the tool runs from any directory.
|
||||
func jetOutputDir() (string, error) {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("resolve runtime caller for jet output path")
|
||||
}
|
||||
// file = .../backend/cmd/jetgen/main.go
|
||||
moduleRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
|
||||
return filepath.Join(moduleRoot, jetOutputDirSuffix), nil
|
||||
}
|
||||
+84
-7
@@ -3,39 +3,116 @@ module scrabble/backend
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
gitea.iliadenisov.ru/developer/scrabble-solver v1.1.1
|
||||
github.com/XSAM/otelsql v0.42.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-jet/jet/v2 v2.14.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/iliadenisov/dafsa v1.1.0
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
github.com/testcontainers/testcontainers-go v0.42.0
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
|
||||
go.opentelemetry.io/otel/metric v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
go.uber.org/zap v1.27.1
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/flatbuffers v23.5.26+incompatible
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||
github.com/jackc/pgconn v1.14.3 // indirect
|
||||
github.com/jackc/pgio v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgproto3/v2 v2.3.3 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgtype v1.14.4 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/moby/api v1.54.2 // indirect
|
||||
github.com/moby/moby/client v0.4.1 // indirect
|
||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
mvdan.cc/xurls/v2 v2.6.0
|
||||
scrabble/pkg v0.0.0
|
||||
)
|
||||
|
||||
+364
-15
@@ -1,20 +1,75 @@
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/XSAM/otelsql v0.42.0 h1:Li0xF4eJUxG2e0x3D4rvRlys1f27yJKvjTh7ljkUP5o=
|
||||
github.com/XSAM/otelsql v0.42.0/go.mod h1:4mOrEv+cS1KmKzrvTktvJnstr5GtKSAK+QHvFR9OcpI=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-jet/jet/v2 v2.14.1 h1:wsfD9e7CGP9h46+IFNlftfncBcmVnKddikbTtapQM3M=
|
||||
github.com/go-jet/jet/v2 v2.14.1/go.mod h1:dqTAECV2Mo3S2NFjbm4vJ1aDruZjhaJ1RAAR8rGUkkc=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -23,73 +78,367 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
|
||||
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
|
||||
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
|
||||
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
|
||||
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
|
||||
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
|
||||
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
|
||||
github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=
|
||||
github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM=
|
||||
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
||||
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
||||
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
|
||||
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
|
||||
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
|
||||
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag=
|
||||
github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
|
||||
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
|
||||
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
|
||||
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
|
||||
github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
|
||||
github.com/jackc/pgtype v1.14.4 h1:fKuNiCumbKTAIxQwXfB/nsrnkEI6bPJrrSiMKgbJ2j8=
|
||||
github.com/jackc/pgtype v1.14.4/go.mod h1:aKeozOde08iifGosdJpz9MBZonJOUJxqNpPBcMJTlVA=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
|
||||
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
|
||||
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
|
||||
github.com/jackc/pgx/v4 v4.18.2/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw=
|
||||
github.com/jackc/pgx/v4 v4.18.3 h1:dE2/TrEsGX3RBprb3qryqSV9Y60iZN1C6i8IrmW9/BA=
|
||||
github.com/jackc/pgx/v4 v4.18.3/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
|
||||
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
|
||||
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
|
||||
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
||||
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
|
||||
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
|
||||
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
// Package account owns durable internal accounts and their platform/email
|
||||
// identities. First contact from a platform auto-provisions an account bound to
|
||||
// that identity. An ephemeral guest is also a durable account row (the sessions
|
||||
// and game_players foreign keys both require one) but carries no identity and is
|
||||
// flagged is_guest, which excludes it from statistics, friends and history.
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// Identity kinds recognised by the backend. Email is modelled as an identity
|
||||
// alongside platform identities; its confirmed flag is driven by the email
|
||||
// confirm-code flow. Robot is a synthetic kind: each pooled
|
||||
// robot opponent is a durable account bound to one robot identity.
|
||||
const (
|
||||
KindTelegram = "telegram"
|
||||
KindEmail = "email"
|
||||
KindRobot = "robot"
|
||||
)
|
||||
|
||||
// uniqueViolation is the PostgreSQL SQLSTATE for a unique-constraint violation.
|
||||
const uniqueViolation = "23505"
|
||||
|
||||
// ErrNotFound is returned when no account matches the lookup.
|
||||
var ErrNotFound = errors.New("account: not found")
|
||||
|
||||
// Account is a durable internal account. AwayStart and AwayEnd bound the daily
|
||||
// local-time window (in TimeZone) during which the player is asleep, so the
|
||||
// turn-timeout sweeper does not auto-resign them inside it. (The robot opponent's
|
||||
// own sleep is anchored to its human opponent's timezone with a per-game drift,
|
||||
// computed in internal/robot, not from a robot account's away window.) HintBalance
|
||||
// is the player's wallet of purchasable hints, spent after a game's per-seat
|
||||
// allowance.
|
||||
type Account struct {
|
||||
ID uuid.UUID
|
||||
DisplayName string
|
||||
PreferredLanguage string
|
||||
TimeZone string
|
||||
AwayStart time.Time
|
||||
AwayEnd time.Time
|
||||
HintBalance int
|
||||
BlockChat bool
|
||||
BlockFriendRequests bool
|
||||
// VariantPreferences is the set of game variants (engine.Variant stable labels:
|
||||
// "scrabble_en", "scrabble_ru", "erudit_ru") the player is willing to be matched
|
||||
// into. It gates the New Game picker, the matchmaker and the friend-invite the
|
||||
// player creates; an invited friend may still accept any variant. A new account
|
||||
// defaults to Erudit only. Never empty — enforced on update and by a DB check.
|
||||
VariantPreferences []string
|
||||
// IsGuest marks an ephemeral guest account: a durable row with no identity,
|
||||
// excluded from statistics, friends and history.
|
||||
IsGuest bool
|
||||
// NotificationsInAppOnly confines notifications to the in-app live stream when
|
||||
// true (the default): the platform side-service skips out-of-app push for the
|
||||
// account.
|
||||
NotificationsInAppOnly bool
|
||||
// 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
|
||||
// never lost when accounts are consolidated.
|
||||
PaidAccount bool
|
||||
// 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
|
||||
// foreign keys of a shared finished game stay valid.
|
||||
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
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Identity is one of an account's platform/email identities, surfaced on the
|
||||
// admin account-detail view. ExternalID is the platform user id (or the email
|
||||
// address for an email identity); Confirmed tracks the email confirm-code flow.
|
||||
type Identity struct {
|
||||
Kind string
|
||||
ExternalID string
|
||||
Confirmed bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Store is the Postgres-backed query surface for accounts and identities.
|
||||
type Store struct {
|
||||
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. Metrics default to a no-op meter until
|
||||
// SetMetrics installs the real one during startup wiring.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db, metrics: defaultAccountMetrics(), suspensions: newSuspensionCache()}
|
||||
}
|
||||
|
||||
// ProvisionByIdentity returns the account bound to (kind, externalID), creating
|
||||
// a fresh durable account and identity when none exists yet. It is safe under
|
||||
// concurrent callers: a losing race on the identity's unique constraint is
|
||||
// resolved by re-reading the winner's account. A platform identity is recorded
|
||||
// as confirmed; an email identity starts unconfirmed.
|
||||
func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
|
||||
return s.provision(ctx, kind, externalID, provisionSeed{})
|
||||
}
|
||||
|
||||
// ProvisionEmail returns the account owning the email identity externalID, creating
|
||||
// it (unconfirmed) on first contact with browserTZ — the client's detected "±HH:MM"
|
||||
// UTC offset — seeded into its time zone. Like ProvisionByIdentity it is race-safe
|
||||
// and leaves an existing account untouched, so a returning user's saved zone is never
|
||||
// overwritten. The email account is created here (the code-request step), not at the
|
||||
// later login, so this is where its zone is seeded.
|
||||
func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ string) (Account, error) {
|
||||
return s.provision(ctx, KindEmail, externalID, provisionSeed{timeZone: seedZone(browserTZ)})
|
||||
}
|
||||
|
||||
// ProvisionRobot provisions (or finds) the durable account backing a robot pool
|
||||
// member: a KindRobot identity carrying displayName, with chat blocked but friend
|
||||
// requests NOT blocked — a request to a robot is accepted as pending and, since the
|
||||
// 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,
|
||||
// collapsing a concurrent-create race on the identity unique constraint into a
|
||||
// re-read of the winner's account.
|
||||
func (s *Store) provision(ctx context.Context, kind, externalID string, seed provisionSeed) (Account, error) {
|
||||
acc, err := s.findByIdentity(ctx, kind, externalID)
|
||||
if err == nil {
|
||||
return acc, nil
|
||||
}
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
return Account{}, err
|
||||
}
|
||||
|
||||
acc, err = s.create(ctx, kind, externalID, seed)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
// A concurrent caller created the identity first; return theirs.
|
||||
return s.findByIdentity(ctx, kind, externalID)
|
||||
}
|
||||
return Account{}, err
|
||||
}
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
// provisionSeed carries the optional create-time profile seed for a brand-new
|
||||
// account (first contact). Empty fields fall back to the accounts table defaults,
|
||||
// so an unknown language keeps the 'en' default, an empty name keeps the ” default
|
||||
// and an empty time zone keeps the 'UTC' default.
|
||||
type provisionSeed struct {
|
||||
preferredLanguage string
|
||||
displayName string
|
||||
timeZone string
|
||||
}
|
||||
|
||||
// seedZone returns browserTZ when it is a well-formed zone to persist at account
|
||||
// creation (a "±HH:MM" offset or a loadable IANA name), else "" so the new account
|
||||
// falls back to the accounts table's 'UTC' default. The client reports the device's
|
||||
// detected offset deterministically; a bad value is dropped rather than guessed at.
|
||||
func seedZone(browserTZ string) string {
|
||||
if validZone(browserTZ) {
|
||||
return browserTZ
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// telegramSeed derives the create-time seed from Telegram launch fields: a
|
||||
// supported preferred language from languageCode (an ISO-639 code, possibly
|
||||
// region-tagged like "ru-RU"), and a display name. The name precedence is the real
|
||||
// name (firstName, sanitized to the editable format) → the @username taken verbatim
|
||||
// (already a valid handle, only trimmed and length-capped, never character-stripped)
|
||||
// → a generated placeholder in the seeded language (placeholderDisplayName), reached
|
||||
// only when firstName has no usable letters and no username is set.
|
||||
func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
||||
var seed provisionSeed
|
||||
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
||||
seed.preferredLanguage = lang
|
||||
}
|
||||
name := sanitizeDisplayName(firstName)
|
||||
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)
|
||||
if r := []rune(name); len(r) > maxDisplayName {
|
||||
name = strings.TrimRight(string(r[:maxDisplayName]), " ")
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
name = placeholderDisplayName(seed.preferredLanguage)
|
||||
}
|
||||
seed.displayName = name
|
||||
return seed
|
||||
}
|
||||
|
||||
// GetByID loads the account identified by id, or ErrNotFound when it is absent.
|
||||
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
|
||||
stmt := postgres.SELECT(table.Accounts.AllColumns).
|
||||
FROM(table.Accounts).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
|
||||
LIMIT(1)
|
||||
|
||||
var row model.Accounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
return Account{}, fmt.Errorf("account: get by id %s: %w", id, err)
|
||||
}
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
// IdentityExternalID returns the external_id of the account's identity of the
|
||||
// given kind, or ErrNotFound when the account has no such identity. The Telegram
|
||||
// side-service uses it (through the gateway push-target lookup) to address an
|
||||
// out-of-app notification to a recipient's Telegram chat.
|
||||
func (s *Store) IdentityExternalID(ctx context.Context, accountID uuid.UUID, kind string) (string, error) {
|
||||
stmt := postgres.SELECT(table.Identities.ExternalID).
|
||||
FROM(table.Identities).
|
||||
WHERE(
|
||||
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.Identities.Kind.EQ(postgres.String(kind))),
|
||||
).
|
||||
LIMIT(1)
|
||||
|
||||
var row model.Identities
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return "", fmt.Errorf("account: identity external id (%s, %s): %w", accountID, kind, err)
|
||||
}
|
||||
return row.ExternalID, nil
|
||||
}
|
||||
|
||||
// Identities returns the account's platform/email identities, oldest first, for
|
||||
// the admin account-detail view.
|
||||
func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity, error) {
|
||||
stmt := postgres.SELECT(table.Identities.AllColumns).
|
||||
FROM(table.Identities).
|
||||
WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))).
|
||||
ORDER_BY(table.Identities.CreatedAt.ASC())
|
||||
var rows []model.Identities
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("account: list identities %s: %w", accountID, err)
|
||||
}
|
||||
out := make([]Identity, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, Identity{Kind: r.Kind, ExternalID: r.ExternalID, Confirmed: r.Confirmed, CreatedAt: r.CreatedAt})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListAccounts returns accounts for the admin user list, newest first, paginated
|
||||
// by limit and offset.
|
||||
func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) {
|
||||
stmt := postgres.SELECT(table.Accounts.AllColumns).
|
||||
FROM(table.Accounts).
|
||||
ORDER_BY(table.Accounts.CreatedAt.DESC()).
|
||||
LIMIT(int64(limit)).
|
||||
OFFSET(int64(offset))
|
||||
var rows []model.Accounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("account: list accounts: %w", err)
|
||||
}
|
||||
out := make([]Account, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, modelToAccount(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CountAccounts returns the total number of accounts, for admin-list pagination.
|
||||
func (s *Store) CountAccounts(ctx context.Context) (int, error) {
|
||||
stmt := postgres.SELECT(postgres.COUNT(table.Accounts.AccountID).AS("count")).
|
||||
FROM(table.Accounts)
|
||||
var dest struct{ Count int64 }
|
||||
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
|
||||
return 0, fmt.Errorf("account: count accounts: %w", err)
|
||||
}
|
||||
return int(dest.Count), nil
|
||||
}
|
||||
|
||||
// AccountByIdentity returns the account bound to (kind, externalID), or ErrNotFound
|
||||
// when none exists. Unlike ProvisionByIdentity it never creates one: the chat-access
|
||||
// resolver uses it to tell a registered Telegram user (eligible to be granted chat
|
||||
// write access) from an unregistered one (left muted).
|
||||
func (s *Store) AccountByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
|
||||
return s.findByIdentity(ctx, kind, externalID)
|
||||
}
|
||||
|
||||
// findByIdentity joins identities to accounts and returns the matching account,
|
||||
// or ErrNotFound.
|
||||
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
|
||||
stmt := postgres.SELECT(table.Accounts.AllColumns).
|
||||
FROM(table.Accounts.INNER_JOIN(
|
||||
table.Identities,
|
||||
table.Identities.AccountID.EQ(table.Accounts.AccountID),
|
||||
)).
|
||||
WHERE(
|
||||
table.Identities.Kind.EQ(postgres.String(kind)).
|
||||
AND(table.Identities.ExternalID.EQ(postgres.String(externalID))),
|
||||
).
|
||||
LIMIT(1)
|
||||
|
||||
var row model.Accounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
return Account{}, fmt.Errorf("account: find by identity (%s, %s): %w", kind, externalID, err)
|
||||
}
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
// create inserts a new account (seeded from seed) and its first identity inside
|
||||
// one transaction and returns the persisted account row.
|
||||
func (s *Store) create(ctx context.Context, kind, externalID string, seed provisionSeed) (Account, error) {
|
||||
accountID, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("account: new account id: %w", err)
|
||||
}
|
||||
identityID, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("account: new identity id: %w", err)
|
||||
}
|
||||
|
||||
var created Account
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
// Seed the new row's display name, language and time zone (first contact); an
|
||||
// empty seed reproduces the table defaults ('', 'en' and 'UTC') the other callers
|
||||
// relied on, so their behaviour is unchanged. time_zone is written explicitly (the
|
||||
// detected offset, or 'UTC' equal to the column default) so a seeded zone lands at
|
||||
// creation while an unseeded one stays UTC.
|
||||
lang := seed.preferredLanguage
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
tz := seed.timeZone
|
||||
if tz == "" {
|
||||
tz = "UTC"
|
||||
}
|
||||
insertAccount := table.Accounts.
|
||||
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone).
|
||||
VALUES(accountID, seed.displayName, lang, tz).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
var row model.Accounts
|
||||
if err := insertAccount.QueryContext(ctx, tx, &row); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
insertIdentity := table.Identities.INSERT(
|
||||
table.Identities.IdentityID,
|
||||
table.Identities.AccountID,
|
||||
table.Identities.Kind,
|
||||
table.Identities.ExternalID,
|
||||
table.Identities.Confirmed,
|
||||
).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram)
|
||||
if _, err := insertIdentity.ExecContext(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
created = modelToAccount(row)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// guestDisplayName is the display name stamped on a freshly provisioned guest.
|
||||
const guestDisplayName = "Guest"
|
||||
|
||||
// ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying
|
||||
// no identity, flagged is_guest, so it can hold a session and a game seat (both
|
||||
// foreign-key the accounts table) while being excluded from statistics, friends
|
||||
// and history. Guests are not reused — each bootstrap mints a new account. browserTZ
|
||||
// (the client's detected "±HH:MM" UTC offset) seeds the guest's time zone, falling
|
||||
// back to the 'UTC' default when empty or malformed.
|
||||
func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account, error) {
|
||||
accountID, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("account: new guest id: %w", err)
|
||||
}
|
||||
tz := seedZone(browserTZ)
|
||||
if tz == "" {
|
||||
tz = "UTC"
|
||||
}
|
||||
stmt := table.Accounts.
|
||||
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone).
|
||||
VALUES(accountID, guestDisplayName, true, tz).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
var row model.Accounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
return Account{}, fmt.Errorf("account: provision guest: %w", err)
|
||||
}
|
||||
s.metrics.recordCreated(ctx, kindGuest)
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
// SpendHint atomically decrements the account's hint wallet by one, returning
|
||||
// true when a hint was spent and false when the balance was already empty. The
|
||||
// guarded UPDATE keeps it safe under concurrent spends across the player's games.
|
||||
func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
stmt := table.Accounts.
|
||||
UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt).
|
||||
SET(table.Accounts.HintBalance.SUB(postgres.Int(1)), postgres.TimestampzT(time.Now().UTC())).
|
||||
WHERE(
|
||||
table.Accounts.AccountID.EQ(postgres.UUID(id)).
|
||||
AND(table.Accounts.HintBalance.GT(postgres.Int(0))),
|
||||
)
|
||||
res, err := stmt.ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("account: spend hint %s: %w", id, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("account: spend hint rows %s: %w", id, err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// GrantHints adds n hints to the account's wallet and returns the new balance. n must be
|
||||
// positive: the additive update can only raise the balance, never lower it, so it enforces the
|
||||
// admin console's raise-only rule by construction and stays correct under a concurrent SpendHint.
|
||||
// It returns ErrNotFound when no account matches.
|
||||
func (s *Store) GrantHints(ctx context.Context, id uuid.UUID, n int) (int, error) {
|
||||
if n <= 0 {
|
||||
return 0, fmt.Errorf("account: grant hints %s: n must be positive, got %d", id, n)
|
||||
}
|
||||
stmt := table.Accounts.
|
||||
UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt).
|
||||
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)))
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("account: clear high-rate flag %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// modelToAccount projects a generated model row into the public Account struct.
|
||||
func modelToAccount(row model.Accounts) Account {
|
||||
var mergedInto uuid.UUID
|
||||
if row.MergedInto != nil {
|
||||
mergedInto = *row.MergedInto
|
||||
}
|
||||
var flaggedHighRateAt time.Time
|
||||
if row.FlaggedHighRateAt != nil {
|
||||
flaggedHighRateAt = *row.FlaggedHighRateAt
|
||||
}
|
||||
return Account{
|
||||
ID: row.AccountID,
|
||||
DisplayName: row.DisplayName,
|
||||
PreferredLanguage: row.PreferredLanguage,
|
||||
VariantPreferences: []string(row.VariantPreferences),
|
||||
TimeZone: row.TimeZone,
|
||||
AwayStart: row.AwayStart,
|
||||
AwayEnd: row.AwayEnd,
|
||||
HintBalance: int(row.HintBalance),
|
||||
BlockChat: row.BlockChat,
|
||||
BlockFriendRequests: row.BlockFriendRequests,
|
||||
IsGuest: row.IsGuest,
|
||||
NotificationsInAppOnly: row.NotificationsInAppOnly,
|
||||
PaidAccount: row.PaidAccount,
|
||||
MergedInto: mergedInto,
|
||||
FlaggedHighRateAt: flaggedHighRateAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// isUniqueViolation reports whether err is a PostgreSQL unique-constraint
|
||||
// violation, used to collapse a concurrent-provision race into a re-read.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == uniqueViolation
|
||||
}
|
||||
|
||||
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
|
||||
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
const (
|
||||
// emailCodeTTL bounds how long an issued confirm-code stays valid.
|
||||
emailCodeTTL = 15 * time.Minute
|
||||
// emailCodeMaxAttempts caps wrong-code submissions before a code is dead.
|
||||
emailCodeMaxAttempts = 5
|
||||
)
|
||||
|
||||
// Errors returned by the email confirm-code flow.
|
||||
var (
|
||||
// ErrInvalidEmail is returned for an unparseable email address.
|
||||
ErrInvalidEmail = errors.New("account: invalid email address")
|
||||
// ErrEmailTaken is returned when the email is already confirmed by another
|
||||
// account; binding it would be a merge, which the link/merge flow owns.
|
||||
ErrEmailTaken = errors.New("account: email already confirmed by another account")
|
||||
// ErrAlreadyConfirmed is returned when the email is already confirmed by the
|
||||
// requesting account.
|
||||
ErrAlreadyConfirmed = errors.New("account: email already confirmed for this account")
|
||||
// ErrNoPendingCode is returned when no live confirm-code exists to verify.
|
||||
ErrNoPendingCode = errors.New("account: no pending confirmation code")
|
||||
// ErrCodeExpired is returned when the confirm-code has passed its TTL.
|
||||
ErrCodeExpired = errors.New("account: confirmation code expired")
|
||||
// ErrTooManyAttempts is returned when the code is locked after too many tries.
|
||||
ErrTooManyAttempts = errors.New("account: too many confirmation attempts")
|
||||
// ErrCodeMismatch is returned when the submitted code does not match.
|
||||
ErrCodeMismatch = errors.New("account: confirmation code does not match")
|
||||
)
|
||||
|
||||
// EmailService runs the email confirm-code flow: it issues a 6-digit code over a
|
||||
// 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),
|
||||
// matching the session model. Binding an email already confirmed by a different
|
||||
// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
|
||||
// and using an email as a login reuses this mechanism.
|
||||
type EmailService struct {
|
||||
store *Store
|
||||
mailer Mailer
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewEmailService constructs an EmailService over store, sending via mailer.
|
||||
func NewEmailService(store *Store, mailer Mailer) *EmailService {
|
||||
return &EmailService{store: store, mailer: mailer, now: func() time.Time { return time.Now().UTC() }}
|
||||
}
|
||||
|
||||
// RequestCode issues a fresh confirm-code for email to accountID and mails it,
|
||||
// replacing any prior pending code for the same account and address. It returns
|
||||
// ErrInvalidEmail, ErrEmailTaken or ErrAlreadyConfirmed without sending.
|
||||
func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, email string) error {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
if owner == accountID {
|
||||
return ErrAlreadyConfirmed
|
||||
}
|
||||
return ErrEmailTaken
|
||||
}
|
||||
code, hash, err := generateCode()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
|
||||
return err
|
||||
}
|
||||
subject := "Your Scrabble confirmation code"
|
||||
body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
|
||||
return s.mailer.Send(ctx, addr, subject, body)
|
||||
}
|
||||
|
||||
// ConfirmCode verifies code for accountID and email. On success it attaches a
|
||||
// confirmed email identity and returns the account. It returns ErrNoPendingCode,
|
||||
// ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch (counting the attempt), or
|
||||
// ErrEmailTaken if the address was confirmed elsewhere in the meantime.
|
||||
func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, email, code string) (Account, error) {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
conf, err := s.store.latestPendingConfirmation(ctx, accountID, addr)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
if s.now().After(conf.expiresAt) {
|
||||
return Account{}, ErrCodeExpired
|
||||
}
|
||||
if conf.attempts >= emailCodeMaxAttempts {
|
||||
return Account{}, ErrTooManyAttempts
|
||||
}
|
||||
if hashCode(code) != conf.codeHash {
|
||||
if err := s.store.bumpConfirmationAttempts(ctx, conf.id); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
return Account{}, ErrCodeMismatch
|
||||
}
|
||||
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
return s.store.GetByID(ctx, accountID)
|
||||
}
|
||||
|
||||
// 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
|
||||
// the unauthenticated email-login entry point and, unlike RequestCode,
|
||||
// does not refuse an already-confirmed email — that is the ordinary returning-user
|
||||
// login. The code is mailed to the address, so only its real owner can complete
|
||||
// the login. On first contact browserTZ (the client's detected "±HH:MM" UTC offset)
|
||||
// seeds the new account's time zone. It returns the target account id for the
|
||||
// subsequent LoginWithCode.
|
||||
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ string) (uuid.UUID, error) {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ)
|
||||
if err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
code, hash, err := generateCode()
|
||||
if err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
if err := s.store.replacePendingConfirmation(ctx, acc.ID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
subject := "Your Scrabble login code"
|
||||
body := fmt.Sprintf("Your login code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
|
||||
if err := s.mailer.Send(ctx, addr, subject, body); err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
return acc.ID, nil
|
||||
}
|
||||
|
||||
// LoginWithCode verifies a login code for email and returns the owning account,
|
||||
// marking the email identity confirmed on first success (idempotent for a
|
||||
// returning user). It mirrors ConfirmCode's checks but updates the existing
|
||||
// identity rather than inserting one, since RequestLoginCode already provisioned
|
||||
// it. It returns ErrNotFound when no account owns the email.
|
||||
func (s *EmailService) LoginWithCode(ctx context.Context, email, code string) (Account, error) {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
acc, err := s.store.findByIdentity(ctx, KindEmail, addr)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
conf, err := s.store.latestPendingConfirmation(ctx, acc.ID, addr)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
if s.now().After(conf.expiresAt) {
|
||||
return Account{}, ErrCodeExpired
|
||||
}
|
||||
if conf.attempts >= emailCodeMaxAttempts {
|
||||
return Account{}, ErrTooManyAttempts
|
||||
}
|
||||
if hashCode(code) != conf.codeHash {
|
||||
if err := s.store.bumpConfirmationAttempts(ctx, conf.id); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
return Account{}, ErrCodeMismatch
|
||||
}
|
||||
if err := s.store.confirmEmailLogin(ctx, conf.id, acc.ID, addr, s.now()); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
return s.store.GetByID(ctx, acc.ID)
|
||||
}
|
||||
|
||||
// emailConfirmation is a pending confirm-code row in domain form.
|
||||
type emailConfirmation struct {
|
||||
id uuid.UUID
|
||||
codeHash string
|
||||
expiresAt time.Time
|
||||
attempts int
|
||||
}
|
||||
|
||||
// confirmedEmailAccount returns the account that holds a confirmed email identity
|
||||
// for email and true, or (zero, false) when none does.
|
||||
func (s *Store) confirmedEmailAccount(ctx context.Context, email string) (uuid.UUID, bool, error) {
|
||||
stmt := postgres.SELECT(table.Identities.AccountID).
|
||||
FROM(table.Identities).
|
||||
WHERE(
|
||||
table.Identities.Kind.EQ(postgres.String(KindEmail)).
|
||||
AND(table.Identities.ExternalID.EQ(postgres.String(email))).
|
||||
AND(table.Identities.Confirmed.EQ(postgres.Bool(true))),
|
||||
).LIMIT(1)
|
||||
var row model.Identities
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return uuid.UUID{}, false, nil
|
||||
}
|
||||
return uuid.UUID{}, false, fmt.Errorf("account: confirmed email owner %s: %w", email, err)
|
||||
}
|
||||
return row.AccountID, true, nil
|
||||
}
|
||||
|
||||
// replacePendingConfirmation clears any pending code for (accountID, email) and
|
||||
// inserts a fresh one, inside one transaction.
|
||||
func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash string, expiresAt time.Time) error {
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return fmt.Errorf("account: new confirmation id: %w", err)
|
||||
}
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
del := table.EmailConfirmations.DELETE().WHERE(
|
||||
table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.EmailConfirmations.Email.EQ(postgres.String(email))).
|
||||
AND(table.EmailConfirmations.ConsumedAt.IS_NULL()),
|
||||
)
|
||||
if _, err := del.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("clear pending confirmations: %w", err)
|
||||
}
|
||||
ins := table.EmailConfirmations.INSERT(
|
||||
table.EmailConfirmations.ConfirmationID, table.EmailConfirmations.AccountID,
|
||||
table.EmailConfirmations.Email, table.EmailConfirmations.CodeHash, table.EmailConfirmations.ExpiresAt,
|
||||
).VALUES(id, accountID, email, codeHash, expiresAt)
|
||||
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert confirmation: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// latestPendingConfirmation loads the newest unconsumed confirm-code for
|
||||
// (accountID, email), or ErrNoPendingCode.
|
||||
func (s *Store) latestPendingConfirmation(ctx context.Context, accountID uuid.UUID, email string) (emailConfirmation, error) {
|
||||
stmt := postgres.SELECT(table.EmailConfirmations.AllColumns).
|
||||
FROM(table.EmailConfirmations).
|
||||
WHERE(
|
||||
table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.EmailConfirmations.Email.EQ(postgres.String(email))).
|
||||
AND(table.EmailConfirmations.ConsumedAt.IS_NULL()),
|
||||
).ORDER_BY(table.EmailConfirmations.CreatedAt.DESC()).LIMIT(1)
|
||||
var row model.EmailConfirmations
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return emailConfirmation{}, ErrNoPendingCode
|
||||
}
|
||||
return emailConfirmation{}, fmt.Errorf("account: load confirmation: %w", err)
|
||||
}
|
||||
return emailConfirmation{
|
||||
id: row.ConfirmationID,
|
||||
codeHash: row.CodeHash,
|
||||
expiresAt: row.ExpiresAt,
|
||||
attempts: int(row.Attempts),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// bumpConfirmationAttempts increments a code's wrong-attempt counter by one.
|
||||
func (s *Store) bumpConfirmationAttempts(ctx context.Context, id uuid.UUID) error {
|
||||
stmt := table.EmailConfirmations.
|
||||
UPDATE(table.EmailConfirmations.Attempts).
|
||||
SET(table.EmailConfirmations.Attempts.ADD(postgres.Int(1))).
|
||||
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(id)))
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("account: bump confirmation attempts: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// confirmEmailIdentity consumes the code and inserts a confirmed email identity,
|
||||
// inside one transaction. A unique-constraint violation means the address was
|
||||
// confirmed by another account first, surfaced as ErrEmailTaken.
|
||||
func (s *Store) confirmEmailIdentity(ctx context.Context, confirmationID, accountID uuid.UUID, email string, now time.Time) error {
|
||||
identityID, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return fmt.Errorf("account: new identity id: %w", err)
|
||||
}
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
upd := table.EmailConfirmations.
|
||||
UPDATE(table.EmailConfirmations.ConsumedAt).
|
||||
SET(postgres.TimestampzT(now)).
|
||||
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(confirmationID)))
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("consume confirmation: %w", err)
|
||||
}
|
||||
ins := table.Identities.INSERT(
|
||||
table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind,
|
||||
table.Identities.ExternalID, table.Identities.Confirmed,
|
||||
).VALUES(identityID, accountID, KindEmail, email, true)
|
||||
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return ErrEmailTaken
|
||||
}
|
||||
return fmt.Errorf("account: confirm email identity: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// confirmEmailLogin consumes the login code and marks the existing email
|
||||
// identity confirmed, inside one transaction. The identity already exists (a
|
||||
// login provisioned it), so this updates rather than inserts and is idempotent
|
||||
// for a returning user whose identity is already confirmed.
|
||||
func (s *Store) confirmEmailLogin(ctx context.Context, confirmationID, accountID uuid.UUID, email string, now time.Time) error {
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
upd := table.EmailConfirmations.
|
||||
UPDATE(table.EmailConfirmations.ConsumedAt).
|
||||
SET(postgres.TimestampzT(now)).
|
||||
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(confirmationID)))
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("consume login code: %w", err)
|
||||
}
|
||||
confirm := table.Identities.
|
||||
UPDATE(table.Identities.Confirmed).
|
||||
SET(postgres.Bool(true)).
|
||||
WHERE(
|
||||
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))).
|
||||
AND(table.Identities.ExternalID.EQ(postgres.String(email))),
|
||||
)
|
||||
if _, err := confirm.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("confirm email identity: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeEmail parses and lower-cases an email address, or returns ErrInvalidEmail.
|
||||
func normalizeEmail(email string) (string, error) {
|
||||
addr, err := mail.ParseAddress(strings.TrimSpace(email))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %q", ErrInvalidEmail, email)
|
||||
}
|
||||
return strings.ToLower(addr.Address), nil
|
||||
}
|
||||
|
||||
// generateCode returns a random 6-digit code and its SHA-256 hex hash.
|
||||
func generateCode() (code, hash string, err error) {
|
||||
n, err := crand.Int(crand.Reader, big.NewInt(1_000_000))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("account: generate code: %w", err)
|
||||
}
|
||||
code = fmt.Sprintf("%06d", n.Int64())
|
||||
return code, hashCode(code), nil
|
||||
}
|
||||
|
||||
// hashCode returns the hex-encoded SHA-256 of a confirm-code.
|
||||
func hashCode(code string) string {
|
||||
sum := sha256.Sum256([]byte(code))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"lowercases", "User@Example.COM", "user@example.com", false},
|
||||
{"trims", " a@b.io ", "a@b.io", false},
|
||||
{"strips display name", "Jane Doe <jane@x.org>", "jane@x.org", false},
|
||||
{"empty", "", "", true},
|
||||
{"no at sign", "notanemail", "", true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := normalizeEmail(tc.in)
|
||||
if tc.wantErr {
|
||||
if !errors.Is(err, ErrInvalidEmail) {
|
||||
t.Fatalf("err = %v, want ErrInvalidEmail", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCodeFormat(t *testing.T) {
|
||||
sixDigits := regexp.MustCompile(`^\d{6}$`)
|
||||
for range 50 {
|
||||
code, hash, err := generateCode()
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
if !sixDigits.MatchString(code) {
|
||||
t.Fatalf("code %q is not exactly six digits", code)
|
||||
}
|
||||
if hash != hashCode(code) {
|
||||
t.Errorf("returned hash does not match hashCode(%q)", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashCodeStable(t *testing.T) {
|
||||
if hashCode("123456") != hashCode("123456") {
|
||||
t.Fatal("hashCode is not deterministic")
|
||||
}
|
||||
if hashCode("123456") == hashCode("654321") {
|
||||
t.Fatal("distinct codes must not share a hash")
|
||||
}
|
||||
if got := len(hashCode("000000")); got != 64 {
|
||||
t.Errorf("hex SHA-256 length = %d, want 64", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// ErrIdentityTaken is returned when a platform identity being linked already
|
||||
// belongs to another account; the caller turns it into a merge.
|
||||
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
|
||||
|
||||
// RequestLinkCode issues and mails a confirm-code for email to accountID,
|
||||
// replacing any prior pending code. Unlike RequestCode it never refuses up front
|
||||
// (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,
|
||||
// so a probe cannot learn whether an address is registered.
|
||||
func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
code, hash, err := generateCode()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
|
||||
return err
|
||||
}
|
||||
subject := "Your Scrabble confirmation code"
|
||||
body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
|
||||
return s.mailer.Send(ctx, addr, subject, body)
|
||||
}
|
||||
|
||||
// ConfirmLink verifies code for (accountID, email) and reports the address's
|
||||
// current owner. When the address is free it binds a confirmed email identity to
|
||||
// accountID and returns (accountID, true, nil). When accountID already owns it,
|
||||
// it returns (accountID, true, nil) unchanged. When another account owns it, it
|
||||
// returns (owner, false, nil) without consuming the code, so the explicit merge
|
||||
// step can re-verify the same live code. It returns the usual confirm-code errors
|
||||
// (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch).
|
||||
func (s *EmailService) ConfirmLink(ctx context.Context, accountID uuid.UUID, email, code string) (uuid.UUID, bool, error) {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
conf, err := s.verifyPendingCode(ctx, accountID, addr, code)
|
||||
if err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
|
||||
if err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
if ok {
|
||||
if owner == accountID {
|
||||
return accountID, true, nil
|
||||
}
|
||||
return owner, false, nil
|
||||
}
|
||||
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
return accountID, true, nil
|
||||
}
|
||||
|
||||
// verifyPendingCode loads and checks the pending confirm-code for (accountID,
|
||||
// addr), counting a wrong attempt. It returns the confirmation on success.
|
||||
func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUID, addr, code string) (emailConfirmation, error) {
|
||||
conf, err := s.store.latestPendingConfirmation(ctx, accountID, addr)
|
||||
if err != nil {
|
||||
return emailConfirmation{}, err
|
||||
}
|
||||
if s.now().After(conf.expiresAt) {
|
||||
return emailConfirmation{}, ErrCodeExpired
|
||||
}
|
||||
if conf.attempts >= emailCodeMaxAttempts {
|
||||
return emailConfirmation{}, ErrTooManyAttempts
|
||||
}
|
||||
if hashCode(code) != conf.codeHash {
|
||||
if err := s.store.bumpConfirmationAttempts(ctx, conf.id); err != nil {
|
||||
return emailConfirmation{}, err
|
||||
}
|
||||
return emailConfirmation{}, ErrCodeMismatch
|
||||
}
|
||||
return conf, nil
|
||||
}
|
||||
|
||||
// AccountIDByIdentity returns the account owning (kind, externalID) and true, or
|
||||
// (uuid.Nil, false) when the identity is free. It backs the platform-identity link
|
||||
// flow.
|
||||
func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string) (uuid.UUID, bool, error) {
|
||||
acc, err := s.findByIdentity(ctx, kind, externalID)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return uuid.Nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
return acc.ID, true, nil
|
||||
}
|
||||
|
||||
// AttachIdentity links a new (kind, externalID) identity to an existing account.
|
||||
// A unique-constraint violation means the identity was taken meanwhile, surfaced
|
||||
// as ErrIdentityTaken. It is used to attach a platform identity (e.g. Telegram)
|
||||
// to the current account during linking.
|
||||
func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, externalID string, confirmed bool) error {
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return fmt.Errorf("account: new identity id: %w", err)
|
||||
}
|
||||
ins := table.Identities.INSERT(
|
||||
table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind,
|
||||
table.Identities.ExternalID, table.Identities.Confirmed,
|
||||
).VALUES(id, accountID, kind, externalID, confirmed)
|
||||
if _, err := ins.ExecContext(ctx, s.db); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return ErrIdentityTaken
|
||||
}
|
||||
return fmt.Errorf("account: attach identity (%s, %s): %w", kind, externalID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearGuest removes the is_guest flag from accountID, promoting an ephemeral guest
|
||||
// to a durable account once it gains its first identity. It is a no-op
|
||||
// for an already-durable account.
|
||||
func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
|
||||
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt).
|
||||
SET(postgres.Bool(false), postgres.TimestampzT(time.Now().UTC())).
|
||||
WHERE(
|
||||
table.Accounts.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.Accounts.IsGuest.EQ(postgres.Bool(true))),
|
||||
)
|
||||
if _, err := upd.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("account: clear guest %s: %w", accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Mailer delivers a transactional email. It is the seam behind which the email
|
||||
// confirm-code flow sends codes, so the relay is swappable and unit tests use a
|
||||
// fixture (see docs/TESTING.md: no real network in tests). The context is offered
|
||||
// for cancellation; the standard-library SMTP implementation sends synchronously
|
||||
// and ignores it.
|
||||
type Mailer interface {
|
||||
Send(ctx context.Context, to, subject, body string) error
|
||||
}
|
||||
|
||||
// SMTPConfig configures the SMTP relay. An empty Host selects the LogMailer
|
||||
// instead, so a deployment without a relay still runs (the code lands in the log).
|
||||
type SMTPConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
// SMTPMailer sends mail through an SMTP relay using the standard library. When a
|
||||
// username is set it authenticates with PLAIN; otherwise it relays unauthenticated.
|
||||
type SMTPMailer struct {
|
||||
cfg SMTPConfig
|
||||
}
|
||||
|
||||
// NewSMTPMailer constructs an SMTPMailer for cfg.
|
||||
func NewSMTPMailer(cfg SMTPConfig) SMTPMailer {
|
||||
return SMTPMailer{cfg: cfg}
|
||||
}
|
||||
|
||||
// Send delivers a plain-text UTF-8 message to to via the configured relay.
|
||||
func (m SMTPMailer) Send(_ context.Context, to, subject, body string) error {
|
||||
addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port)
|
||||
var auth smtp.Auth
|
||||
if m.cfg.Username != "" {
|
||||
auth = smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
|
||||
}
|
||||
if err := smtp.SendMail(addr, auth, m.cfg.From, []string{to}, message(m.cfg.From, to, subject, body)); err != nil {
|
||||
return fmt.Errorf("account: send mail to %s: %w", to, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// message renders a minimal RFC 5322 plain-text email.
|
||||
func message(from, to, subject, body string) []byte {
|
||||
return []byte("From: " + from + "\r\n" +
|
||||
"To: " + to + "\r\n" +
|
||||
"Subject: " + subject + "\r\n" +
|
||||
"MIME-Version: 1.0\r\n" +
|
||||
"Content-Type: text/plain; charset=UTF-8\r\n" +
|
||||
"\r\n" + body + "\r\n")
|
||||
}
|
||||
|
||||
// LogMailer logs the message instead of sending it. It is the default when no
|
||||
// SMTP relay is configured and is intended for development only: it logs the body,
|
||||
// which carries the confirm-code, so it must not be used in production.
|
||||
type LogMailer struct {
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewLogMailer constructs a LogMailer that logs through log.
|
||||
func NewLogMailer(log *zap.Logger) LogMailer {
|
||||
return LogMailer{log: log}
|
||||
}
|
||||
|
||||
// Send logs the message at info level and reports success.
|
||||
func (m LogMailer) Send(_ context.Context, to, subject, body string) error {
|
||||
if m.log != nil {
|
||||
m.log.Info("email not sent (log mailer)",
|
||||
zap.String("to", to), zap.String("subject", subject), zap.String("body", body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.opentelemetry.io/otel/metric/noop"
|
||||
)
|
||||
|
||||
// meterName scopes the account domain's OpenTelemetry instruments.
|
||||
const meterName = "scrabble/backend/account"
|
||||
|
||||
// kindGuest labels guest accounts in accounts_created_total. Guests carry no
|
||||
// identity, so they have no identity Kind; this is the metric label for them.
|
||||
const kindGuest = "guest"
|
||||
|
||||
// accountMetrics holds the account domain's operational instruments. It defaults
|
||||
// to no-ops (see defaultAccountMetrics); SetMetrics installs the real meter during
|
||||
// startup wiring.
|
||||
type accountMetrics struct {
|
||||
created metric.Int64Counter
|
||||
}
|
||||
|
||||
// defaultAccountMetrics returns instruments backed by a no-op meter.
|
||||
func defaultAccountMetrics() *accountMetrics {
|
||||
return newAccountMetrics(noop.NewMeterProvider().Meter(meterName))
|
||||
}
|
||||
|
||||
// newAccountMetrics builds the instruments on meter, falling back to a no-op
|
||||
// counter on the (rare) construction error.
|
||||
func newAccountMetrics(meter metric.Meter) *accountMetrics {
|
||||
c, err := meter.Int64Counter("accounts_created_total",
|
||||
metric.WithDescription("New accounts created, labelled by kind (telegram/email/guest); robots are not counted."))
|
||||
if err != nil {
|
||||
c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("accounts_created_total")
|
||||
}
|
||||
return &accountMetrics{created: c}
|
||||
}
|
||||
|
||||
// SetMetrics installs the meter the account store records to. It must be called
|
||||
// during startup wiring; the default is a no-op meter.
|
||||
func (s *Store) SetMetrics(meter metric.Meter) {
|
||||
if meter == nil {
|
||||
return
|
||||
}
|
||||
s.metrics = newAccountMetrics(meter)
|
||||
}
|
||||
|
||||
// recordCreated counts one newly created account of the given kind.
|
||||
func (m *accountMetrics) recordCreated(ctx context.Context, kind string) {
|
||||
m.created.Add(ctx, 1, metric.WithAttributes(attribute.String("kind", kind)))
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// maxDisplayName caps an editable display name's length in runes (the column itself
|
||||
// is unbounded; auto-provisioned platform names bypass this editor validation).
|
||||
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).
|
||||
const maxAwayWindow = 12 * time.Hour
|
||||
|
||||
// displayNameRe enforces the editable display-name format: Unicode letters
|
||||
// joined by single space / "." / "_" separators, where a "." or "_" may be followed
|
||||
// by a single space. No leading separator and no two adjacent separators (except
|
||||
// "<dot|underscore> <space>"). The name may end with EITHER a single trailing "."
|
||||
// (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
|
||||
// field (an unknown language, an invalid timezone, or an over-long display name).
|
||||
var ErrInvalidProfile = errors.New("account: invalid profile")
|
||||
|
||||
// ProfileUpdate is the full set of player-editable profile fields. UpdateProfile
|
||||
// overwrites every field, so callers send the complete desired profile. AwayStart
|
||||
// and AwayEnd carry only the hour and minute of the daily away window, in the
|
||||
// account's TimeZone.
|
||||
type ProfileUpdate struct {
|
||||
DisplayName string
|
||||
PreferredLanguage string // "en" or "ru"
|
||||
TimeZone string // an IANA location name
|
||||
AwayStart time.Time
|
||||
AwayEnd time.Time
|
||||
BlockChat bool
|
||||
BlockFriendRequests bool
|
||||
NotificationsInAppOnly bool
|
||||
// VariantPreferences is the set of game variants the player allows themselves to
|
||||
// be matched into (engine.Variant stable labels). UpdateProfile cleans it to a
|
||||
// deduplicated, canonically ordered subset of the known variants and rejects an
|
||||
// empty set.
|
||||
VariantPreferences []string
|
||||
}
|
||||
|
||||
// knownVariants is the closed set of game-variant labels (engine.Variant stable
|
||||
// labels) a profile's variant preferences may contain. It lives here so the store
|
||||
// does not depend on the engine package; the server handler additionally validates
|
||||
// against engine.ParseVariant, and a DB check enforces the same subset.
|
||||
var knownVariants = map[string]bool{"erudit_ru": true, "scrabble_ru": true, "scrabble_en": true}
|
||||
|
||||
// canonicalVariantOrder is the deterministic order variant preferences are stored
|
||||
// in (Erudit, Russian Scrabble, English), independent of the client's order.
|
||||
var canonicalVariantOrder = []string{"erudit_ru", "scrabble_ru", "scrabble_en"}
|
||||
|
||||
// validateVariantPreferences cleans a profile's variant-preference set: it drops
|
||||
// duplicates, rejects an unknown label or an empty set (ErrInvalidProfile) and
|
||||
// returns the preferences in canonicalVariantOrder so the stored value is
|
||||
// deterministic regardless of the order the client sent.
|
||||
func validateVariantPreferences(prefs []string) ([]string, error) {
|
||||
seen := make(map[string]bool, len(prefs))
|
||||
for _, p := range prefs {
|
||||
p = strings.TrimSpace(p)
|
||||
if !knownVariants[p] {
|
||||
return nil, fmt.Errorf("%w: variant preference %q", ErrInvalidProfile, p)
|
||||
}
|
||||
seen[p] = true
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
return nil, fmt.Errorf("%w: variant preferences must not be empty", ErrInvalidProfile)
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for _, v := range canonicalVariantOrder {
|
||||
if seen[v] {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateProfile validates and overwrites the editable fields of the account, then
|
||||
// returns the stored row. It reports ErrInvalidProfile for a bad language,
|
||||
// timezone or display name and ErrNotFound when no account matches id.
|
||||
func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate) (Account, error) {
|
||||
lang := strings.TrimSpace(p.PreferredLanguage)
|
||||
if lang != "en" && lang != "ru" {
|
||||
return Account{}, fmt.Errorf("%w: preferred_language %q", ErrInvalidProfile, p.PreferredLanguage)
|
||||
}
|
||||
tz := strings.TrimSpace(p.TimeZone)
|
||||
if !validZone(tz) {
|
||||
return Account{}, fmt.Errorf("%w: time_zone %q", ErrInvalidProfile, p.TimeZone)
|
||||
}
|
||||
name, err := ValidateDisplayName(p.DisplayName)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
if err := validateAwayWindow(p.AwayStart, p.AwayEnd); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
prefs, err := validateVariantPreferences(p.VariantPreferences)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
|
||||
stmt := table.Accounts.UPDATE(
|
||||
table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone,
|
||||
table.Accounts.AwayStart, table.Accounts.AwayEnd,
|
||||
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests,
|
||||
table.Accounts.NotificationsInAppOnly, table.Accounts.VariantPreferences,
|
||||
table.Accounts.UpdatedAt,
|
||||
).SET(
|
||||
postgres.String(name), postgres.String(lang), postgres.String(tz),
|
||||
postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd),
|
||||
postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests),
|
||||
postgres.Bool(p.NotificationsInAppOnly),
|
||||
// prefs are validated against the closed knownVariants set; bind as a text[]
|
||||
// parameter (lib/pq encodes the array, the cast pins the column type).
|
||||
postgres.Raw("#variant_prefs::text[]", map[string]interface{}{"#variant_prefs": pq.StringArray(prefs)}),
|
||||
postgres.TimestampzT(time.Now().UTC()),
|
||||
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
var row model.Accounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
return Account{}, fmt.Errorf("account: update profile %s: %w", id, err)
|
||||
}
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
// ValidateDisplayName trims surrounding whitespace and checks the editable
|
||||
// display-name length (<= maxDisplayName runes) and format (displayNameRe),
|
||||
// returning the cleaned name or ErrInvalidProfile. It is exported so the gateway
|
||||
// boundary could reuse it; the UI mirrors the same rule.
|
||||
func ValidateDisplayName(raw string) (string, error) {
|
||||
name := strings.TrimSpace(raw)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("%w: display name is empty", ErrInvalidProfile)
|
||||
}
|
||||
if utf8.RuneCountInString(name) > maxDisplayName {
|
||||
return "", fmt.Errorf("%w: display name exceeds %d characters", ErrInvalidProfile, maxDisplayName)
|
||||
}
|
||||
if !displayNameRe.MatchString(name) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// midnight, does not exceed maxAwayWindow. A zero-length window (start == end) means
|
||||
// "no away time" and is allowed.
|
||||
func validateAwayWindow(start, end time.Time) error {
|
||||
mins := (end.Hour()*60 + end.Minute()) - (start.Hour()*60 + start.Minute())
|
||||
if mins < 0 {
|
||||
mins += 24 * 60
|
||||
}
|
||||
if time.Duration(mins)*time.Minute > maxAwayWindow {
|
||||
return fmt.Errorf("%w: away window exceeds %s", ErrInvalidProfile, maxAwayWindow)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestUpdateProfileValidation checks that bad fields are rejected before any
|
||||
// database access, so a nil-backed Store is enough to exercise the guards. It also
|
||||
// confirms UpdateProfile wires the validators (name format, away window,
|
||||
// offset/IANA timezone), not just their unit tests in validate_test.go.
|
||||
func TestUpdateProfileValidation(t *testing.T) {
|
||||
s := &Store{}
|
||||
base := ProfileUpdate{DisplayName: "Kaya", PreferredLanguage: "en", TimeZone: "UTC", VariantPreferences: []string{"erudit_ru"}}
|
||||
hm := func(h, m int) time.Time { return time.Date(0, 1, 1, h, m, 0, 0, time.UTC) }
|
||||
tests := []struct {
|
||||
name string
|
||||
mut func(p *ProfileUpdate)
|
||||
}{
|
||||
{"unknown language", func(p *ProfileUpdate) { p.PreferredLanguage = "fr" }},
|
||||
{"invalid timezone", func(p *ProfileUpdate) { p.TimeZone = "Mars/Olympus" }},
|
||||
{"bad offset timezone", func(p *ProfileUpdate) { p.TimeZone = "+15:00" }},
|
||||
{"over-long name", func(p *ProfileUpdate) { p.DisplayName = strings.Repeat("x", maxDisplayName+1) }},
|
||||
{"bad name layout", func(p *ProfileUpdate) { p.DisplayName = "Bad__Name" }},
|
||||
{"away over 12h", func(p *ProfileUpdate) { p.AwayStart, p.AwayEnd = hm(8, 0), hm(21, 0) }},
|
||||
{"empty variant preferences", func(p *ProfileUpdate) { p.VariantPreferences = nil }},
|
||||
{"unknown variant preference", func(p *ProfileUpdate) { p.VariantPreferences = []string{"chess"} }},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := base
|
||||
tc.mut(&p)
|
||||
if _, err := s.UpdateProfile(context.Background(), uuid.New(), p); !errors.Is(err, ErrInvalidProfile) {
|
||||
t.Fatalf("err = %v, want ErrInvalidProfile", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
|
||||
// create-time account seed: supported-language detection (bare and region-tagged),
|
||||
// the real-name → @username (verbatim) → placeholder display-name precedence, and
|
||||
// the sanitization of the real name (emoji, digits, punctuation stripped to the
|
||||
// editable format). The username, when used, is kept verbatim.
|
||||
func TestTelegramSeed(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
languageCode, username, firstName string
|
||||
wantLang, wantName string
|
||||
}{
|
||||
"ru bare": {"ru", "user", "Иван", "ru", "Иван"},
|
||||
"en region-tagged": {"en-US", "user", "John", "en", "John"},
|
||||
"ru region-tagged": {"ru-RU", "", "Пётр", "ru", "Пётр"},
|
||||
"unknown language": {"fr", "frodo", "Frodo", "", "Frodo"},
|
||||
"empty language": {"", "neo", "Neo", "", "Neo"},
|
||||
"first name wins": {"en", "handle", "Real Name", "en", "Real Name"},
|
||||
"username fallback": {"en", "handle", "", "en", "handle"},
|
||||
"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 {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := telegramSeed(tc.languageCode, tc.username, tc.firstName)
|
||||
if got.preferredLanguage != tc.wantLang {
|
||||
t.Errorf("preferredLanguage = %q, want %q", got.preferredLanguage, tc.wantLang)
|
||||
}
|
||||
if got.displayName != tc.wantName {
|
||||
t.Errorf("displayName = %q, want %q", got.displayName, tc.wantName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// maxDisplayName runes (counted in runes, not bytes).
|
||||
func TestTelegramSeedTruncatesLongName(t *testing.T) {
|
||||
long := strings.Repeat("я", maxDisplayName+5)
|
||||
got := telegramSeed("ru", "", long)
|
||||
if n := utf8.RuneCountInString(got.displayName); n != maxDisplayName {
|
||||
t.Errorf("display name rune count = %d, want %d", n, maxDisplayName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// ReapAbandonedGuests deletes guest accounts created before olderThan that are
|
||||
// not seated in any game. It returns the number deleted.
|
||||
//
|
||||
// Scope is deliberately "no game seat at all", not merely "no active game": a
|
||||
// finished game belongs to the other players' history, and game_players carries no
|
||||
// ON DELETE CASCADE to accounts (docs/ARCHITECTURE.md §4), so a guest with any seat
|
||||
// is retained (and a delete would be blocked by that foreign key regardless). The
|
||||
// dependent rows of a reaped guest — sessions, identities, account_stats — fall
|
||||
// away through their own ON DELETE CASCADE foreign keys. Account age is the
|
||||
// abandonment signal because sessions are revoke-only with no maintained
|
||||
// last_seen_at, so a lingering session never expires on its own.
|
||||
func (s *Store) ReapAbandonedGuests(ctx context.Context, olderThan time.Time) (int64, error) {
|
||||
stmt := table.Accounts.DELETE().WHERE(
|
||||
table.Accounts.IsGuest.EQ(postgres.Bool(true)).
|
||||
AND(table.Accounts.CreatedAt.LT(postgres.TimestampzT(olderThan))).
|
||||
AND(postgres.NOT(postgres.EXISTS(
|
||||
postgres.SELECT(table.GamePlayers.AccountID).
|
||||
FROM(table.GamePlayers).
|
||||
WHERE(table.GamePlayers.AccountID.EQ(table.Accounts.AccountID)),
|
||||
))),
|
||||
)
|
||||
res, err := stmt.ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("account: reap guests: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("account: reap guests rows affected: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// GuestReaper periodically deletes abandoned guest accounts via
|
||||
// Store.ReapAbandonedGuests. It mirrors the game turn-timeout sweeper and the
|
||||
// matchmaker reaper: one background goroutine, started once from main.
|
||||
type GuestReaper struct {
|
||||
store *Store
|
||||
retention time.Duration
|
||||
clock func() time.Time
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewGuestReaper constructs a reaper deleting guests whose account age exceeds
|
||||
// retention. log may be nil.
|
||||
func NewGuestReaper(store *Store, retention time.Duration, log *zap.Logger) *GuestReaper {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &GuestReaper{
|
||||
store: store,
|
||||
retention: retention,
|
||||
clock: func() time.Time { return time.Now().UTC() },
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Run reaps abandoned guests on each tick until ctx is cancelled.
|
||||
func (r *GuestReaper) Run(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
n, err := r.store.ReapAbandonedGuests(ctx, r.clock().Add(-r.retention))
|
||||
if err != nil {
|
||||
r.log.Warn("guest reap failed", zap.Error(err))
|
||||
} else if n > 0 {
|
||||
r.log.Info("reaped abandoned guests", zap.Int64("count", n))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// BestMoveTile is one letter cell of a best-move word: its concrete letter (the
|
||||
// designated letter for a blank), its tile point value (0 for a blank) and whether it
|
||||
// is a blank. It is the persisted/served shape: the game domain marshals a slice of
|
||||
// these into account_best_move.tiles, and the statistics screen renders them as game
|
||||
// tiles without consulting the variant's alphabet.
|
||||
type BestMoveTile struct {
|
||||
Letter string `json:"letter"`
|
||||
Value int `json:"value"`
|
||||
Blank bool `json:"blank"`
|
||||
}
|
||||
|
||||
// BestMove is an account's highest-scoring single play within one game variant: the
|
||||
// move's total score (every word it formed plus the all-tiles bonus, matching
|
||||
// MaxWordPoints) and its main word as an ordered slice of tiles.
|
||||
type BestMove struct {
|
||||
Variant string
|
||||
Score int
|
||||
Tiles []BestMoveTile
|
||||
}
|
||||
|
||||
// Stats is a durable account's lifetime record, written by the game domain on each
|
||||
// finish and read for the player's statistics screen. MaxGamePoints is the best
|
||||
// single game's total; MaxWordPoints is the best single move's score (which already
|
||||
// includes every word it formed plus the all-tiles bonus). BestMoves holds the same
|
||||
// best move broken down per variant, with the word itself — empty for an account with
|
||||
// no recorded play yet, and never carrying a variant the account has not played.
|
||||
type Stats struct {
|
||||
Wins int
|
||||
Losses int
|
||||
Draws int
|
||||
MaxGamePoints int
|
||||
MaxWordPoints int
|
||||
// Moves is the lifetime count of the account's plays (tile placements); HintsUsed is the
|
||||
// lifetime count of hints taken. The statistics screen shows the hint share (HintsUsed / Moves).
|
||||
Moves int
|
||||
HintsUsed int
|
||||
BestMoves []BestMove
|
||||
}
|
||||
|
||||
// GetStats returns the lifetime statistics for id. An account with no account_stats
|
||||
// row yet — a guest, or a player who has not finished a game — yields the zero
|
||||
// Stats (all counters zero) rather than an error.
|
||||
func (s *Store) GetStats(ctx context.Context, id uuid.UUID) (Stats, error) {
|
||||
stmt := postgres.SELECT(table.AccountStats.AllColumns).
|
||||
FROM(table.AccountStats).
|
||||
WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(id))).
|
||||
LIMIT(1)
|
||||
var row model.AccountStats
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Stats{}, nil
|
||||
}
|
||||
return Stats{}, fmt.Errorf("account: get stats %s: %w", id, err)
|
||||
}
|
||||
best, err := s.bestMoves(ctx, id)
|
||||
if err != nil {
|
||||
return Stats{}, err
|
||||
}
|
||||
return Stats{
|
||||
Wins: int(row.Wins),
|
||||
Losses: int(row.Losses),
|
||||
Draws: int(row.Draws),
|
||||
MaxGamePoints: int(row.MaxGamePoints),
|
||||
MaxWordPoints: int(row.MaxWordPoints),
|
||||
Moves: int(row.Moves),
|
||||
HintsUsed: int(row.HintsUsed),
|
||||
BestMoves: best,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// bestMoves reads an account's per-variant best moves, ordered by variant for a stable
|
||||
// response. Each row's tiles JSON is decoded into the served BestMoveTile slice. An
|
||||
// account with no recorded play yields an empty (nil) slice rather than an error.
|
||||
func (s *Store) bestMoves(ctx context.Context, id uuid.UUID) ([]BestMove, error) {
|
||||
stmt := postgres.SELECT(
|
||||
table.AccountBestMove.Variant,
|
||||
table.AccountBestMove.Score,
|
||||
table.AccountBestMove.Tiles,
|
||||
).
|
||||
FROM(table.AccountBestMove).
|
||||
WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(id))).
|
||||
ORDER_BY(table.AccountBestMove.Variant.ASC())
|
||||
var rows []model.AccountBestMove
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("account: best moves %s: %w", id, err)
|
||||
}
|
||||
out := make([]BestMove, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var tiles []BestMoveTile
|
||||
if err := json.Unmarshal([]byte(r.Tiles), &tiles); err != nil {
|
||||
return nil, fmt.Errorf("account: decode best-move tiles %s/%s: %w", id, r.Variant, err)
|
||||
}
|
||||
out = append(out, BestMove{Variant: r.Variant, Score: int(r.Score), Tiles: tiles})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// offsetZoneRe matches a fixed UTC offset like "+03:00" or "-05:30" — the form the
|
||||
// profile editor stores (an offset dropdown rather than an IANA name).
|
||||
var offsetZoneRe = regexp.MustCompile(`^([+-])(\d{2}):(\d{2})$`)
|
||||
|
||||
// parseOffsetZone parses a "±HH:MM" offset into a fixed-offset location, reporting
|
||||
// ok=false when name is not a well-formed offset within ±14:00.
|
||||
func parseOffsetZone(name string) (*time.Location, bool) {
|
||||
m := offsetZoneRe.FindStringSubmatch(name)
|
||||
if m == nil {
|
||||
return nil, false
|
||||
}
|
||||
h, _ := strconv.Atoi(m[2])
|
||||
min, _ := strconv.Atoi(m[3])
|
||||
if h > 14 || min > 59 || (h == 14 && min > 0) {
|
||||
return nil, false
|
||||
}
|
||||
secs := h*3600 + min*60
|
||||
if m[1] == "-" {
|
||||
secs = -secs
|
||||
}
|
||||
return time.FixedZone(name, secs), true
|
||||
}
|
||||
|
||||
// ResolveZone resolves a stored timezone — a fixed "±HH:MM" offset or an IANA name —
|
||||
// to a *time.Location, falling back to UTC when it is empty or unrecognised, so a
|
||||
// bad profile value never breaks the turn-timeout sweeper or the robot's sleep.
|
||||
func ResolveZone(name string) *time.Location {
|
||||
if name == "" {
|
||||
return time.UTC
|
||||
}
|
||||
if loc, ok := parseOffsetZone(name); ok {
|
||||
return loc
|
||||
}
|
||||
if loc, err := time.LoadLocation(name); err == nil {
|
||||
return loc
|
||||
}
|
||||
return time.UTC
|
||||
}
|
||||
|
||||
// validZone reports whether name is an acceptable timezone for a profile update —
|
||||
// either a "±HH:MM" offset or a loadable IANA location.
|
||||
func validZone(name string) bool {
|
||||
if _, ok := parseOffsetZone(name); ok {
|
||||
return true
|
||||
}
|
||||
_, err := time.LoadLocation(name)
|
||||
return err == nil
|
||||
}
|
||||
@@ -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, "?", "_")
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateDisplayName(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
in string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
"plain": {"Kaya", "Kaya", true},
|
||||
"cyrillic": {"Кая", "Кая", true},
|
||||
"dot underscore mix": {"Name_P. Last", "Name_P. Last", true},
|
||||
"single dot": {"Mr.Smith", "Mr.Smith", true},
|
||||
"dot then space": {"Mr. Smith", "Mr. Smith", true},
|
||||
"trim surrounding": {" Kaya ", "Kaya", true},
|
||||
"adjacent specials": {"Name P._Last", "", false},
|
||||
"two spaces": {"Name Last", "", false},
|
||||
"leading special": {"_Name", "", false},
|
||||
"trailing underscore": {"Name_", "", 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},
|
||||
"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 {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got, err := ValidateDisplayName(tc.in)
|
||||
if tc.ok != (err == nil) || (tc.ok && got != tc.want) {
|
||||
t.Fatalf("ValidateDisplayName(%q) = (%q, err=%v), want (%q, ok=%v)", tc.in, got, err, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAwayWindow(t *testing.T) {
|
||||
hm := func(h, m int) time.Time { return time.Date(0, 1, 1, h, m, 0, 0, time.UTC) }
|
||||
cases := map[string]struct {
|
||||
start, end time.Time
|
||||
ok bool
|
||||
}{
|
||||
"8h overnight": {hm(22, 0), hm(6, 0), true},
|
||||
"12h exact": {hm(0, 0), hm(12, 0), true},
|
||||
"13h daytime": {hm(8, 0), hm(21, 0), false},
|
||||
"zero window": {hm(7, 0), hm(7, 0), true},
|
||||
"13h wrap": {hm(20, 0), hm(9, 0), false},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := validateAwayWindow(tc.start, tc.end); tc.ok != (err == nil) {
|
||||
t.Fatalf("validateAwayWindow = %v, want ok=%v", err, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAndValidZone(t *testing.T) {
|
||||
offsetOf := func(name string) int {
|
||||
_, off := time.Date(2024, 1, 1, 12, 0, 0, 0, ResolveZone(name)).Zone()
|
||||
return off
|
||||
}
|
||||
if got := offsetOf("+03:00"); got != 3*3600 {
|
||||
t.Errorf("+03:00 offset = %d, want 10800", got)
|
||||
}
|
||||
if got := offsetOf("-05:30"); got != -(5*3600 + 30*60) {
|
||||
t.Errorf("-05:30 offset = %d", got)
|
||||
}
|
||||
if ResolveZone("nonsense-zone") != time.UTC {
|
||||
t.Error("unknown zone should resolve to UTC")
|
||||
}
|
||||
for _, ok := range []string{"+05:45", "-12:00", "+14:00", "Europe/Moscow", "UTC"} {
|
||||
if !validZone(ok) {
|
||||
t.Errorf("validZone(%q) = false, want true", ok)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"+15:00", "03:00", "+3:00", "nope", "+05:99"} {
|
||||
if validZone(bad) {
|
||||
t.Errorf("validZone(%q) = true, want false", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
// Package accountmerge retires a secondary account into a primary one in a single
|
||||
// transaction: it sums statistics (merging the per-variant best moves), sums the hint
|
||||
// wallet, ORs the paid flag, repoints
|
||||
// the secondary's identities, transfers its games/chat/complaints/invitations,
|
||||
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
|
||||
// (accounts.merged_into). It is the data core of account linking & merge
|
||||
// (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.
|
||||
package accountmerge
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// statusActive mirrors game.StatusActive; the active-shared-game guard reads it
|
||||
// without taking a dependency on the game package.
|
||||
const statusActive = "active"
|
||||
|
||||
// Friendship statuses, highest precedence first, mirroring internal/social.
|
||||
const (
|
||||
friendAccepted = "accepted"
|
||||
friendPending = "pending"
|
||||
friendDeclined = "declined"
|
||||
)
|
||||
|
||||
// ErrActiveGameConflict is returned when the primary and secondary accounts share
|
||||
// an active game: merging would seat one player against themselves, so the caller
|
||||
// must wait for the game to finish.
|
||||
var ErrActiveGameConflict = errors.New("accountmerge: primary and secondary share an active game")
|
||||
|
||||
// ErrSameAccount is returned when primary and secondary are the same account.
|
||||
var ErrSameAccount = errors.New("accountmerge: primary and secondary are the same account")
|
||||
|
||||
// Merger merges accounts over a Postgres handle.
|
||||
type Merger struct {
|
||||
db *sql.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewMerger constructs a Merger over db.
|
||||
func NewMerger(db *sql.DB) *Merger {
|
||||
return &Merger{db: db, now: func() time.Time { return time.Now().UTC() }}
|
||||
}
|
||||
|
||||
// Merge retires secondary into primary atomically. The secondary is kept as a
|
||||
// tombstone (merged_into=primary) so the no-cascade foreign keys of any shared
|
||||
// finished game stay valid; its seat in such a game is left untouched. The merge
|
||||
// is refused with ErrActiveGameConflict when the two share an active game.
|
||||
func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error {
|
||||
if primary == secondary {
|
||||
return ErrSameAccount
|
||||
}
|
||||
now := m.now()
|
||||
return withTx(ctx, m.db, func(tx *sql.Tx) error {
|
||||
if err := guardActiveSharedGame(ctx, tx, primary, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mergeStats(ctx, tx, primary, secondary, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mergeBestMoves(ctx, tx, primary, secondary, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reassignColumn(ctx, tx, table.Identities, table.Identities.AccountID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: identities: %w", err)
|
||||
}
|
||||
if err := transferGamePlayers(ctx, tx, primary, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reassignColumn(ctx, tx, table.ChatMessages, table.ChatMessages.SenderID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: chat: %w", err)
|
||||
}
|
||||
if err := reassignColumn(ctx, tx, table.Complaints, table.Complaints.ComplainantID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: complaints: %w", err)
|
||||
}
|
||||
if err := mergeFriendships(ctx, tx, primary, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mergeBlocks(ctx, tx, primary, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mergeInvitations(ctx, tx, primary, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteEphemerals(ctx, tx, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
return tombstone(ctx, tx, primary, secondary, now)
|
||||
})
|
||||
}
|
||||
|
||||
// guardActiveSharedGame returns ErrActiveGameConflict when primary and secondary
|
||||
// are both seated in the same active game.
|
||||
func guardActiveSharedGame(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
||||
pri, err := activeGameIDs(ctx, tx, primary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(pri) == 0 {
|
||||
return nil
|
||||
}
|
||||
sec, err := activeGameIDs(ctx, tx, secondary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
have := make(map[uuid.UUID]struct{}, len(pri))
|
||||
for _, id := range pri {
|
||||
have[id] = struct{}{}
|
||||
}
|
||||
for _, id := range sec {
|
||||
if _, ok := have[id]; ok {
|
||||
return ErrActiveGameConflict
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// activeGameIDs lists the active games accountID is seated in.
|
||||
func activeGameIDs(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) ([]uuid.UUID, error) {
|
||||
stmt := postgres.SELECT(table.GamePlayers.GameID).
|
||||
FROM(table.GamePlayers.INNER_JOIN(table.Games, table.Games.GameID.EQ(table.GamePlayers.GameID))).
|
||||
WHERE(
|
||||
table.GamePlayers.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.Games.Status.EQ(postgres.String(statusActive))),
|
||||
)
|
||||
var rows []model.GamePlayers
|
||||
if err := stmt.QueryContext(ctx, tx, &rows); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("accountmerge: active games %s: %w", accountID, err)
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.GameID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws and
|
||||
// the moves/hints-used counters summed, max points kept) and deletes the secondary row.
|
||||
func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||
var sec model.AccountStats
|
||||
err := postgres.SELECT(table.AccountStats.AllColumns).
|
||||
FROM(table.AccountStats).
|
||||
WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(secondary))).
|
||||
QueryContext(ctx, tx, &sec)
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("accountmerge: load secondary stats: %w", err)
|
||||
}
|
||||
|
||||
ensure := table.AccountStats.INSERT(table.AccountStats.AccountID).
|
||||
VALUES(primary).ON_CONFLICT(table.AccountStats.AccountID).DO_NOTHING()
|
||||
if _, err := ensure.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: ensure primary stats: %w", err)
|
||||
}
|
||||
var pri model.AccountStats
|
||||
if err := postgres.SELECT(table.AccountStats.AllColumns).
|
||||
FROM(table.AccountStats).
|
||||
WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary))).
|
||||
FOR(postgres.UPDATE()).
|
||||
QueryContext(ctx, tx, &pri); err != nil {
|
||||
return fmt.Errorf("accountmerge: lock primary stats: %w", err)
|
||||
}
|
||||
|
||||
upd := table.AccountStats.UPDATE(
|
||||
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
|
||||
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints,
|
||||
table.AccountStats.Moves, table.AccountStats.HintsUsed, table.AccountStats.UpdatedAt,
|
||||
).SET(
|
||||
postgres.Int(int64(pri.Wins+sec.Wins)),
|
||||
postgres.Int(int64(pri.Losses+sec.Losses)),
|
||||
postgres.Int(int64(pri.Draws+sec.Draws)),
|
||||
postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))),
|
||||
postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))),
|
||||
postgres.Int(int64(pri.Moves+sec.Moves)),
|
||||
postgres.Int(int64(pri.HintsUsed+sec.HintsUsed)),
|
||||
postgres.TimestampzT(now),
|
||||
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary)))
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: update primary stats: %w", err)
|
||||
}
|
||||
|
||||
del := table.AccountStats.DELETE().WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(secondary)))
|
||||
if _, err := del.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: delete secondary stats: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeBestMoves folds secondary's per-variant best moves into primary, keeping the
|
||||
// higher-scoring play per variant (the same rule the per-game upsert uses), then deletes
|
||||
// the secondary's rows — the secondary is only tombstoned, not removed, so without this
|
||||
// they would linger on a dead account and never reach the merged statistics screen.
|
||||
func mergeBestMoves(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||
var srows []model.AccountBestMove
|
||||
err := postgres.SELECT(table.AccountBestMove.AllColumns).
|
||||
FROM(table.AccountBestMove).
|
||||
WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary))).
|
||||
QueryContext(ctx, tx, &srows)
|
||||
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
||||
return fmt.Errorf("accountmerge: load secondary best moves: %w", err)
|
||||
}
|
||||
for _, s := range srows {
|
||||
ins := table.AccountBestMove.
|
||||
INSERT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant,
|
||||
table.AccountBestMove.Score, table.AccountBestMove.Tiles, table.AccountBestMove.UpdatedAt).
|
||||
VALUES(primary, s.Variant, s.Score, s.Tiles, postgres.TimestampzT(now)).
|
||||
ON_CONFLICT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant).
|
||||
DO_UPDATE(postgres.SET(
|
||||
table.AccountBestMove.Score.SET(table.AccountBestMove.EXCLUDED.Score),
|
||||
table.AccountBestMove.Tiles.SET(table.AccountBestMove.EXCLUDED.Tiles),
|
||||
table.AccountBestMove.UpdatedAt.SET(table.AccountBestMove.EXCLUDED.UpdatedAt),
|
||||
).WHERE(table.AccountBestMove.EXCLUDED.Score.GT(table.AccountBestMove.Score)))
|
||||
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: merge best move %s: %w", s.Variant, err)
|
||||
}
|
||||
}
|
||||
del := table.AccountBestMove.DELETE().WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary)))
|
||||
if _, err := del.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: delete secondary best moves: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
|
||||
// all other profile fields stay the primary's.
|
||||
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||
var sec model.Accounts
|
||||
if err := postgres.SELECT(table.Accounts.AllColumns).
|
||||
FROM(table.Accounts).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary))).
|
||||
QueryContext(ctx, tx, &sec); err != nil {
|
||||
return fmt.Errorf("accountmerge: load secondary account: %w", err)
|
||||
}
|
||||
upd := table.Accounts.UPDATE(
|
||||
table.Accounts.HintBalance, table.Accounts.PaidAccount, table.Accounts.UpdatedAt,
|
||||
).SET(
|
||||
table.Accounts.HintBalance.ADD(postgres.Int(int64(sec.HintBalance))),
|
||||
table.Accounts.PaidAccount.OR(postgres.Bool(sec.PaidAccount)),
|
||||
postgres.TimestampzT(now),
|
||||
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(primary)))
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: update primary account: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// transferGamePlayers moves secondary's seats to primary, except in a game primary
|
||||
// already sits in (a shared finished game — active is barred by the guard), where
|
||||
// the secondary seat is left as the tombstone so the no-cascade FK stays valid.
|
||||
func transferGamePlayers(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
||||
var prows []model.GamePlayers
|
||||
if err := postgres.SELECT(table.GamePlayers.GameID).
|
||||
FROM(table.GamePlayers).
|
||||
WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(primary))).
|
||||
QueryContext(ctx, tx, &prows); err != nil {
|
||||
if !errors.Is(err, qrm.ErrNoRows) {
|
||||
return fmt.Errorf("accountmerge: primary seats: %w", err)
|
||||
}
|
||||
}
|
||||
cond := table.GamePlayers.AccountID.EQ(postgres.UUID(secondary))
|
||||
if len(prows) > 0 {
|
||||
ids := make([]postgres.Expression, len(prows))
|
||||
for i, r := range prows {
|
||||
ids[i] = postgres.UUID(r.GameID)
|
||||
}
|
||||
cond = cond.AND(table.GamePlayers.GameID.NOT_IN(ids...))
|
||||
}
|
||||
upd := table.GamePlayers.UPDATE(table.GamePlayers.AccountID).SET(postgres.UUID(primary)).WHERE(cond)
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: transfer seats: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reassignColumn blanket-reassigns a no-collision account column from secondary to
|
||||
// primary (identities, chat sender, complaint complainant).
|
||||
func reassignColumn(ctx context.Context, tx *sql.Tx, tbl postgres.Table, col postgres.ColumnString, primary, secondary uuid.UUID) error {
|
||||
upd := tbl.UPDATE(col).SET(postgres.UUID(primary)).
|
||||
WHERE(col.EQ(postgres.UUID(secondary)))
|
||||
_, err := upd.ExecContext(ctx, tx)
|
||||
return err
|
||||
}
|
||||
|
||||
// friendRank ranks a friendship status for dedupe precedence (higher wins).
|
||||
func friendRank(status string) int {
|
||||
switch status {
|
||||
case friendAccepted:
|
||||
return 3
|
||||
case friendPending:
|
||||
return 2
|
||||
case friendDeclined:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// mergeFriendships repoints secondary's friendships to primary, dropping the direct
|
||||
// primary-secondary edge (it would become a self-edge) and de-duplicating a shared
|
||||
// counterparty by keeping the higher-precedence status (accepted > pending >
|
||||
// declined). Each account has at most one edge per unordered pair, so the per-other
|
||||
// decision is unambiguous.
|
||||
func mergeFriendships(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
||||
if err := deletePair(ctx, tx, table.Friendships.DELETE(),
|
||||
table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: drop self-friendship: %w", err)
|
||||
}
|
||||
|
||||
priByOther := map[uuid.UUID]string{}
|
||||
var prows []model.Friendships
|
||||
if err := selectEdges(ctx, tx, table.Friendships, table.Friendships.AllColumns, table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, &prows); err != nil {
|
||||
return fmt.Errorf("accountmerge: primary friendships: %w", err)
|
||||
}
|
||||
for _, r := range prows {
|
||||
priByOther[otherOf(r.RequesterID, r.AddresseeID, primary)] = r.Status
|
||||
}
|
||||
|
||||
var srows []model.Friendships
|
||||
if err := selectEdges(ctx, tx, table.Friendships, table.Friendships.AllColumns, table.Friendships.RequesterID, table.Friendships.AddresseeID, secondary, &srows); err != nil {
|
||||
return fmt.Errorf("accountmerge: secondary friendships: %w", err)
|
||||
}
|
||||
for _, r := range srows {
|
||||
other := otherOf(r.RequesterID, r.AddresseeID, secondary)
|
||||
if priStatus, ok := priByOther[other]; ok {
|
||||
if friendRank(r.Status) <= friendRank(priStatus) {
|
||||
if err := deleteEdge(ctx, tx, table.Friendships.DELETE(),
|
||||
table.Friendships.RequesterID, table.Friendships.AddresseeID, r.RequesterID, r.AddresseeID); err != nil {
|
||||
return fmt.Errorf("accountmerge: drop dominated friendship: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := deletePair(ctx, tx, table.Friendships.DELETE(),
|
||||
table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, other); err != nil {
|
||||
return fmt.Errorf("accountmerge: drop superseded friendship: %w", err)
|
||||
}
|
||||
}
|
||||
if err := repointEdge(ctx, tx, table.Friendships, table.Friendships.RequesterID, table.Friendships.AddresseeID,
|
||||
r.RequesterID, r.AddresseeID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: repoint friendship: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeBlocks repoints secondary's blocks to primary, dropping the direct
|
||||
// primary-secondary block (a self-block) and de-duplicating a counterparty already
|
||||
// blocked by primary in either direction (a block is undirected for suppression).
|
||||
func mergeBlocks(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
||||
if err := deletePair(ctx, tx, table.Blocks.DELETE(),
|
||||
table.Blocks.BlockerID, table.Blocks.BlockedID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: drop self-block: %w", err)
|
||||
}
|
||||
|
||||
priOthers := map[uuid.UUID]struct{}{}
|
||||
var prows []model.Blocks
|
||||
if err := selectEdges(ctx, tx, table.Blocks, table.Blocks.AllColumns, table.Blocks.BlockerID, table.Blocks.BlockedID, primary, &prows); err != nil {
|
||||
return fmt.Errorf("accountmerge: primary blocks: %w", err)
|
||||
}
|
||||
for _, r := range prows {
|
||||
priOthers[otherOf(r.BlockerID, r.BlockedID, primary)] = struct{}{}
|
||||
}
|
||||
|
||||
var srows []model.Blocks
|
||||
if err := selectEdges(ctx, tx, table.Blocks, table.Blocks.AllColumns, table.Blocks.BlockerID, table.Blocks.BlockedID, secondary, &srows); err != nil {
|
||||
return fmt.Errorf("accountmerge: secondary blocks: %w", err)
|
||||
}
|
||||
for _, r := range srows {
|
||||
if _, ok := priOthers[otherOf(r.BlockerID, r.BlockedID, secondary)]; ok {
|
||||
if err := deleteEdge(ctx, tx, table.Blocks.DELETE(),
|
||||
table.Blocks.BlockerID, table.Blocks.BlockedID, r.BlockerID, r.BlockedID); err != nil {
|
||||
return fmt.Errorf("accountmerge: drop dup block: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := repointEdge(ctx, tx, table.Blocks, table.Blocks.BlockerID, table.Blocks.BlockedID,
|
||||
r.BlockerID, r.BlockedID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: repoint block: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeInvitations deletes secondary's pending invitations as inviter (cascading to
|
||||
// their invitees) and repoints its invitee rows to primary, dropping a row where
|
||||
// primary is already an invitee of the same invitation.
|
||||
func mergeInvitations(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
||||
delInv := table.GameInvitations.DELETE().
|
||||
WHERE(table.GameInvitations.InviterID.EQ(postgres.UUID(secondary)))
|
||||
if _, err := delInv.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: delete secondary invitations: %w", err)
|
||||
}
|
||||
|
||||
priInv := map[uuid.UUID]struct{}{}
|
||||
var prows []model.GameInvitationInvitees
|
||||
if err := postgres.SELECT(table.GameInvitationInvitees.InvitationID).
|
||||
FROM(table.GameInvitationInvitees).
|
||||
WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(primary))).
|
||||
QueryContext(ctx, tx, &prows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
||||
return fmt.Errorf("accountmerge: primary invitees: %w", err)
|
||||
}
|
||||
for _, r := range prows {
|
||||
priInv[r.InvitationID] = struct{}{}
|
||||
}
|
||||
|
||||
var srows []model.GameInvitationInvitees
|
||||
if err := postgres.SELECT(table.GameInvitationInvitees.InvitationID).
|
||||
FROM(table.GameInvitationInvitees).
|
||||
WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(secondary))).
|
||||
QueryContext(ctx, tx, &srows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
||||
return fmt.Errorf("accountmerge: secondary invitees: %w", err)
|
||||
}
|
||||
for _, r := range srows {
|
||||
where := table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(r.InvitationID)).
|
||||
AND(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(secondary)))
|
||||
if _, dup := priInv[r.InvitationID]; dup {
|
||||
if _, err := table.GameInvitationInvitees.DELETE().WHERE(where).ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: drop dup invitee: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
upd := table.GameInvitationInvitees.UPDATE(table.GameInvitationInvitees.AccountID).
|
||||
SET(postgres.UUID(primary)).WHERE(where)
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: repoint invitee: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteEphemerals drops the secondary's pending email confirmations and friend
|
||||
// codes (short-lived, single-use; not worth carrying over).
|
||||
func deleteEphemerals(ctx context.Context, tx *sql.Tx, secondary uuid.UUID) error {
|
||||
if _, err := table.EmailConfirmations.DELETE().
|
||||
WHERE(table.EmailConfirmations.AccountID.EQ(postgres.UUID(secondary))).
|
||||
ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: delete confirmations: %w", err)
|
||||
}
|
||||
if _, err := table.FriendCodes.DELETE().
|
||||
WHERE(table.FriendCodes.AccountID.EQ(postgres.UUID(secondary))).
|
||||
ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: delete friend codes: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tombstone marks secondary retired, pointing at primary for audit.
|
||||
func tombstone(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||
upd := table.Accounts.UPDATE(table.Accounts.MergedInto, table.Accounts.MergedAt, table.Accounts.UpdatedAt).
|
||||
SET(postgres.UUID(primary), postgres.TimestampzT(now), postgres.TimestampzT(now)).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary)))
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: tombstone secondary: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// otherOf returns the endpoint of a two-account edge that is not self.
|
||||
func otherOf(a, b, self uuid.UUID) uuid.UUID {
|
||||
if a == self {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// selectEdges loads the rows of a symmetric two-column edge table touching account.
|
||||
func selectEdges[T any](ctx context.Context, tx *sql.Tx, tbl postgres.Table, cols postgres.Projection, left, right postgres.ColumnString, account uuid.UUID, dest *[]T) error {
|
||||
err := postgres.SELECT(cols).
|
||||
FROM(tbl).
|
||||
WHERE(left.EQ(postgres.UUID(account)).OR(right.EQ(postgres.UUID(account)))).
|
||||
QueryContext(ctx, tx, dest)
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// deletePair deletes the directed-or-reverse edge between a and b.
|
||||
func deletePair(ctx context.Context, tx *sql.Tx, del postgres.DeleteStatement, left, right postgres.ColumnString, a, b uuid.UUID) error {
|
||||
cond := left.EQ(postgres.UUID(a)).AND(right.EQ(postgres.UUID(b))).
|
||||
OR(left.EQ(postgres.UUID(b)).AND(right.EQ(postgres.UUID(a))))
|
||||
_, err := del.WHERE(cond).ExecContext(ctx, tx)
|
||||
return err
|
||||
}
|
||||
|
||||
// deleteEdge deletes the single edge identified by its (left, right) primary key.
|
||||
func deleteEdge(ctx context.Context, tx *sql.Tx, del postgres.DeleteStatement, left, right postgres.ColumnString, l, r uuid.UUID) error {
|
||||
cond := left.EQ(postgres.UUID(l)).AND(right.EQ(postgres.UUID(r)))
|
||||
_, err := del.WHERE(cond).ExecContext(ctx, tx)
|
||||
return err
|
||||
}
|
||||
|
||||
// repointEdge replaces the secondary endpoint of edge (l, r) with primary, keeping
|
||||
// the edge's direction.
|
||||
func repointEdge(ctx context.Context, tx *sql.Tx, tbl postgres.Table, left, right postgres.ColumnString, l, r, primary, secondary uuid.UUID) error {
|
||||
var col postgres.ColumnString
|
||||
var where postgres.BoolExpression
|
||||
if l == secondary {
|
||||
col, where = left, left.EQ(postgres.UUID(secondary)).AND(right.EQ(postgres.UUID(r)))
|
||||
} else {
|
||||
col, where = right, left.EQ(postgres.UUID(l)).AND(right.EQ(postgres.UUID(secondary)))
|
||||
}
|
||||
_, err := tbl.UPDATE(col).SET(postgres.UUID(primary)).WHERE(where).ExecContext(ctx, tx)
|
||||
return err
|
||||
}
|
||||
|
||||
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
|
||||
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("accountmerge: begin tx: %w", err)
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("accountmerge: commit tx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/* Admin console stylesheet. Deliberately small and dependency-free: the console
|
||||
is an internal operator tool served under /_gm, not a public surface. */
|
||||
:root {
|
||||
--bg: #11151c;
|
||||
--panel: #1b2230;
|
||||
--panel-hi: #232c3d;
|
||||
--ink: #e6ebf2;
|
||||
--ink-dim: #9aa7ba;
|
||||
--line: #2c3850;
|
||||
--accent: #5aa9ff;
|
||||
--danger: #ff6b6b;
|
||||
--ok: #4ecb8d;
|
||||
--warn: #f1c453;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 15px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.topbar .brand { font-weight: 700; letter-spacing: 0.04em; }
|
||||
.topbar .mainnav { display: flex; gap: 1rem; flex: 1; flex-wrap: wrap; }
|
||||
.topbar .mainnav a.active { color: var(--ink); border-bottom: 2px solid var(--accent); }
|
||||
.content { padding: 1.5rem; max-width: 1100px; margin: 0 auto; }
|
||||
h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
|
||||
.lede { color: var(--ink-dim); margin-top: 0; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin: 1.2rem 0; }
|
||||
.card {
|
||||
display: block;
|
||||
padding: 1rem 1.2rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.card:hover { background: var(--panel-hi); text-decoration: none; }
|
||||
.card h2 { font-size: 1.05rem; margin: 0 0 0.3rem; color: var(--accent); }
|
||||
.card .bignum { font-size: 1.8rem; margin: 0; color: var(--ink); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.panel {
|
||||
padding: 0.9rem 1.1rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.panel h2 { font-size: 1rem; margin: 0 0 0.6rem; color: var(--ink); }
|
||||
.kv { list-style: none; margin: 0; padding: 0; }
|
||||
.kv li { padding: 0.15rem 0; color: var(--ink-dim); }
|
||||
.kv li b { color: var(--ink); font-weight: 600; }
|
||||
.note { color: var(--ink-dim); font-style: italic; margin: 0.2rem 0; }
|
||||
.ok { color: var(--ok); }
|
||||
.bad { color: var(--danger); }
|
||||
.warn { color: var(--warn); }
|
||||
|
||||
.list { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.list th, .list td { text-align: left; padding: 0.35rem 0.6rem; border-bottom: 1px solid var(--line); }
|
||||
.list th { color: var(--ink-dim); font-weight: 600; }
|
||||
.list tr:hover td { background: var(--panel-hi); }
|
||||
.list td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.pager { display: flex; gap: 1rem; align-items: center; color: var(--ink-dim); }
|
||||
.subnav { color: var(--ink-dim); margin: -0.2rem 0 1rem; font-size: 0.9rem; }
|
||||
.subnav a.active { color: var(--ink); }
|
||||
|
||||
.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 label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
|
||||
.form input, .form select, .form textarea {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font: inherit;
|
||||
}
|
||||
.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 {
|
||||
background: var(--accent);
|
||||
color: #06121f;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { filter: brightness(1.1); }
|
||||
button.danger { background: var(--danger); color: #1a0606; }
|
||||
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 form { margin: 0; }
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Package adminconsole renders the backend's server-side admin console: a small,
|
||||
// dependency-free set of Go html/template pages plus one embedded stylesheet,
|
||||
// served under /_gm. It owns the rendering and the page view models only; the gin
|
||||
// handlers (internal/server) fetch the domain data, populate the view models and
|
||||
// gate the surface — the gateway puts HTTP Basic-Auth in front of /_gm and a
|
||||
// same-origin check guards the POST actions (docs/ARCHITECTURE.md §12). It mirrors
|
||||
// the shape of galaxy-game's adminconsole package, minus the per-operator CSRF
|
||||
// token and operator name (this console tracks no operator identity).
|
||||
package adminconsole
|
||||
@@ -0,0 +1,101 @@
|
||||
package adminconsole
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed templates
|
||||
var templatesFS embed.FS
|
||||
|
||||
//go:embed assets
|
||||
var assetsFS embed.FS
|
||||
|
||||
// Renderer holds the parsed admin console templates. It composes one template set
|
||||
// per content page, each combining the shared layout (the page chrome and the
|
||||
// "layout" entry template) with that page's "content" block, so rendering a page
|
||||
// is a single ExecuteTemplate call against "layout".
|
||||
type Renderer struct {
|
||||
pages map[string]*template.Template
|
||||
}
|
||||
|
||||
// PageData is the view model passed to every admin console page. Title is the
|
||||
// document title; ActiveNav marks the highlighted navigation entry; Data carries
|
||||
// the page-specific payload (one of the *View types in views.go).
|
||||
type PageData struct {
|
||||
Title string
|
||||
ActiveNav string
|
||||
Data any
|
||||
}
|
||||
|
||||
// NewRenderer parses the embedded layout and every content page under
|
||||
// templates/pages. It fails when a template cannot be parsed.
|
||||
func NewRenderer() (*Renderer, error) {
|
||||
base, err := template.New("layout").ParseFS(templatesFS, "templates/layout.gohtml")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin console layout: %w", err)
|
||||
}
|
||||
|
||||
pageFiles, err := fs.Glob(templatesFS, "templates/pages/*.gohtml")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("enumerate admin console pages: %w", err)
|
||||
}
|
||||
if len(pageFiles) == 0 {
|
||||
return nil, fmt.Errorf("admin console: no page templates found under templates/pages")
|
||||
}
|
||||
|
||||
pages := make(map[string]*template.Template, len(pageFiles))
|
||||
for _, file := range pageFiles {
|
||||
name := strings.TrimSuffix(path.Base(file), ".gohtml")
|
||||
clone, err := base.Clone()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone admin console layout for %q: %w", name, err)
|
||||
}
|
||||
if _, err := clone.ParseFS(templatesFS, file); err != nil {
|
||||
return nil, fmt.Errorf("parse admin console page %q: %w", name, err)
|
||||
}
|
||||
pages[name] = clone
|
||||
}
|
||||
|
||||
return &Renderer{pages: pages}, nil
|
||||
}
|
||||
|
||||
// MustNewRenderer is like NewRenderer but panics on error. The templates are
|
||||
// embedded at build time, so a parse failure is a programmer error.
|
||||
func MustNewRenderer() *Renderer {
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
// Render writes the named page, wrapped in the shared layout, to w using data. It
|
||||
// renders into an intermediate buffer first, so a mid-render failure never emits
|
||||
// a partial document. It returns an error for an unknown page or a failed render.
|
||||
func (r *Renderer) Render(w io.Writer, page string, data PageData) error {
|
||||
tmpl, ok := r.pages[page]
|
||||
if !ok {
|
||||
return fmt.Errorf("admin console: unknown page %q", page)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
|
||||
return fmt.Errorf("render admin console page %q: %w", page, err)
|
||||
}
|
||||
|
||||
_, err := buf.WriteTo(w)
|
||||
return err
|
||||
}
|
||||
|
||||
// Assets returns the embedded static asset tree rooted at the assets directory,
|
||||
// suitable for serving under /_gm/assets/.
|
||||
func Assets() (fs.FS, error) {
|
||||
return fs.Sub(assetsFS, "assets")
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package adminconsole
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRendererRendersEveryPage parses the embedded templates and renders each
|
||||
// page with a representative view, asserting the page executes, carries the
|
||||
// shared layout chrome and shows a distinctive value.
|
||||
func TestRendererRendersEveryPage(t *testing.T) {
|
||||
r, err := NewRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("new renderer: %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
page string
|
||||
data any
|
||||
want string
|
||||
}{
|
||||
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
|
||||
{"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", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
|
||||
Friends: []RelationRow{{AccountID: "b2", DisplayName: "Ann", Date: "2026-06-10 12:00"}},
|
||||
Blocks: []RelationRow{{AccountID: "c3", DisplayName: "Bob", Date: "2026-06-11 09:00"}},
|
||||
BlockedBy: []RelationRow{{AccountID: "d4", DisplayName: "Cay", Date: "2026-06-12 08:00"}},
|
||||
}, `/_gm/users/c3`},
|
||||
{"throttled", ThrottledView{
|
||||
Episodes: []ThrottleEpisodeRow{{Class: "user", Key: "a1", UserID: "a1", Rejected: 1234, FirstSeen: "2026-06-10 12:00", LastSeen: "2026-06-10 12:05"}},
|
||||
Flagged: []FlaggedAccountRow{{ID: "a1", DisplayName: "Kaya", FlaggedAt: "2026-06-10 12:05"}},
|
||||
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"},
|
||||
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1", Unread: true}}, UnreadOnly: true, Pager: NewPager(1, 50, 1)}, "unread only"},
|
||||
{"chatmessage", ChatMessageDetailView{ID: "m1", GameID: "g1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Kind: "message", Body: "good luck", Unread: true, Seats: []ChatSeatStatusRow{{Seat: 0, AccountID: "a1", DisplayName: "Kaya", Role: "sender"}, {Seat: 1, AccountID: "b2", DisplayName: "Opp", Role: "unread"}}}, "Read by seat"},
|
||||
{"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"},
|
||||
{"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", 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"},
|
||||
{"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.page, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := r.Render(&buf, tc.page, PageData{Title: tc.page, Data: tc.data}); err != nil {
|
||||
t.Fatalf("render %s: %v", tc.page, err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, tc.want) {
|
||||
t.Errorf("render %s: missing %q in output", tc.page, tc.want)
|
||||
}
|
||||
if !strings.Contains(out, "Scrabble · admin") {
|
||||
t.Errorf("render %s: missing layout chrome", tc.page)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestRendererUnknownPage(t *testing.T) {
|
||||
r := MustNewRenderer()
|
||||
if err := r.Render(&bytes.Buffer{}, "nope", PageData{}); err == nil {
|
||||
t.Fatal("expected an error rendering an unknown page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssets confirms the stylesheet is embedded and reachable under the assets
|
||||
// root.
|
||||
func TestAssets(t *testing.T) {
|
||||
fsys, err := Assets()
|
||||
if err != nil {
|
||||
t.Fatalf("assets: %v", err)
|
||||
}
|
||||
if _, err := fs.Stat(fsys, "console.css"); err != nil {
|
||||
t.Errorf("console.css not embedded: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{{define "layout" -}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>{{.Title}} · Scrabble admin</title>
|
||||
<link rel="stylesheet" href="/_gm/assets/console.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<span class="brand">Scrabble · admin</span>
|
||||
<nav class="mainnav">
|
||||
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</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/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/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
||||
<a href="/_gm/grafana/">Grafana ↗</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="content">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{- end}}
|
||||
@@ -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}}
|
||||
@@ -0,0 +1,14 @@
|
||||
{{define "content" -}}
|
||||
<h1>Broadcast</h1>
|
||||
{{with .Data}}
|
||||
<section class="panel"><h2>Post to the game channel</h2>
|
||||
{{if .ConnectorEnabled}}
|
||||
<form class="form col" method="post" action="/_gm/broadcast">
|
||||
<label>Message <textarea name="text" required></textarea></label>
|
||||
<div><button type="submit">Post to channel</button></div>
|
||||
</form>
|
||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||
</section>
|
||||
<p class="note">To message a single user, open their <a href="/_gm/users">user page</a>.</p>
|
||||
{{end}}
|
||||
{{- 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}}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<h1>Complaint: {{.Word}}</h1>
|
||||
<nav class="subnav"><a href="/_gm/complaints">« complaints</a></nav>
|
||||
<section class="panel"><h2>Details</h2>
|
||||
<ul class="kv">
|
||||
<li><b>Word</b> <code>{{.Word}}</code></li>
|
||||
<li><b>Variant</b> {{.Variant}}</li>
|
||||
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
||||
<li><b>Lookup at filing</b> {{if .WasValid}}<span class="ok">valid</span>{{else}}<span class="bad">invalid</span>{{end}}</li>
|
||||
<li><b>Filer note</b> {{if .Note}}{{.Note}}{{else}}<span class="note">none</span>{{end}}</li>
|
||||
<li><b>Game</b> <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a></li>
|
||||
<li><b>Filed</b> {{.CreatedAt}}</li>
|
||||
<li><b>Status</b> {{.Status}}</li>
|
||||
{{if .Resolved}}<li><b>Disposition</b> {{.Disposition}}</li><li><b>Resolution note</b> {{.ResolutionNote}}</li><li><b>Resolved</b> {{.ResolvedAt}}</li>{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
<section class="panel"><h2>{{if .Resolved}}Re-resolve{{else}}Resolve{{end}}</h2>
|
||||
<form class="form col" method="post" action="/_gm/complaints/{{.ID}}/resolve">
|
||||
<label>Disposition
|
||||
<select name="disposition">
|
||||
<option value="reject">reject — dictionary is correct</option>
|
||||
<option value="accept_add">accept — add word to the dictionary</option>
|
||||
<option value="accept_remove">accept — remove word from the dictionary</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Note <textarea name="note"></textarea></label>
|
||||
<div><button type="submit">Resolve</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,30 @@
|
||||
{{define "content" -}}
|
||||
<h1>Complaints</h1>
|
||||
{{with .Data}}
|
||||
<nav class="subnav">
|
||||
<a href="/_gm/complaints?status=open"{{if eq .Status "open"}} class="active"{{end}}>open</a> ·
|
||||
<a href="/_gm/complaints?status=resolved"{{if eq .Status "resolved"}} class="active"{{end}}>resolved</a> ·
|
||||
<a href="/_gm/complaints"{{if eq .Status ""}} class="active"{{end}}>all</a>
|
||||
</nav>
|
||||
<table class="list">
|
||||
<thead><tr><th>Word</th><th>Variant</th><th>Was valid</th><th>Status</th><th>Disposition</th><th>Filed</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td><a href="/_gm/complaints/{{.ID}}">{{.Word}}</a></td>
|
||||
<td>{{.Variant}}</td>
|
||||
<td>{{if .WasValid}}<span class="ok">valid</span>{{else}}<span class="bad">invalid</span>{{end}}</td>
|
||||
<td>{{.Status}}</td>
|
||||
<td>{{.Disposition}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
</tr>
|
||||
{{else}}<tr><td colspan="6"><span class="note">no complaints</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .Pager.HasPrev}}<a href="/_gm/complaints?status={{.Status}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||
{{if .Pager.HasNext}}<a href="/_gm/complaints?status={{.Status}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,25 @@
|
||||
{{define "content" -}}
|
||||
<h1>Dashboard</h1>
|
||||
<p class="lede">Operator console for users, games, complaints and dictionaries.</p>
|
||||
{{with .Data}}
|
||||
<div class="cards">
|
||||
<a class="card" href="/_gm/users"><h2>Users</h2><p class="bignum">{{.Accounts}}</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/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>
|
||||
</div>
|
||||
<section class="panel">
|
||||
<h2>Dictionaries</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Variant</th><th>Latest</th><th>Resident versions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Variants}}
|
||||
<tr><td>{{.Variant}}</td><td>{{.Latest}}</td><td>{{range .Versions}}<span class="pill">{{.}}</span> {{end}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,46 @@
|
||||
{{define "content" -}}
|
||||
<h1>Dictionary</h1>
|
||||
{{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>
|
||||
<table class="list">
|
||||
<thead><tr><th>Variant</th><th>Resident</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Variants}}
|
||||
<tr><td>{{.Variant}}</td><td>{{range .Versions}}<span class="pill">{{.}}</span> {{end}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel"><h2>Update dictionaries</h2>
|
||||
<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/upload" enctype="multipart/form-data">
|
||||
<label>Release archive <input type="file" name="archive" accept=".gz,.tgz,application/gzip" required></label>
|
||||
<div><button type="submit">Upload & preview</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Pending dictionary changes</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Variant</th><th>Action</th><th>Word</th><th>Resolved</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Changes}}
|
||||
<tr><td>{{.Variant}}</td><td>{{.Action}}</td><td><code>{{.Word}}</code></td><td>{{.ResolvedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="4"><span class="note">no pending changes</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<form class="form" method="post" action="/_gm/dictionary/changes/apply">
|
||||
<label>Mark applied for variant
|
||||
<select name="variant">
|
||||
<option value="scrabble_en">scrabble_en</option>
|
||||
<option value="scrabble_ru">scrabble_ru</option>
|
||||
<option value="erudit_ru">erudit_ru</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>In version <input type="text" name="version" value="{{.ActiveVersion}}" required></label>
|
||||
<div><button type="submit">Mark applied</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -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}}
|
||||
@@ -0,0 +1,113 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<h1>Game {{.ID}}</h1>
|
||||
<nav class="subnav"><a href="/_gm/games">« games</a> · <a href="/_gm/messages?game={{.ID}}">messages</a></nav>
|
||||
<section class="panel"><h2>Summary</h2>
|
||||
<ul class="kv">
|
||||
<li><b>Variant</b> {{.Variant}}</li>
|
||||
<li><b>Dictionary</b> {{.DictVersion}}</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>To move</b> seat {{.ToMove}}</li>
|
||||
<li><b>Moves</b> {{.MoveCount}}</li>
|
||||
<li><b>Created</b> {{.CreatedAt}}</li>
|
||||
<li><b>Updated</b> {{.UpdatedAt}}</li>
|
||||
{{if .FinishedAt}}<li><b>Finished</b> {{.FinishedAt}}</li>{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
<section class="panel"><h2>Seats</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Seat</th><th>Player</th><th>Score</th><th>Hints used</th><th>Winner</th><th>Robot</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Seats}}
|
||||
<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}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{if .HasReplay}}
|
||||
<section class="panel"><h2>Replay</h2>
|
||||
<div class="replay-stage">
|
||||
<div class="rack-slot rack-top" data-seat="0"></div>
|
||||
<div class="rack-slot rack-left" data-seat="2"></div>
|
||||
<div class="replay-board" id="replay-board"></div>
|
||||
<div class="rack-slot rack-right" data-seat="3"></div>
|
||||
<div class="rack-slot rack-bottom" data-seat="1"></div>
|
||||
</div>
|
||||
<div class="replay-controls">
|
||||
<button type="button" id="replay-prev">◀ prev</button>
|
||||
<span class="replay-pos" id="replay-pos"></span>
|
||||
<button type="button" id="replay-next">next ▶</button>
|
||||
</div>
|
||||
<ol class="replay-log" id="replay-log"></ol>
|
||||
<script>
|
||||
const REPLAY = {{.ReplayJSON}};
|
||||
(function(){
|
||||
if(!REPLAY||!REPLAY.steps){return;}
|
||||
const N=15, COLS="ABCDEFGHIJKLMNO", PREM={tw:"3W",dw:"2W",tl:"3L",dl:"2L"};
|
||||
const boardEl=document.getElementById("replay-board"), logEl=document.getElementById("replay-log");
|
||||
const posEl=document.getElementById("replay-pos"), prevBtn=document.getElementById("replay-prev"), nextBtn=document.getElementById("replay-next");
|
||||
let step=0;
|
||||
function esc(s){const d=document.createElement("div");d.textContent=s==null?"":s;return d.innerHTML;}
|
||||
function tileHTML(t){const sub=(t.v&&t.v>0)?"<sub>"+t.v+"<\/sub>":"";return "<span class=\"tile"+(t.b?" blank":"")+"\">"+esc(t.l)+sub+"<\/span>";}
|
||||
function placedAt(k){const m={};for(let s=1;s<=k;s++){const mv=REPLAY.steps[s]&&REPLAY.steps[s].move;if(mv&&mv.placements){for(const p of mv.placements){m[p.r+","+p.c]=p;}}}return m;}
|
||||
function renderBoard(){
|
||||
const placed=placedAt(step);let h="<div class=\"board-grid\"><div class=\"bh corner\"><\/div>";
|
||||
for(let c=0;c<N;c++){h+="<div class=\"bh\">"+COLS[c]+"<\/div>";}
|
||||
for(let r=0;r<N;r++){h+="<div class=\"bh\">"+(r+1)+"<\/div>";
|
||||
for(let c=0;c<N;c++){const p=placed[r+","+c];
|
||||
if(p){h+="<div class=\"cell filled\">"+tileHTML(p)+"<\/div>";continue;}
|
||||
const prem=REPLAY.premium[r][c], centre=(r===REPLAY.centre[0]&&c===REPLAY.centre[1]);
|
||||
const label=centre?"★":(prem?PREM[prem]:"");
|
||||
h+="<div class=\"cell "+(prem||"")+(centre?" centre":"")+"\">"+(label?"<span class=\"prem\">"+label+"<\/span>":"")+"<\/div>";}}
|
||||
h+="<\/div>";boardEl.innerHTML=h;
|
||||
}
|
||||
function renderRacks(){
|
||||
const st=REPLAY.steps[step];
|
||||
document.querySelectorAll(".rack-slot").forEach(function(slot){
|
||||
const seat=parseInt(slot.dataset.seat,10), info=REPLAY.seats.find(function(s){return s.seat===seat;}), rack=st.racks[seat];
|
||||
if(!info||!rack){slot.style.display="none";slot.innerHTML="";return;}
|
||||
slot.style.display="";slot.classList.toggle("active",st.toMove===seat);
|
||||
const nm=info.accountId?"<a href=\"/_gm/users/"+info.accountId+"\">"+esc(info.name)+"<\/a>":esc(info.name||("seat "+seat));
|
||||
slot.innerHTML="<div class=\"rack-name\">"+nm+" · "+(st.scores[seat]||0)+"<\/div><div class=\"rack-tiles\">"+rack.map(tileHTML).join("")+"<\/div>";
|
||||
});
|
||||
}
|
||||
function renderLog(){
|
||||
let h="";
|
||||
for(let s=1;s<=step;s++){const st=REPLAY.steps[s], m=st.move;if(!m){continue;}
|
||||
const who=((REPLAY.seats.find(function(x){return x.seat===m.seat;})||{}).name)||("seat "+m.seat);
|
||||
let desc;
|
||||
if(m.action==="play"){desc="played "+((m.words&&m.words.length)?m.words.join(", "):"")+" for "+m.score;}
|
||||
else if(m.action==="exchange"){desc="exchanged "+((m.exchanged&&m.exchanged.length)||0)+" tiles";}
|
||||
else if(m.action==="pass"){desc="passed";}
|
||||
else{desc=m.action;}
|
||||
const drew=(st.drawn&&st.drawn.length)?" · drew "+st.drawn.map(function(t){return t.l;}).join(""):"";
|
||||
h+="<li class=\""+(s===step?"cur":"")+"\">"+esc(who)+" "+esc(desc)+drew+" · bag "+st.bagLen+"<\/li>";}
|
||||
logEl.innerHTML=h||"<li class=\"note\">opening position<\/li>";
|
||||
}
|
||||
function render(){renderBoard();renderRacks();renderLog();posEl.textContent=step+" / "+(REPLAY.steps.length-1);prevBtn.disabled=step<=0;nextBtn.disabled=step>=REPLAY.steps.length-1;}
|
||||
prevBtn.onclick=function(){if(step>0){step--;render();}};
|
||||
nextBtn.onclick=function(){if(step<REPLAY.steps.length-1){step++;render();}};
|
||||
document.addEventListener("keydown",function(e){if(e.key==="ArrowLeft"){prevBtn.click();}else if(e.key==="ArrowRight"){nextBtn.click();}});
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,24 @@
|
||||
{{define "content" -}}
|
||||
<h1>Games</h1>
|
||||
{{with .Data}}
|
||||
<nav class="subnav">
|
||||
<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=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
|
||||
</nav>
|
||||
<table class="list">
|
||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th>🤖</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<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="6"><span class="note">no games</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .Pager.HasPrev}}<a href="/_gm/games?status={{.Status}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||
{{if .Pager.HasNext}}<a href="/_gm/games?status={{.Status}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,7 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<h1>{{.Heading}}</h1>
|
||||
<p>{{.Body}}</p>
|
||||
<p><a href="{{.Back}}">« back</a></p>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -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}}
|
||||
@@ -0,0 +1,156 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<h1>{{.DisplayName}}</h1>
|
||||
<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">
|
||||
<section class="panel"><h2>Account</h2>
|
||||
<ul class="kv">
|
||||
<li><b>ID</b> {{.ID}}</li>
|
||||
<li><b>Language</b> {{.Language}}</li>
|
||||
<li><b>Timezone</b> {{.TimeZone}}</li>
|
||||
<li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li>
|
||||
<li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li>
|
||||
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
|
||||
<li><b>Hint wallet</b> {{.HintBalance}}</li>
|
||||
{{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>
|
||||
</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 class="panel"><h2>Statistics</h2>
|
||||
{{if .HasStats}}
|
||||
<ul class="kv">
|
||||
<li><b>Wins</b> {{.Stats.Wins}}</li>
|
||||
<li><b>Losses</b> {{.Stats.Losses}}</li>
|
||||
<li><b>Draws</b> {{.Stats.Draws}}</li>
|
||||
<li><b>Moves</b> {{.Stats.Moves}}</li>
|
||||
<li><b>Hints used</b> {{.Stats.HintsUsed}}</li>
|
||||
<li><b>Best game</b> {{.Stats.MaxGamePoints}}</li>
|
||||
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
|
||||
</ul>
|
||||
{{else}}<p class="note">no statistics</p>{{end}}
|
||||
</section>
|
||||
</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>
|
||||
<table class="list">
|
||||
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Identities}}
|
||||
<tr><td>{{.Kind}}</td><td><code>{{.ExternalID}}</code></td><td>{{if .Confirmed}}<span class="ok">yes</span>{{else}}<span class="warn">no</span>{{end}}</td><td>{{.CreatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel"><h2>Friends</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Account</th><th>Friends since</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Friends}}
|
||||
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
|
||||
{{else}}<tr><td colspan="2"><span class="note">no friends</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel"><h2>Blocks</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Blocks}}
|
||||
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
|
||||
{{else}}<tr><td colspan="2"><span class="note">blocks no one</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel"><h2>Blocked by</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .BlockedBy}}
|
||||
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
|
||||
{{else}}<tr><td colspan="2"><span class="note">blocked by no one</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{if .TelegramID}}
|
||||
<section class="panel"><h2>Send Telegram message</h2>
|
||||
{{if .ConnectorEnabled}}
|
||||
<form class="form col" method="post" action="/_gm/users/{{.ID}}/message">
|
||||
<label>Message <textarea name="text" required></textarea></label>
|
||||
<div><button type="submit">Send to user</button></div>
|
||||
</form>
|
||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
<section class="panel"><h2>Games</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Games}}
|
||||
<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>
|
||||
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,37 @@
|
||||
{{define "content" -}}
|
||||
<h1>Users</h1>
|
||||
{{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">
|
||||
<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>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></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>{{.Language}}</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>
|
||||
{{else}}
|
||||
<tr><td colspan="8"><span class="note">no users</span></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .Pager.HasPrev}}<a href="/_gm/users?{{.FilterQuery}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||
{{if .Pager.HasNext}}<a href="/_gm/users?{{.FilterQuery}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,570 @@
|
||||
package adminconsole
|
||||
|
||||
import "html/template"
|
||||
|
||||
// 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
|
||||
// templates stay logic-free.
|
||||
|
||||
// Pager is the shared list pagination state.
|
||||
type Pager struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
}
|
||||
|
||||
// NewPager builds the pagination state for a 1-based page of pageSize over total
|
||||
// items.
|
||||
func NewPager(page, pageSize, total int) Pager {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
p := Pager{Page: page, PageSize: pageSize, Total: total, PrevPage: page - 1, NextPage: page + 1}
|
||||
p.HasPrev = page > 1
|
||||
p.HasNext = page*pageSize < total
|
||||
return p
|
||||
}
|
||||
|
||||
// VariantVersions lists the dictionary versions resident for one variant.
|
||||
type VariantVersions struct {
|
||||
Variant string
|
||||
Latest string
|
||||
Versions []string
|
||||
}
|
||||
|
||||
// DashboardView is the landing-page summary.
|
||||
type DashboardView struct {
|
||||
Accounts int
|
||||
Games int
|
||||
ActiveGames int
|
||||
OpenComplaints int
|
||||
OpenFeedback 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
|
||||
}
|
||||
|
||||
// UsersView is the paginated account list.
|
||||
type UsersView struct {
|
||||
Items []UserRow
|
||||
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. 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 {
|
||||
ID string
|
||||
DisplayName string
|
||||
Kind string
|
||||
Language string
|
||||
Guest bool
|
||||
FlaggedHighRate bool
|
||||
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.
|
||||
type UserDetailView struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
Language string
|
||||
TimeZone string
|
||||
Guest bool
|
||||
NotificationsInAppOnly bool
|
||||
PaidAccount bool
|
||||
// MergedInto is the primary account id when this account has been retired by a
|
||||
// merge, or empty for a live account.
|
||||
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
|
||||
// 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
|
||||
HasStats bool
|
||||
Stats StatsRow
|
||||
Identities []IdentityRow
|
||||
Games []GameRow
|
||||
TelegramID string
|
||||
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.
|
||||
type StatsRow struct {
|
||||
Wins int
|
||||
Losses int
|
||||
Draws int
|
||||
MaxGamePoints int
|
||||
MaxWordPoints int
|
||||
Moves int
|
||||
HintsUsed int
|
||||
}
|
||||
|
||||
// IdentityRow is one platform/email identity of an account.
|
||||
type IdentityRow struct {
|
||||
Kind string
|
||||
ExternalID string
|
||||
Confirmed bool
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// GameRow is one game row in a list.
|
||||
type GameRow struct {
|
||||
ID string
|
||||
Variant string
|
||||
Status string
|
||||
Players int
|
||||
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.
|
||||
type GamesView struct {
|
||||
Items []GameRow
|
||||
Status string
|
||||
Pager Pager
|
||||
}
|
||||
|
||||
// GameDetailView is one game with its seats.
|
||||
type GameDetailView struct {
|
||||
ID string
|
||||
Variant string
|
||||
DictVersion string
|
||||
Status string
|
||||
Players int
|
||||
ToMove int
|
||||
EndReason string
|
||||
MoveCount int
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
FinishedAt string
|
||||
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
|
||||
VsAI bool
|
||||
Seats []SeatRow
|
||||
// HasRobot is true when any seat is a robot, gating the robot-target caption;
|
||||
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
||||
HasRobot bool
|
||||
RobotTargetPct int
|
||||
// ReplayJSON is the game-replay payload (board, seats, per-step racks/scores/bag) the
|
||||
// game_detail page feeds to its vanilla-JS stepper; HasReplay gates the replay section.
|
||||
ReplayJSON template.JS
|
||||
HasReplay bool
|
||||
// SetupDraws is the first-move draw — one row per tile drawn (docs/ARCHITECTURE.md §6) —
|
||||
// and FirstMover is the resolved name of the seat-0 player the draw elected.
|
||||
SetupDraws []SetupDrawRow
|
||||
FirstMover string
|
||||
}
|
||||
|
||||
// SetupDrawRow is one tile drawn in the first-move seeding (docs/ARCHITECTURE.md §6): the
|
||||
// round, the player (Name/AccountID, or "(opponent)" with an empty AccountID for an
|
||||
// auto-match synthetic draw not yet back-filled), the drawn letter (upper-cased; "?" for a
|
||||
// blank) and its draw rank.
|
||||
type SetupDrawRow struct {
|
||||
Round int
|
||||
Name string
|
||||
AccountID string
|
||||
Letter string
|
||||
Blank bool
|
||||
Rank int
|
||||
}
|
||||
|
||||
// SeatRow is one seat of a game. For a robot seat (IsRobot) RobotIntent is the game's
|
||||
// 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 {
|
||||
Seat int
|
||||
DisplayName string
|
||||
AccountID string
|
||||
Score int
|
||||
HintsUsed int
|
||||
Winner bool
|
||||
IsRobot bool
|
||||
RobotIntent string
|
||||
NextMove string
|
||||
}
|
||||
|
||||
// ComplaintsView is the paginated complaint review queue.
|
||||
type ComplaintsView struct {
|
||||
Items []ComplaintRow
|
||||
Status string
|
||||
Pager Pager
|
||||
}
|
||||
|
||||
// ComplaintRow is one complaint row in the queue.
|
||||
type ComplaintRow struct {
|
||||
ID string
|
||||
Word string
|
||||
Variant string
|
||||
WasValid bool
|
||||
Status string
|
||||
Disposition string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// ComplaintDetailView is one complaint with its resolution state and form.
|
||||
type ComplaintDetailView struct {
|
||||
ID string
|
||||
Word string
|
||||
Variant string
|
||||
DictVersion string
|
||||
WasValid bool
|
||||
Note string
|
||||
Status string
|
||||
Disposition string
|
||||
ResolutionNote string
|
||||
CreatedAt string
|
||||
ResolvedAt string
|
||||
GameID string
|
||||
Resolved bool
|
||||
}
|
||||
|
||||
// DictionaryView lists the resident versions per variant, the active version new
|
||||
// games pin, and the pending wordlist changes from accepted complaints.
|
||||
type DictionaryView struct {
|
||||
// ActiveVersion is the dictionary version new games pin; the update form sets it.
|
||||
ActiveVersion string
|
||||
Variants []VariantVersions
|
||||
Changes []DictChangeRow
|
||||
}
|
||||
|
||||
// DictChangeRow is one pending wordlist edit.
|
||||
type DictChangeRow struct {
|
||||
Variant string
|
||||
Word string
|
||||
Action 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.
|
||||
type BroadcastView struct {
|
||||
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.
|
||||
type MessageView struct {
|
||||
Heading string
|
||||
Body 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)
|
||||
}
|
||||
}
|
||||
@@ -5,28 +5,152 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/postgres"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/robot"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
)
|
||||
|
||||
// Config holds the backend's runtime configuration.
|
||||
type Config struct {
|
||||
// HTTPAddr is the listen address of the HTTP listener (host:port).
|
||||
HTTPAddr string
|
||||
// GRPCAddr is the listen address of the gRPC push listener (host:port) that
|
||||
// streams live events to the gateway.
|
||||
GRPCAddr string
|
||||
// LogLevel is the zap log level: "debug", "info", "warn" or "error".
|
||||
LogLevel string
|
||||
// Postgres configures the primary database pool.
|
||||
Postgres postgres.Config
|
||||
// Telemetry configures the OpenTelemetry providers.
|
||||
Telemetry telemetry.Config
|
||||
// Game configures the game subsystem (dictionaries, sweeper, live-game cache).
|
||||
Game game.Config
|
||||
// Lobby configures matchmaking robot substitution (wait window, reaper cadence).
|
||||
Lobby lobby.Config
|
||||
// Robot configures the robot opponent driver (scan cadence).
|
||||
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
|
||||
// selects the development log mailer (the code is logged, not sent).
|
||||
SMTP account.SMTPConfig
|
||||
// ConnectorAddr is the gRPC address of the Telegram platform connector
|
||||
// side-service, used by the admin console to send operator broadcasts. Empty
|
||||
// disables broadcasts (the admin broadcast actions report "not configured").
|
||||
ConnectorAddr string
|
||||
// GuestReapInterval is the cadence of the abandoned-guest reaper sweep.
|
||||
GuestReapInterval time.Duration
|
||||
// GuestRetention is the account age past which an unused guest (no game seat)
|
||||
// is eligible for deletion by the reaper.
|
||||
GuestRetention time.Duration
|
||||
}
|
||||
|
||||
// Defaults applied when the corresponding environment variable is unset.
|
||||
const (
|
||||
defaultHTTPAddr = ":8080"
|
||||
defaultGRPCAddr = ":9090"
|
||||
defaultLogLevel = "info"
|
||||
defaultGuestReapInterval = time.Hour
|
||||
defaultGuestRetention = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// Load reads the configuration from the environment, applies defaults for
|
||||
// unset variables, and validates the result.
|
||||
// Load reads the configuration from the environment, applies defaults for unset
|
||||
// variables, and validates the result.
|
||||
func Load() (Config, error) {
|
||||
pg := postgres.DefaultConfig()
|
||||
pg.DSN = os.Getenv("BACKEND_POSTGRES_DSN")
|
||||
var err error
|
||||
if pg.MaxOpenConns, err = envInt("BACKEND_POSTGRES_MAX_OPEN_CONNS", pg.MaxOpenConns); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if pg.MaxIdleConns, err = envInt("BACKEND_POSTGRES_MAX_IDLE_CONNS", pg.MaxIdleConns); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if pg.ConnMaxLifetime, err = envDuration("BACKEND_POSTGRES_CONN_MAX_LIFETIME", pg.ConnMaxLifetime); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if pg.OperationTimeout, err = envDuration("BACKEND_POSTGRES_OPERATION_TIMEOUT", pg.OperationTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
tel := telemetry.DefaultConfig()
|
||||
tel.ServiceName = envOr("BACKEND_SERVICE_NAME", tel.ServiceName)
|
||||
tel.TracesExporter = envOr("BACKEND_OTEL_TRACES_EXPORTER", tel.TracesExporter)
|
||||
tel.MetricsExporter = envOr("BACKEND_OTEL_METRICS_EXPORTER", tel.MetricsExporter)
|
||||
|
||||
gm := game.DefaultConfig()
|
||||
gm.DictDir = envOr("BACKEND_DICT_DIR", gm.DictDir)
|
||||
gm.DictVersion = envOr("BACKEND_DICT_VERSION", gm.DictVersion)
|
||||
if gm.TimeoutSweepInterval, err = envDuration("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", gm.TimeoutSweepInterval); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if gm.CacheTTL, err = envDuration("BACKEND_GAME_CACHE_TTL", gm.CacheTTL); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
lb := lobby.DefaultConfig()
|
||||
if lb.RobotWait, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT", lb.RobotWait); err != nil {
|
||||
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 {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
rb := robot.DefaultConfig()
|
||||
if rb.DriveInterval, err = envDuration("BACKEND_ROBOT_DRIVE_INTERVAL", rb.DriveInterval); err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
guestRetention, err := envDuration("BACKEND_GUEST_RETENTION", defaultGuestRetention)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
smtp := account.SMTPConfig{
|
||||
Host: os.Getenv("BACKEND_SMTP_HOST"),
|
||||
Port: envOr("BACKEND_SMTP_PORT", "587"),
|
||||
Username: os.Getenv("BACKEND_SMTP_USERNAME"),
|
||||
Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
|
||||
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
|
||||
}
|
||||
|
||||
c := Config{
|
||||
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
|
||||
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
|
||||
LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel),
|
||||
Postgres: pg,
|
||||
Telemetry: tel,
|
||||
Game: gm,
|
||||
Lobby: lb,
|
||||
Robot: rb,
|
||||
RateWatch: rw,
|
||||
SMTP: smtp,
|
||||
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
||||
GuestReapInterval: guestReapInterval,
|
||||
GuestRetention: guestRetention,
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
return Config{}, err
|
||||
@@ -44,6 +168,33 @@ func (c Config) validate() error {
|
||||
if c.HTTPAddr == "" {
|
||||
return fmt.Errorf("config: BACKEND_HTTP_ADDR must not be empty")
|
||||
}
|
||||
if c.GRPCAddr == "" {
|
||||
return fmt.Errorf("config: BACKEND_GRPC_ADDR must not be empty")
|
||||
}
|
||||
if err := c.Postgres.Validate(); err != nil {
|
||||
return fmt.Errorf("config: %w (set BACKEND_POSTGRES_DSN)", err)
|
||||
}
|
||||
if err := c.Telemetry.Validate(); err != nil {
|
||||
return fmt.Errorf("config: %w", err)
|
||||
}
|
||||
if err := c.Game.Validate(); err != nil {
|
||||
return fmt.Errorf("config: %w (set BACKEND_DICT_DIR)", err)
|
||||
}
|
||||
if err := c.Lobby.Validate(); err != nil {
|
||||
return fmt.Errorf("config: %w", err)
|
||||
}
|
||||
if err := c.Robot.Validate(); err != nil {
|
||||
return fmt.Errorf("config: %w", err)
|
||||
}
|
||||
if err := c.RateWatch.Validate(); err != nil {
|
||||
return fmt.Errorf("config: %w", err)
|
||||
}
|
||||
if c.GuestReapInterval <= 0 {
|
||||
return fmt.Errorf("config: BACKEND_GUEST_REAP_INTERVAL must be positive")
|
||||
}
|
||||
if c.GuestRetention <= 0 {
|
||||
return fmt.Errorf("config: BACKEND_GUEST_RETENTION must be positive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -55,3 +206,31 @@ func envOr(key, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// envInt parses the environment variable named key as an int, returning
|
||||
// fallback when it is unset and an error when it is set but malformed.
|
||||
func envInt(key string, fallback int) (int, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("config: %s: %w", key, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// envDuration parses the environment variable named key as a Go duration,
|
||||
// returning fallback when it is unset and an error when it is set but malformed.
|
||||
func envDuration(key string, fallback time.Duration) (time.Duration, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("config: %s: %w", key, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
// TestLoadDefaults verifies that Load applies defaults when the environment is
|
||||
// empty.
|
||||
"scrabble/backend/internal/postgres"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
)
|
||||
|
||||
// testDSN is a syntactically valid DSN used to satisfy the required-DSN check.
|
||||
const testDSN = "postgres://u:p@localhost:5432/db?search_path=backend&sslmode=disable"
|
||||
|
||||
// TestLoadDefaults verifies that Load applies defaults when only the required
|
||||
// DSN is set.
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
t.Setenv("BACKEND_HTTP_ADDR", "")
|
||||
t.Setenv("BACKEND_LOG_LEVEL", "")
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
||||
t.Setenv("BACKEND_DICT_DIR", "/dict")
|
||||
t.Setenv("BACKEND_DICT_VERSION", "")
|
||||
t.Setenv("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", "")
|
||||
t.Setenv("BACKEND_GAME_CACHE_TTL", "")
|
||||
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
@@ -18,12 +32,45 @@ func TestLoadDefaults(t *testing.T) {
|
||||
if c.LogLevel != defaultLogLevel {
|
||||
t.Errorf("LogLevel = %q, want %q", c.LogLevel, defaultLogLevel)
|
||||
}
|
||||
if c.Postgres.DSN != testDSN {
|
||||
t.Errorf("Postgres.DSN = %q, want %q", c.Postgres.DSN, testDSN)
|
||||
}
|
||||
if c.Postgres.MaxOpenConns != postgres.DefaultMaxOpenConns {
|
||||
t.Errorf("Postgres.MaxOpenConns = %d, want %d", c.Postgres.MaxOpenConns, postgres.DefaultMaxOpenConns)
|
||||
}
|
||||
if c.Telemetry.ServiceName != telemetry.DefaultServiceName {
|
||||
t.Errorf("Telemetry.ServiceName = %q, want %q", c.Telemetry.ServiceName, telemetry.DefaultServiceName)
|
||||
}
|
||||
if c.Telemetry.TracesExporter != telemetry.ExporterNone {
|
||||
t.Errorf("Telemetry.TracesExporter = %q, want %q", c.Telemetry.TracesExporter, telemetry.ExporterNone)
|
||||
}
|
||||
if c.Game.DictDir != "/dict" {
|
||||
t.Errorf("Game.DictDir = %q, want /dict", c.Game.DictDir)
|
||||
}
|
||||
if c.Game.DictVersion != "v1" {
|
||||
t.Errorf("Game.DictVersion = %q, want v1", c.Game.DictVersion)
|
||||
}
|
||||
if c.Game.TimeoutSweepInterval != time.Minute {
|
||||
t.Errorf("Game.TimeoutSweepInterval = %s, want 1m", c.Game.TimeoutSweepInterval)
|
||||
}
|
||||
if c.Game.CacheTTL != 24*time.Hour {
|
||||
t.Errorf("Game.CacheTTL = %s, want 24h", c.Game.CacheTTL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOverrides verifies that environment variables override the defaults.
|
||||
func TestLoadOverrides(t *testing.T) {
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
||||
t.Setenv("BACKEND_HTTP_ADDR", "127.0.0.1:9090")
|
||||
t.Setenv("BACKEND_LOG_LEVEL", "debug")
|
||||
t.Setenv("BACKEND_POSTGRES_MAX_OPEN_CONNS", "7")
|
||||
t.Setenv("BACKEND_POSTGRES_OPERATION_TIMEOUT", "3s")
|
||||
t.Setenv("BACKEND_SERVICE_NAME", "scrabble-test")
|
||||
t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "stdout")
|
||||
t.Setenv("BACKEND_DICT_DIR", "/srv/dict")
|
||||
t.Setenv("BACKEND_DICT_VERSION", "2026-06")
|
||||
t.Setenv("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", "30s")
|
||||
t.Setenv("BACKEND_GAME_CACHE_TTL", "1h")
|
||||
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
@@ -32,15 +79,126 @@ func TestLoadOverrides(t *testing.T) {
|
||||
if c.HTTPAddr != "127.0.0.1:9090" {
|
||||
t.Errorf("HTTPAddr = %q, want %q", c.HTTPAddr, "127.0.0.1:9090")
|
||||
}
|
||||
if c.Game.DictDir != "/srv/dict" || c.Game.DictVersion != "2026-06" {
|
||||
t.Errorf("Game dict = %q/%q, want /srv/dict/2026-06", c.Game.DictDir, c.Game.DictVersion)
|
||||
}
|
||||
if c.Game.TimeoutSweepInterval != 30*time.Second {
|
||||
t.Errorf("Game.TimeoutSweepInterval = %s, want 30s", c.Game.TimeoutSweepInterval)
|
||||
}
|
||||
if c.Game.CacheTTL != time.Hour {
|
||||
t.Errorf("Game.CacheTTL = %s, want 1h", c.Game.CacheTTL)
|
||||
}
|
||||
if c.LogLevel != "debug" {
|
||||
t.Errorf("LogLevel = %q, want %q", c.LogLevel, "debug")
|
||||
t.Errorf("LogLevel = %q", c.LogLevel)
|
||||
}
|
||||
if c.Postgres.MaxOpenConns != 7 {
|
||||
t.Errorf("Postgres.MaxOpenConns = %d, want 7", c.Postgres.MaxOpenConns)
|
||||
}
|
||||
if c.Postgres.OperationTimeout != 3*time.Second {
|
||||
t.Errorf("Postgres.OperationTimeout = %s, want 3s", c.Postgres.OperationTimeout)
|
||||
}
|
||||
if c.Telemetry.ServiceName != "scrabble-test" {
|
||||
t.Errorf("Telemetry.ServiceName = %q", c.Telemetry.ServiceName)
|
||||
}
|
||||
if c.Telemetry.TracesExporter != telemetry.ExporterStdout {
|
||||
t.Errorf("Telemetry.TracesExporter = %q, want %q", c.Telemetry.TracesExporter, telemetry.ExporterStdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRejectsMissingDSN verifies that an empty DSN fails validation.
|
||||
func TestLoadRejectsMissingDSN(t *testing.T) {
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", "")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: expected an error for a missing DSN, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRejectsInvalidLevel verifies that an unknown log level is rejected.
|
||||
func TestLoadRejectsInvalidLevel(t *testing.T) {
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
||||
t.Setenv("BACKEND_LOG_LEVEL", "verbose")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: expected an error for an invalid log level, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRejectsMissingDictDir verifies that an unset dictionary directory fails
|
||||
// validation (the game subsystem cannot load without it).
|
||||
func TestLoadRejectsMissingDictDir(t *testing.T) {
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
||||
t.Setenv("BACKEND_LOG_LEVEL", "info")
|
||||
t.Setenv("BACKEND_DICT_DIR", "")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: expected an error for a missing dictionary dir, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRejectsMalformedInt verifies that a non-numeric pool size is rejected.
|
||||
func TestLoadRejectsMalformedInt(t *testing.T) {
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
||||
t.Setenv("BACKEND_POSTGRES_MAX_OPEN_CONNS", "lots")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: expected an error for a malformed int, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRejectsMalformedDuration verifies that a malformed duration is rejected.
|
||||
func TestLoadRejectsMalformedDuration(t *testing.T) {
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
||||
t.Setenv("BACKEND_POSTGRES_OPERATION_TIMEOUT", "soon")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: expected an error for a malformed duration, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRejectsUnsupportedExporter verifies that an exporter outside the
|
||||
// supported set is rejected.
|
||||
func TestLoadRejectsUnsupportedExporter(t *testing.T) {
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
||||
t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "prometheus")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: expected an error for an unsupported exporter, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAcceptsOTLPExporter verifies that the otlp exporter is now accepted
|
||||
// (the collector is stood up with the deploy; the default stays none).
|
||||
func TestLoadAcceptsOTLPExporter(t *testing.T) {
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
||||
t.Setenv("BACKEND_DICT_DIR", "/dict")
|
||||
t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "otlp")
|
||||
t.Setenv("BACKEND_OTEL_METRICS_EXPORTER", "otlp")
|
||||
if _, err := Load(); err != nil {
|
||||
t.Fatalf("Load with otlp exporters: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadGuestReaperDefaultsAndOverride covers the guest-reaper knobs: defaults
|
||||
// when unset, an override, and rejection of a non-positive value.
|
||||
func TestLoadGuestReaperDefaultsAndOverride(t *testing.T) {
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
||||
t.Setenv("BACKEND_DICT_DIR", "/dict")
|
||||
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if c.GuestReapInterval != defaultGuestReapInterval {
|
||||
t.Errorf("GuestReapInterval = %s, want %s", c.GuestReapInterval, defaultGuestReapInterval)
|
||||
}
|
||||
if c.GuestRetention != defaultGuestRetention {
|
||||
t.Errorf("GuestRetention = %s, want %s", c.GuestRetention, defaultGuestRetention)
|
||||
}
|
||||
|
||||
t.Setenv("BACKEND_GUEST_RETENTION", "168h")
|
||||
if c, err = Load(); err != nil {
|
||||
t.Fatalf("Load (override): %v", err)
|
||||
} else if c.GuestRetention != 168*time.Hour {
|
||||
t.Errorf("GuestRetention = %s, want 168h", c.GuestRetention)
|
||||
}
|
||||
|
||||
t.Setenv("BACKEND_GUEST_REAP_INTERVAL", "0s")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: expected an error for a non-positive reap interval, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Package connector is the backend's gRPC client for operator broadcasts: a direct
|
||||
// message to one user, or a post to the game channel. It calls the gateway's
|
||||
// bot-link relay (which forwards the send to the remote bot over the reverse mTLS
|
||||
// link and reports back whether it was delivered). The relay lives on the trusted
|
||||
// internal network, so the connection uses insecure (plaintext) transport
|
||||
// credentials (docs/ARCHITECTURE.md §12). It speaks the Telegram service contract,
|
||||
// narrowed to the two broadcast methods the admin surface needs.
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
telegramv1 "scrabble/pkg/proto/telegram/v1"
|
||||
)
|
||||
|
||||
// Client wraps the connector's Telegram gRPC service.
|
||||
type Client struct {
|
||||
conn *grpc.ClientConn
|
||||
c telegramv1.TelegramClient
|
||||
}
|
||||
|
||||
// New dials the connector gRPC endpoint at addr.
|
||||
func New(addr string) (*Client, error) {
|
||||
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connector: dial %s: %w", addr, err)
|
||||
}
|
||||
return &Client{conn: conn, c: telegramv1.NewTelegramClient(conn)}, nil
|
||||
}
|
||||
|
||||
// Close releases the gRPC connection.
|
||||
func (c *Client) Close() error { return c.conn.Close() }
|
||||
|
||||
// SendToUser sends an operator text message to one user, addressed by their
|
||||
// platform external_id, through the bot. delivered reports whether the connector
|
||||
// actually sent it (false when the user has not started the bot).
|
||||
func (c *Client) SendToUser(ctx context.Context, externalID, text string) (bool, error) {
|
||||
resp, err := c.c.SendToUser(ctx, &telegramv1.SendToUserRequest{ExternalId: externalID, Text: text})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return resp.GetDelivered(), nil
|
||||
}
|
||||
|
||||
// SendToGameChannel posts an operator text message to the bot's game channel.
|
||||
// delivered reports whether the connector sent it (false when the bot has no
|
||||
// channel configured).
|
||||
func (c *Client) SendToGameChannel(ctx context.Context, text string) (bool, error) {
|
||||
resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return resp.GetDelivered(), nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// dictionary.
|
||||
type AlphabetEntry struct {
|
||||
// Index is the alphabet-index byte the wire uses for this letter (0..Size-1).
|
||||
Index byte
|
||||
// Letter is the concrete character, in the case the solver ruleset emits (lower).
|
||||
Letter string
|
||||
// Value is the tile's point score.
|
||||
Value int
|
||||
}
|
||||
|
||||
// BlankIndex is the wire sentinel for a blank tile inside an alphabet-index sequence (a
|
||||
// rack or an exchange list). It is out of range of every offered variant's alphabet (the
|
||||
// largest has 33 letters), so it never collides with a real letter index. A placed blank
|
||||
// instead travels as an ordinary tile carrying its designated letter's index alongside a
|
||||
// separate blank flag. The constant is untyped so it serves both byte (FlatBuffers ubyte)
|
||||
// and int (the gateway/backend JSON edge) call sites.
|
||||
const BlankIndex = 0xFF
|
||||
|
||||
// variantCodec is the cached per-variant alphabet data backing the wire helpers: the
|
||||
// ordered display table and a case-insensitive letter→index lookup. Both are derived once
|
||||
// from the solver ruleset (see variantCodecs).
|
||||
type variantCodec struct {
|
||||
table []AlphabetEntry
|
||||
letterToIndex map[string]byte
|
||||
}
|
||||
|
||||
// variantCodecs holds one codec per offered variant, built once at package load from each
|
||||
// ruleset's alphabet and value table. The rulesets are needed only here (not per request),
|
||||
// so the hot path never rebuilds them.
|
||||
var variantCodecs = buildVariantCodecs()
|
||||
|
||||
func buildVariantCodecs() map[Variant]*variantCodec {
|
||||
m := make(map[Variant]*variantCodec, len(Variants()))
|
||||
for _, v := range Variants() {
|
||||
rs, ok := v.ruleset()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
size := rs.Alphabet.Size()
|
||||
table := make([]AlphabetEntry, size)
|
||||
lut := make(map[string]byte, size)
|
||||
for i := range size {
|
||||
ch, err := rs.Alphabet.Character(byte(i))
|
||||
if err != nil {
|
||||
// An offered variant's alphabet never yields a bad index; skip defensively.
|
||||
continue
|
||||
}
|
||||
table[i] = AlphabetEntry{Index: byte(i), Letter: ch, Value: rs.Values[i]}
|
||||
lut[strings.ToLower(ch)] = byte(i)
|
||||
}
|
||||
m[v] = &variantCodec{table: table, letterToIndex: lut}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// AlphabetTable returns a copy of variant's full alphabet as an ordered (index, letter,
|
||||
// value) table, or ErrUnknownVariant. Entry i has Index i, so the slice doubles as an
|
||||
// index→(letter, value) lookup. It needs no dictionary — the data comes from the solver
|
||||
// ruleset alone — so it is safe to build for any offered variant and is the same table the
|
||||
// client caches for display while live play exchanges bare indices.
|
||||
func AlphabetTable(v Variant) ([]AlphabetEntry, error) {
|
||||
c, ok := variantCodecs[v]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
|
||||
}
|
||||
out := make([]AlphabetEntry, len(c.table))
|
||||
copy(out, c.table)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LetterForIndex maps one alphabet index to its concrete letter for variant. It is the
|
||||
// wire-decode primitive for a placed tile (a blank carries its designated letter's index).
|
||||
// An out-of-range index is an illegal play.
|
||||
func LetterForIndex(v Variant, idx int) (string, error) {
|
||||
c, ok := variantCodecs[v]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%w: %d", ErrUnknownVariant, v)
|
||||
}
|
||||
if idx < 0 || idx >= len(c.table) {
|
||||
return "", fmt.Errorf("%w: alphabet index %d for %s", ErrIllegalPlay, idx, v)
|
||||
}
|
||||
return c.table[idx].Letter, nil
|
||||
}
|
||||
|
||||
// EncodeRack maps a decoded rack (the Game.Hand form: concrete letters with "?" for an
|
||||
// undesignated blank) to wire alphabet indices, using BlankIndex for each blank. It backs
|
||||
// the per-player state view, whose rack the client renders via the cached table.
|
||||
func EncodeRack(v Variant, letters []string) ([]int, error) {
|
||||
c, ok := variantCodecs[v]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
|
||||
}
|
||||
out := make([]int, len(letters))
|
||||
for i, l := range letters {
|
||||
if l == blankLetter {
|
||||
out[i] = BlankIndex
|
||||
continue
|
||||
}
|
||||
idx, ok := c.letterToIndex[strings.ToLower(l)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: rack letter %q for %s", ErrTilesNotOnRack, l, v)
|
||||
}
|
||||
out[i] = int(idx)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DecodeTiles maps a wire rack/exchange index list back to the decoded letter form ("?"
|
||||
// for a blank, BlankIndex), for handing to the existing letter-based exchange path.
|
||||
func DecodeTiles(v Variant, idx []int) ([]string, error) {
|
||||
out := make([]string, len(idx))
|
||||
for i, x := range idx {
|
||||
if x == BlankIndex {
|
||||
out[i] = blankLetter
|
||||
continue
|
||||
}
|
||||
l, err := LetterForIndex(v, x)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w (exchange)", err)
|
||||
}
|
||||
out[i] = l
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DecodeWord maps a sequence of alphabet indices to a concrete word (word-check carries no
|
||||
// blanks). The client constrains input to the variant's alphabet, so every index is a real
|
||||
// letter.
|
||||
func DecodeWord(v Variant, idx []int) (string, error) {
|
||||
var sb strings.Builder
|
||||
for _, x := range idx {
|
||||
l, err := LetterForIndex(v, x)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w (word check)", err)
|
||||
}
|
||||
sb.WriteString(l)
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAlphabetTableEnglish pins the English table against the solver ruleset: 26 letters,
|
||||
// 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.
|
||||
func TestAlphabetTableEnglish(t *testing.T) {
|
||||
tab, err := AlphabetTable(VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("AlphabetTable(scrabble_en): %v", err)
|
||||
}
|
||||
if len(tab) != 26 {
|
||||
t.Fatalf("size = %d, want 26", len(tab))
|
||||
}
|
||||
for i, e := range tab {
|
||||
if int(e.Index) != i {
|
||||
t.Errorf("entry %d has Index %d, want %d (index must equal position)", i, e.Index, i)
|
||||
}
|
||||
}
|
||||
// a=index0/value1, q=index16/value10, z=index25/value10.
|
||||
if tab[0].Letter != "a" || tab[0].Value != 1 {
|
||||
t.Errorf("entry 0 = %q/%d, want a/1", tab[0].Letter, tab[0].Value)
|
||||
}
|
||||
if tab[16].Letter != "q" || tab[16].Value != 10 {
|
||||
t.Errorf("entry 16 = %q/%d, want q/10", tab[16].Letter, tab[16].Value)
|
||||
}
|
||||
if tab[25].Letter != "z" || tab[25].Value != 10 {
|
||||
t.Errorf("entry 25 = %q/%d, want z/10", tab[25].Letter, tab[25].Value)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAlphabetTableRussianVariants pins both Russian variants: they share the 33-letter
|
||||
// alphabet but differ in tile values — most visibly ё (index 6), worth 3 in Russian
|
||||
// Scrabble and 0 in Эрудит.
|
||||
func TestAlphabetTableRussianVariants(t *testing.T) {
|
||||
ru, err := AlphabetTable(VariantRussianScrabble)
|
||||
if err != nil {
|
||||
t.Fatalf("AlphabetTable(scrabble_ru): %v", err)
|
||||
}
|
||||
er, err := AlphabetTable(VariantErudit)
|
||||
if err != nil {
|
||||
t.Fatalf("AlphabetTable(erudit_ru): %v", err)
|
||||
}
|
||||
if len(ru) != 33 || len(er) != 33 {
|
||||
t.Fatalf("sizes = %d/%d, want 33/33", len(ru), len(er))
|
||||
}
|
||||
if ru[0].Letter != "а" || ru[0].Value != 1 {
|
||||
t.Errorf("scrabble_ru entry 0 = %q/%d, want а/1", ru[0].Letter, ru[0].Value)
|
||||
}
|
||||
if ru[6].Letter != "ё" || ru[6].Value != 3 {
|
||||
t.Errorf("scrabble_ru ё (entry 6) = %q/%d, want ё/3", ru[6].Letter, ru[6].Value)
|
||||
}
|
||||
if er[6].Letter != "ё" || er[6].Value != 0 {
|
||||
t.Errorf("erudit_ru ё (entry 6) = %q/%d, want ё/0", er[6].Letter, er[6].Value)
|
||||
}
|
||||
if ru[32].Letter != "я" || er[32].Letter != "я" {
|
||||
t.Errorf("last letter = %q/%q, want я/я", ru[32].Letter, er[32].Letter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAlphabetTableUnknownVariant rejects a variant outside the catalogue.
|
||||
func TestAlphabetTableUnknownVariant(t *testing.T) {
|
||||
if _, err := AlphabetTable(Variant(99)); !errors.Is(err, ErrUnknownVariant) {
|
||||
t.Fatalf("got %v, want ErrUnknownVariant", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRackCodecRoundTrip pins the rack/exchange index codec the edge uses: EncodeRack maps
|
||||
// concrete letters (with "?" for a blank) to indices (BlankIndex for the blank) and
|
||||
// DecodeTiles inverts it. EncodeRack is case-insensitive so it accepts the lower-case
|
||||
// Hand form and an upper-case letter alike.
|
||||
func TestRackCodecRoundTrip(t *testing.T) {
|
||||
letters := []string{"c", "a", "t", "?"}
|
||||
idx, err := EncodeRack(VariantEnglish, letters)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeRack: %v", err)
|
||||
}
|
||||
if want := []int{2, 0, 19, BlankIndex}; !slices.Equal(idx, want) {
|
||||
t.Fatalf("EncodeRack = %v, want %v", idx, want)
|
||||
}
|
||||
back, err := DecodeTiles(VariantEnglish, idx)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeTiles: %v", err)
|
||||
}
|
||||
if !slices.Equal(back, letters) {
|
||||
t.Fatalf("DecodeTiles = %v, want %v", back, letters)
|
||||
}
|
||||
if up, err := EncodeRack(VariantEnglish, []string{"C"}); err != nil || !slices.Equal(up, []int{2}) {
|
||||
t.Errorf("EncodeRack upper-case = %v,%v; want [2],nil", up, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeWordAndBounds covers the word-check decode and the out-of-range guard.
|
||||
func TestDecodeWordAndBounds(t *testing.T) {
|
||||
w, err := DecodeWord(VariantEnglish, []int{2, 0, 19})
|
||||
if err != nil || w != "cat" {
|
||||
t.Fatalf("DecodeWord = %q,%v; want cat,nil", w, err)
|
||||
}
|
||||
if _, err := LetterForIndex(VariantEnglish, 26); !errors.Is(err, ErrIllegalPlay) {
|
||||
t.Errorf("out-of-range index: got %v, want ErrIllegalPlay", err)
|
||||
}
|
||||
if _, err := DecodeWord(VariantEnglish, []int{BlankIndex}); !errors.Is(err, ErrIllegalPlay) {
|
||||
t.Errorf("blank in word: got %v, want ErrIllegalPlay", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||
)
|
||||
|
||||
// blankTile marks a blank tile in a hand or in the bag, matching the
|
||||
// scrabble-solver convention (selfplay) so a hand of these bytes interoperates
|
||||
// with the solver's rack helpers.
|
||||
const blankTile byte = 0xff
|
||||
|
||||
// Bag is the shuffled draw pile for one game. Unlike the solver's self-play bag
|
||||
// it supports returning tiles, which an exchange needs. It is seeded once, so a
|
||||
// game's draws are reproducible from its seed and the sequence of operations.
|
||||
// Bag is not safe for concurrent use; the owning Game serialises access.
|
||||
type Bag struct {
|
||||
tiles []byte
|
||||
rng *rand.Rand
|
||||
}
|
||||
|
||||
// NewBag fills a bag from the ruleset's tile counts and blanks and shuffles it
|
||||
// with seed. Letters are stored as alphabet-index bytes and blanks as blankTile.
|
||||
func NewBag(rs *rules.Ruleset, seed int64) *Bag {
|
||||
var tiles []byte
|
||||
for i, n := range rs.Counts {
|
||||
for range n {
|
||||
tiles = append(tiles, byte(i))
|
||||
}
|
||||
}
|
||||
for range rs.Blanks {
|
||||
tiles = append(tiles, blankTile)
|
||||
}
|
||||
b := &Bag{tiles: tiles, rng: rand.New(rand.NewSource(seed))}
|
||||
b.shuffle()
|
||||
return b
|
||||
}
|
||||
|
||||
// Len returns the number of tiles left in the bag.
|
||||
func (b *Bag) Len() int { return len(b.tiles) }
|
||||
|
||||
// Draw removes up to n tiles from the bag and returns them in a fresh slice.
|
||||
// Drawing more than remain returns all of them; drawing from an empty bag
|
||||
// returns an empty slice.
|
||||
func (b *Bag) Draw(n int) []byte {
|
||||
if n > len(b.tiles) {
|
||||
n = len(b.tiles)
|
||||
}
|
||||
out := make([]byte, n)
|
||||
copy(out, b.tiles[len(b.tiles)-n:])
|
||||
b.tiles = b.tiles[:len(b.tiles)-n]
|
||||
return out
|
||||
}
|
||||
|
||||
// Return puts tiles back into the bag and reshuffles, as when a player exchanges
|
||||
// tiles. The tiles must use the same encoding as Draw (alphabet indices and
|
||||
// blankTile).
|
||||
func (b *Bag) Return(tiles []byte) {
|
||||
b.tiles = append(b.tiles, tiles...)
|
||||
b.shuffle()
|
||||
}
|
||||
|
||||
// shuffle randomises the remaining tiles with the bag's own RNG, keeping draws
|
||||
// deterministic for a given seed and sequence of operations.
|
||||
func (b *Bag) shuffle() {
|
||||
b.rng.Shuffle(len(b.tiles), func(i, j int) { b.tiles[i], b.tiles[j] = b.tiles[j], b.tiles[i] })
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||
)
|
||||
|
||||
// allTiles returns the full multiset of tiles a bag is filled from, in ruleset
|
||||
// order (letters then blanks).
|
||||
func allTiles(rs *rules.Ruleset) []byte {
|
||||
var ts []byte
|
||||
for i, n := range rs.Counts {
|
||||
for range n {
|
||||
ts = append(ts, byte(i))
|
||||
}
|
||||
}
|
||||
for range rs.Blanks {
|
||||
ts = append(ts, blankTile)
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
// TestBagDeterministic checks that two bags with the same seed draw identically.
|
||||
func TestBagDeterministic(t *testing.T) {
|
||||
rs := rules.English()
|
||||
a, b := NewBag(rs, 42), NewBag(rs, 42)
|
||||
if a.Len() != b.Len() {
|
||||
t.Fatalf("len mismatch: %d vs %d", a.Len(), b.Len())
|
||||
}
|
||||
for a.Len() > 0 {
|
||||
if da, db := a.Draw(3), b.Draw(3); !slices.Equal(da, db) {
|
||||
t.Fatalf("same seed drew differently: %v vs %v", da, db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBagReturnConservesMultiset checks Len accounting and that Return puts the
|
||||
// exact tiles back, leaving the bag's multiset unchanged.
|
||||
func TestBagReturnConservesMultiset(t *testing.T) {
|
||||
rs := rules.English()
|
||||
want := tileCounts(allTiles(rs))
|
||||
total := len(allTiles(rs))
|
||||
|
||||
b := NewBag(rs, 7)
|
||||
if b.Len() != total {
|
||||
t.Fatalf("new bag len = %d, want %d", b.Len(), total)
|
||||
}
|
||||
drawn := b.Draw(rs.RackSize)
|
||||
if b.Len() != total-rs.RackSize {
|
||||
t.Fatalf("after draw len = %d, want %d", b.Len(), total-rs.RackSize)
|
||||
}
|
||||
b.Return(drawn)
|
||||
if b.Len() != total {
|
||||
t.Fatalf("after return len = %d, want %d", b.Len(), total)
|
||||
}
|
||||
if got := tileCounts(b.Draw(b.Len())); !maps.Equal(got, want) {
|
||||
t.Fatalf("multiset changed across draw/return")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBagDrawAll returns everything once the bag is exhausted and never panics.
|
||||
func TestBagDrawAll(t *testing.T) {
|
||||
rs := rules.English()
|
||||
b := NewBag(rs, 1)
|
||||
all := b.Draw(b.Len() + 10) // asking for more than present returns all
|
||||
if len(all) != len(allTiles(rs)) {
|
||||
t.Fatalf("drew %d, want %d", len(all), len(allTiles(rs)))
|
||||
}
|
||||
if b.Len() != 0 {
|
||||
t.Fatalf("bag len = %d, want 0", b.Len())
|
||||
}
|
||||
if got := b.Draw(1); len(got) != 0 {
|
||||
t.Fatalf("draw from empty bag returned %d tiles", len(got))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// ActionKind classifies a turn in the move log.
|
||||
type ActionKind uint8
|
||||
|
||||
const (
|
||||
// ActionPlay is a tile placement forming one or more words.
|
||||
ActionPlay ActionKind = iota
|
||||
// ActionPass is a forfeited turn.
|
||||
ActionPass
|
||||
// ActionExchange swaps tiles with the bag.
|
||||
ActionExchange
|
||||
// ActionResign abandons the game.
|
||||
ActionResign
|
||||
// ActionTimeout is the auto-resignation a missed turn becomes; recorded by
|
||||
// the game domain, never produced by the engine itself.
|
||||
ActionTimeout
|
||||
)
|
||||
|
||||
// String renders the action kind for logs and GCG export.
|
||||
func (a ActionKind) String() string {
|
||||
switch a {
|
||||
case ActionPlay:
|
||||
return "play"
|
||||
case ActionPass:
|
||||
return "pass"
|
||||
case ActionExchange:
|
||||
return "exchange"
|
||||
case ActionResign:
|
||||
return "resign"
|
||||
case ActionTimeout:
|
||||
return "timeout"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// TileRecord is a single placed tile decoded to a concrete letter, so the move
|
||||
// log is independent of any dictionary and of the solver's internal encoding.
|
||||
type TileRecord struct {
|
||||
Row, Col int
|
||||
// Letter is the concrete character placed; for a blank, the letter it stands
|
||||
// for.
|
||||
Letter string
|
||||
// Blank reports whether the tile was placed from a blank (and so scored 0).
|
||||
Blank bool
|
||||
}
|
||||
|
||||
// MoveRecord is one turn in the dictionary-independent history. A play carries
|
||||
// the placed tiles, the words it formed and its score; a pass or resignation
|
||||
// carries only the action; an exchange carries the number of tiles swapped. The
|
||||
// game domain adds timestamps and persistence around these values.
|
||||
type MoveRecord struct {
|
||||
Player int
|
||||
Action ActionKind
|
||||
Dir Direction // ActionPlay only: orientation of the main word (H/V)
|
||||
MainRow, MainCol int // ActionPlay only: the main word's first-letter coordinate
|
||||
Tiles []TileRecord // ActionPlay only
|
||||
Words []string // ActionPlay only: the main word first, then cross words
|
||||
Count int // ActionExchange only: number of tiles swapped
|
||||
Score int // points scored this turn (0 for non-plays)
|
||||
Total int // the player's running total after this turn
|
||||
}
|
||||
|
||||
// recordPlay decodes a scored, committed move into a dictionary-independent
|
||||
// MoveRecord for the given player, stamping the player and their running total.
|
||||
func (g *Game) recordPlay(player int, m scrabble.Move) MoveRecord {
|
||||
rec := g.decodeMove(m)
|
||||
rec.Player = player
|
||||
rec.Total = g.scores[player]
|
||||
return rec
|
||||
}
|
||||
|
||||
// decodeMove decodes a scored move's placements and words into a
|
||||
// dictionary-independent MoveRecord, without the player or running total (which
|
||||
// only a committed play has). It backs both recordPlay and the non-committing
|
||||
// previews HintView and EvaluatePlay.
|
||||
func (g *Game) decodeMove(m scrabble.Move) MoveRecord {
|
||||
tiles := make([]TileRecord, len(m.Tiles))
|
||||
for i, p := range m.Tiles {
|
||||
tiles[i] = TileRecord{Row: p.Row, Col: p.Col, Letter: g.letter(p.Letter), Blank: p.Blank}
|
||||
}
|
||||
words := make([]string, 0, 1+len(m.Cross))
|
||||
words = append(words, g.word(m.Main))
|
||||
for _, cw := range m.Cross {
|
||||
words = append(words, g.word(cw))
|
||||
}
|
||||
return MoveRecord{
|
||||
Action: ActionPlay,
|
||||
Dir: fromScrabbleDir(m.Dir),
|
||||
MainRow: m.Main.Row,
|
||||
MainCol: m.Main.Col,
|
||||
Tiles: tiles,
|
||||
Words: words,
|
||||
Score: m.Score,
|
||||
}
|
||||
}
|
||||
|
||||
// letter decodes one alphabet index to its concrete character via the ruleset's
|
||||
// alphabet. A malformed index yields the empty string.
|
||||
func (g *Game) letter(idx byte) string {
|
||||
s, err := g.rules.Alphabet.Character(idx)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// word decodes a solver word's letters to a concrete string via the ruleset's
|
||||
// alphabet. A malformed word yields the empty string.
|
||||
func (g *Game) word(w scrabble.Word) string {
|
||||
s, err := g.rules.Alphabet.Decode(w.Letters)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ReplayBoard reconstructs the board the play records produce, on an empty board
|
||||
// for ruleset rs, using only the alphabet and never a dictionary: each recorded
|
||||
// letter is re-indexed and the placements are applied. Non-play records are
|
||||
// ignored. It realises the history invariant in docs/ARCHITECTURE.md §9.1 — an
|
||||
// archived game replays from decoded values plus its variant metadata alone.
|
||||
func ReplayBoard(rs *rules.Ruleset, records []MoveRecord) (*board.Board, error) {
|
||||
b := board.New(rs.Rows, rs.Cols)
|
||||
for _, rec := range records {
|
||||
if rec.Action != ActionPlay {
|
||||
continue
|
||||
}
|
||||
placements := make([]scrabble.Placement, len(rec.Tiles))
|
||||
for i, t := range rec.Tiles {
|
||||
idx, err := rs.Alphabet.Index(t.Letter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("engine: replay letter %q at (%d,%d): %w", t.Letter, t.Row, t.Col, err)
|
||||
}
|
||||
placements[i] = scrabble.Placement{Row: t.Row, Col: t.Col, Letter: idx, Blank: t.Blank}
|
||||
}
|
||||
scrabble.Apply(b, scrabble.Move{Tiles: placements})
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// blankCellFlag is the bit board cells set for a blank tile (board.go encoding).
|
||||
const blankCellFlag byte = 0x80
|
||||
|
||||
// TestDecodeBlankPlayAndReplay places "cat" with the C drawn from a blank, then
|
||||
// checks the decoded record keeps the concrete letter and the blank flag, and
|
||||
// that ReplayBoard — using only the ruleset, no dictionary — reproduces the
|
||||
// blank on the board.
|
||||
func TestDecodeBlankPlayAndReplay(t *testing.T) {
|
||||
g := newEnglishGame(t, 1)
|
||||
rs := g.rules
|
||||
row, col := centre(rs)
|
||||
|
||||
idx := func(s string) byte {
|
||||
t.Helper()
|
||||
i, err := rs.Alphabet.Index(s)
|
||||
if err != nil {
|
||||
t.Fatalf("index %q: %v", s, err)
|
||||
}
|
||||
return i
|
||||
}
|
||||
ps := []scrabble.Placement{
|
||||
{Row: row, Col: col, Letter: idx("c"), Blank: true},
|
||||
{Row: row, Col: col + 1, Letter: idx("a")},
|
||||
{Row: row, Col: col + 2, Letter: idx("t")},
|
||||
}
|
||||
|
||||
move, err := g.solver.ValidatePlay(g.board, scrabble.Horizontal, ps)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
rec := g.recordPlay(0, move)
|
||||
|
||||
if rec.Action != ActionPlay || len(rec.Tiles) != 3 {
|
||||
t.Fatalf("record = %+v, want a 3-tile play", rec)
|
||||
}
|
||||
if blank := rec.Tiles[0]; blank.Letter != "c" || !blank.Blank {
|
||||
t.Errorf("blank tile = %+v, want letter \"c\" with Blank=true", blank)
|
||||
}
|
||||
if rec.Tiles[1].Blank || rec.Tiles[1].Letter != "a" {
|
||||
t.Errorf("second tile = %+v, want plain \"a\"", rec.Tiles[1])
|
||||
}
|
||||
if len(rec.Words) == 0 || rec.Words[0] != "cat" {
|
||||
t.Errorf("words = %v, want main word \"cat\"", rec.Words)
|
||||
}
|
||||
|
||||
rs2, err := Ruleset(VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("ruleset: %v", err)
|
||||
}
|
||||
b, err := ReplayBoard(rs2, []MoveRecord{rec})
|
||||
if err != nil {
|
||||
t.Fatalf("replay: %v", err)
|
||||
}
|
||||
if b.At(row, col)&blankCellFlag == 0 {
|
||||
t.Error("replayed centre cell lost its blank flag")
|
||||
}
|
||||
if !b.Filled(row, col+1) || b.At(row, col+1)&blankCellFlag != 0 {
|
||||
t.Error("replayed \"a\" cell should be a filled, non-blank tile")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// blankLetter is how a blank tile is written in the decoded, domain-facing API:
|
||||
// in a rack it is an undesignated blank, and in an exchange it is a blank being
|
||||
// swapped. A blank placed on the board is a TileRecord with Blank set and Letter
|
||||
// holding the concrete letter it stands for.
|
||||
const blankLetter = "?"
|
||||
|
||||
// Direction is the orientation of a play as seen by the game domain. It decouples
|
||||
// the domain from the solver's own Direction type: internal/engine is the only
|
||||
// backend package that imports scrabble-solver (see docs/ARCHITECTURE.md §5), so
|
||||
// the engine accepts and returns decoded, solver-free values.
|
||||
type Direction uint8
|
||||
|
||||
const (
|
||||
// Horizontal lays a word left to right along a row.
|
||||
Horizontal Direction = iota
|
||||
// Vertical lays a word top to bottom down a column.
|
||||
Vertical
|
||||
)
|
||||
|
||||
// String renders the direction as "H" or "V", the form the move journal and GCG
|
||||
// export use.
|
||||
func (d Direction) String() string {
|
||||
if d == Vertical {
|
||||
return "V"
|
||||
}
|
||||
return "H"
|
||||
}
|
||||
|
||||
// scrabbleDir maps the domain Direction to the solver's Direction.
|
||||
func (d Direction) scrabbleDir() scrabble.Direction {
|
||||
if d == Vertical {
|
||||
return scrabble.Vertical
|
||||
}
|
||||
return scrabble.Horizontal
|
||||
}
|
||||
|
||||
// fromScrabbleDir maps the solver's Direction to the domain Direction.
|
||||
func fromScrabbleDir(d scrabble.Direction) Direction {
|
||||
if d == scrabble.Vertical {
|
||||
return Vertical
|
||||
}
|
||||
return Horizontal
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// (ErrTilesNotOnRack, ErrIllegalPlay, ErrGameOver) plus ErrIllegalPlay when a
|
||||
// letter is outside the variant's alphabet.
|
||||
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)
|
||||
if err != nil {
|
||||
return MoveRecord{}, err
|
||||
}
|
||||
return g.Play(dir.scrabbleDir(), placements)
|
||||
}
|
||||
|
||||
// SubmitExchange swaps the current player's tiles, named in decoded terms: a
|
||||
// concrete letter per tile, or "?" for a blank. It encodes them and delegates to
|
||||
// Exchange, returning the same errors plus ErrTilesNotOnRack when a letter is
|
||||
// outside the variant's alphabet.
|
||||
func (g *Game) SubmitExchange(tiles []string) (MoveRecord, error) {
|
||||
raw, err := g.encodeTiles(tiles)
|
||||
if err != nil {
|
||||
return MoveRecord{}, err
|
||||
}
|
||||
return g.Exchange(raw)
|
||||
}
|
||||
|
||||
// EvaluatePlay scores and validates a tentative play without committing it,
|
||||
// backing the unlimited "what would my next move score, and is it legal?" tool.
|
||||
// It infers the play's orientation from the tiles and the board and applies the
|
||||
// game's play options exactly as SubmitPlay does, so under the single-word rule
|
||||
// perpendicular cross-words are ignored: the preview's legality and score then
|
||||
// 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 {
|
||||
return MoveRecord{}, ErrGameOver
|
||||
}
|
||||
placements, err := g.placements(tiles)
|
||||
if err != nil {
|
||||
return MoveRecord{}, err
|
||||
}
|
||||
move, err := g.solver.ValidatePlayOpts(g.board, g.playDirection(placements), placements, g.playOpts())
|
||||
if err != nil {
|
||||
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
||||
}
|
||||
return g.decodeMove(move), nil
|
||||
}
|
||||
|
||||
// HintView returns the highest-scoring legal play for the current player as a
|
||||
// decoded MoveRecord and true, or a zero record and false when there is none. It
|
||||
// is the one-per-game hint's top-1 move in domain-facing form.
|
||||
func (g *Game) HintView() (MoveRecord, bool) {
|
||||
move, ok := g.Hint()
|
||||
if !ok {
|
||||
return MoveRecord{}, false
|
||||
}
|
||||
return g.decodeMove(move), true
|
||||
}
|
||||
|
||||
// Candidates returns every legal play for the current player as decoded
|
||||
// MoveRecords, ranked by descending score (so the first entry equals HintView's
|
||||
// move). It is empty when the player has no legal play. The robot opponent picks
|
||||
// from these by margin without importing the solver; each record carries the
|
||||
// move's score, so a caller can choose by resulting score difference rather than
|
||||
// always taking the maximum.
|
||||
func (g *Game) Candidates() []MoveRecord {
|
||||
moves := g.GenerateMoves()
|
||||
out := make([]MoveRecord, len(moves))
|
||||
for i, m := range moves {
|
||||
out[i] = g.decodeMove(m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Hand returns the player's current rack decoded to concrete letters, with "?"
|
||||
// for each undesignated blank. The order mirrors the internal hand. It supplies
|
||||
// the GCG rack field and the per-player game-state view.
|
||||
func (g *Game) Hand(player int) []string {
|
||||
hand := g.hands[player]
|
||||
out := make([]string, len(hand))
|
||||
for i, t := range hand {
|
||||
if t == blankTile {
|
||||
out[i] = blankLetter
|
||||
continue
|
||||
}
|
||||
out[i] = g.letter(t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// placements encodes decoded tiles into solver placements via the ruleset
|
||||
// alphabet, wrapping a bad letter as ErrIllegalPlay.
|
||||
func (g *Game) placements(tiles []TileRecord) ([]scrabble.Placement, error) {
|
||||
out := make([]scrabble.Placement, len(tiles))
|
||||
for i, t := range tiles {
|
||||
idx, err := g.rules.Alphabet.Index(t.Letter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: letter %q at (%d,%d): %v", ErrIllegalPlay, t.Letter, t.Row, t.Col, err)
|
||||
}
|
||||
out[i] = scrabble.Placement{Row: t.Row, Col: t.Col, Letter: idx, Blank: t.Blank}
|
||||
}
|
||||
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
|
||||
// concrete letter) into the internal byte form, wrapping a bad letter as
|
||||
// ErrTilesNotOnRack (the caller cannot hold a tile it cannot name).
|
||||
func (g *Game) encodeTiles(tiles []string) ([]byte, error) {
|
||||
raw := make([]byte, len(tiles))
|
||||
for i, t := range tiles {
|
||||
if t == blankLetter {
|
||||
raw[i] = blankTile
|
||||
continue
|
||||
}
|
||||
idx, err := g.rules.Alphabet.Index(t)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: tile %q: %v", ErrTilesNotOnRack, t, err)
|
||||
}
|
||||
raw[i] = idx
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDirectionString covers the H/V rendering used by the journal and GCG.
|
||||
func TestDirectionString(t *testing.T) {
|
||||
if Horizontal.String() != "H" {
|
||||
t.Errorf("Horizontal = %q, want H", Horizontal.String())
|
||||
}
|
||||
if Vertical.String() != "V" {
|
||||
t.Errorf("Vertical = %q, want V", Vertical.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubmitPlayMatchesHint plays the decoded top-1 move through SubmitPlay and
|
||||
// checks it scores and advances exactly like the underlying solver move, proving
|
||||
// the decode→encode round trip.
|
||||
func TestSubmitPlayMatchesHint(t *testing.T) {
|
||||
g := openingGame(t)
|
||||
hint, ok := g.HintView()
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
rec, err := g.SubmitPlay(hint.Tiles)
|
||||
if err != nil {
|
||||
t.Fatalf("submit play: %v", err)
|
||||
}
|
||||
if rec.Score != hint.Score {
|
||||
t.Errorf("played score = %d, want hint score %d", rec.Score, hint.Score)
|
||||
}
|
||||
if rec.Action != ActionPlay {
|
||||
t.Errorf("action = %v, want play", rec.Action)
|
||||
}
|
||||
if g.Score(0) != hint.Score {
|
||||
t.Errorf("player 0 score = %d, want %d", g.Score(0), hint.Score)
|
||||
}
|
||||
if g.ToMove() != 1 {
|
||||
t.Errorf("to move = %d, want 1 after a play", g.ToMove())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCandidatesRankedAndMatchesHint checks that Candidates decodes every
|
||||
// generated move, ranks them by descending score, and leads with the same move
|
||||
// HintView reveals.
|
||||
func TestCandidatesRankedAndMatchesHint(t *testing.T) {
|
||||
g := openingGame(t)
|
||||
cands := g.Candidates()
|
||||
if len(cands) == 0 {
|
||||
t.Fatal("opening game has no candidates")
|
||||
}
|
||||
if got, want := len(cands), len(g.GenerateMoves()); got != want {
|
||||
t.Errorf("candidate count = %d, want %d (one per generated move)", got, want)
|
||||
}
|
||||
for i := 1; i < len(cands); i++ {
|
||||
if cands[i-1].Score < cands[i].Score {
|
||||
t.Errorf("candidates not ranked: [%d].Score=%d < [%d].Score=%d", i-1, cands[i-1].Score, i, cands[i].Score)
|
||||
}
|
||||
}
|
||||
hint, ok := g.HintView()
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
if cands[0].Score != hint.Score {
|
||||
t.Errorf("top candidate score = %d, want hint score %d", cands[0].Score, hint.Score)
|
||||
}
|
||||
for _, c := range cands {
|
||||
if c.Action != ActionPlay {
|
||||
t.Errorf("candidate action = %v, want play", c.Action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluatePlayDoesNotCommit checks the preview scores like a real play but
|
||||
// leaves the board, scores, turn and bag untouched.
|
||||
func TestEvaluatePlayDoesNotCommit(t *testing.T) {
|
||||
g := openingGame(t)
|
||||
hint, ok := g.HintView()
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
boardBefore := g.BoardClone()
|
||||
scoreBefore, toMoveBefore, bagBefore := g.Score(0), g.ToMove(), g.BagLen()
|
||||
|
||||
rec, err := g.EvaluatePlay(hint.Tiles)
|
||||
if err != nil {
|
||||
t.Fatalf("evaluate play: %v", err)
|
||||
}
|
||||
if rec.Score != hint.Score {
|
||||
t.Errorf("evaluated score = %d, want %d", rec.Score, hint.Score)
|
||||
}
|
||||
if !boardsEqual(boardBefore, g.BoardClone()) {
|
||||
t.Error("evaluate must not mutate the board")
|
||||
}
|
||||
if g.Score(0) != scoreBefore || g.ToMove() != toMoveBefore || g.BagLen() != bagBefore {
|
||||
t.Errorf("evaluate mutated state: score %d->%d, toMove %d->%d, bag %d->%d",
|
||||
scoreBefore, g.Score(0), toMoveBefore, g.ToMove(), bagBefore, g.BagLen())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluatePlayRejectsIllegal reports ErrIllegalPlay for a play the solver
|
||||
// rejects (a single off-centre opening tile) without committing.
|
||||
func TestEvaluatePlayRejectsIllegal(t *testing.T) {
|
||||
g := newEnglishGame(t, 1)
|
||||
letter := g.Hand(0)[0]
|
||||
_, err := g.EvaluatePlay([]TileRecord{{Row: 0, Col: 0, Letter: letter}})
|
||||
if !errors.Is(err, ErrIllegalPlay) {
|
||||
t.Errorf("evaluate off-centre opening = %v, want ErrIllegalPlay", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubmitExchangeWithBlank exchanges a full rack that includes a blank,
|
||||
// exercising the "?" encoding path, and checks the turn advances.
|
||||
func TestSubmitExchangeWithBlank(t *testing.T) {
|
||||
g := gameWithBlankInHand(t)
|
||||
hand := g.Hand(0)
|
||||
if !slices.Contains(hand, blankLetter) {
|
||||
t.Fatalf("hand %v has no blank", hand)
|
||||
}
|
||||
rec, err := g.SubmitExchange(hand)
|
||||
if err != nil {
|
||||
t.Fatalf("submit exchange: %v", err)
|
||||
}
|
||||
if rec.Action != ActionExchange || rec.Count != len(hand) {
|
||||
t.Errorf("exchange record = %+v, want action exchange count %d", rec, len(hand))
|
||||
}
|
||||
if g.ToMove() != 1 {
|
||||
t.Errorf("to move = %d, want 1 after an exchange", g.ToMove())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandDecodesBlank checks Hand returns concrete letters and "?" for a blank,
|
||||
// agreeing with the internal hand.
|
||||
func TestHandDecodesBlank(t *testing.T) {
|
||||
g := gameWithBlankInHand(t)
|
||||
hand := g.Hand(0)
|
||||
if len(hand) != g.rules.RackSize {
|
||||
t.Fatalf("hand size = %d, want %d", len(hand), g.rules.RackSize)
|
||||
}
|
||||
var blanks int
|
||||
for _, s := range hand {
|
||||
if s == "" {
|
||||
t.Errorf("hand %v has an empty letter", hand)
|
||||
}
|
||||
if s == blankLetter {
|
||||
blanks++
|
||||
}
|
||||
}
|
||||
var want int
|
||||
for _, t := range g.hands[0] {
|
||||
if t == blankTile {
|
||||
want++
|
||||
}
|
||||
}
|
||||
if blanks != want {
|
||||
t.Errorf("decoded blanks = %d, want %d", blanks, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryLookup covers word-check membership and its error taxonomy.
|
||||
func TestRegistryLookup(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
variant Variant
|
||||
word string
|
||||
want bool
|
||||
}{
|
||||
{"scrabble_en hit", VariantEnglish, "cat", true},
|
||||
{"scrabble_en miss", VariantEnglish, "zzzz", false},
|
||||
{"scrabble_ru hit", VariantRussianScrabble, "кот", true},
|
||||
{"erudit_ru hit", VariantErudit, "кот", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := testReg.Lookup(tc.variant, testVersion, tc.word)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("lookup %q = %v, want %v", tc.word, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := testReg.Lookup(VariantEnglish, "missing", "cat"); !errors.Is(err, ErrUnknownVersion) {
|
||||
t.Errorf("unknown version = %v, want ErrUnknownVersion", err)
|
||||
}
|
||||
if _, err := NewRegistry().Lookup(VariantEnglish, testVersion, "cat"); !errors.Is(err, ErrUnknownVariant) {
|
||||
t.Errorf("empty registry = %v, want ErrUnknownVariant", err)
|
||||
}
|
||||
if _, err := testReg.Lookup(VariantEnglish, testVersion, "кот"); err == nil {
|
||||
t.Error("out-of-alphabet lookup must error")
|
||||
}
|
||||
}
|
||||
|
||||
// gameWithBlankInHand returns a two-player English game whose player 0 holds at
|
||||
// least one blank, searching a deterministic range of seeds.
|
||||
func gameWithBlankInHand(t *testing.T) *Game {
|
||||
t.Helper()
|
||||
for seed := int64(1); seed <= 200; seed++ {
|
||||
g := newEnglishGame(t, seed)
|
||||
if slices.Contains(g.Hand(0), blankLetter) {
|
||||
return g
|
||||
}
|
||||
}
|
||||
t.Fatal("no opening rack with a blank found in seeds 1..200")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Package engine is the backend's in-process bridge to the scrabble-solver
|
||||
// library. It catalogues the playable variants, loads versioned dictionaries
|
||||
// into a registry of solvers, and exposes a pure rules engine (the in-memory
|
||||
// Game) that drives a match through legal plays, passes, exchanges and
|
||||
// resignations while detecting the end of the game.
|
||||
//
|
||||
// Two invariants shape the package. First, the solver speaks alphabet-index
|
||||
// bytes that are meaningful only alongside the matching ruleset; every value
|
||||
// that leaves the engine for persistence or display is decoded to concrete
|
||||
// characters (see decode.go and docs/ARCHITECTURE.md §9.1), so archived games
|
||||
// replay independently of any dictionary. Second, the engine owns rules and
|
||||
// scoring only: turn scheduling, the 24-hour timeout, persistence and transport
|
||||
// belong to the game domain.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||
)
|
||||
|
||||
// Variant identifies a Scrabble variant the backend offers. Each maps to a
|
||||
// scrabble-solver ruleset and a committed dictionary.
|
||||
type Variant uint8
|
||||
|
||||
const (
|
||||
// VariantEnglish is standard English Scrabble (the SOWPODS dictionary).
|
||||
VariantEnglish Variant = iota
|
||||
// VariantRussianScrabble is Russian Scrabble.
|
||||
VariantRussianScrabble
|
||||
// VariantErudit is the Russian "Эрудит" variant.
|
||||
VariantErudit
|
||||
)
|
||||
|
||||
// String returns the variant's stable identifier, used in logs and as a metadata
|
||||
// label on persisted games.
|
||||
func (v Variant) String() string {
|
||||
switch v {
|
||||
case VariantEnglish:
|
||||
return "scrabble_en"
|
||||
case VariantRussianScrabble:
|
||||
return "scrabble_ru"
|
||||
case VariantErudit:
|
||||
return "erudit_ru"
|
||||
}
|
||||
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
|
||||
// (nil, false) for an unrecognised variant.
|
||||
func (v Variant) ruleset() (*rules.Ruleset, bool) {
|
||||
switch v {
|
||||
case VariantEnglish:
|
||||
return rules.English(), true
|
||||
case VariantRussianScrabble:
|
||||
return rules.RussianScrabble(), true
|
||||
case VariantErudit:
|
||||
return rules.Erudit(), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Variants returns the variants the backend offers, in catalogue order.
|
||||
func Variants() []Variant {
|
||||
return []Variant{VariantEnglish, VariantRussianScrabble, VariantErudit}
|
||||
}
|
||||
|
||||
// ParseVariant maps a stable label produced by Variant.String back to its
|
||||
// Variant, or returns ErrUnknownVariant. It is the inverse the game domain uses
|
||||
// to read a persisted variant.
|
||||
func ParseVariant(s string) (Variant, error) {
|
||||
for _, v := range Variants() {
|
||||
if v.String() == s {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("%w: %q", ErrUnknownVariant, s)
|
||||
}
|
||||
|
||||
// Ruleset returns the scrabble-solver ruleset for variant. It needs no
|
||||
// dictionary, so it supports dictionary-independent board replay (see
|
||||
// ReplayBoard) from a finished game's variant metadata alone.
|
||||
func Ruleset(v Variant) (*rules.Ruleset, error) {
|
||||
rs, ok := v.ruleset()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
// Sentinel errors returned across the engine. Callers match them with
|
||||
// errors.Is; the wrapped detail carries the offending value.
|
||||
var (
|
||||
// ErrUnknownVariant is returned for a variant the engine does not recognise.
|
||||
ErrUnknownVariant = errors.New("engine: unknown variant")
|
||||
// ErrUnknownVersion is returned when no dictionary is registered for a
|
||||
// (variant, version) pair.
|
||||
ErrUnknownVersion = errors.New("engine: unknown dictionary version")
|
||||
// ErrUnknownDropoutTiles is returned by ParseDropoutTiles for a label that is
|
||||
// neither "remove" nor "return".
|
||||
ErrUnknownDropoutTiles = errors.New("engine: unknown drop-out tile disposition")
|
||||
// ErrIllegalPlay wraps a solver validation failure: off-board geometry, a
|
||||
// word absent from the dictionary, or a play that does not connect.
|
||||
ErrIllegalPlay = errors.New("engine: illegal play")
|
||||
// ErrTilesNotOnRack is returned when a play or exchange references tiles the
|
||||
// acting player does not hold.
|
||||
ErrTilesNotOnRack = errors.New("engine: tiles not on the player's rack")
|
||||
// ErrNotEnoughTilesToExchange is returned when an exchange is attempted while
|
||||
// the bag holds fewer tiles than a full rack.
|
||||
ErrNotEnoughTilesToExchange = errors.New("engine: not enough tiles in the bag to exchange")
|
||||
// ErrNothingToExchange is returned for an exchange of zero tiles.
|
||||
ErrNothingToExchange = errors.New("engine: exchange requires at least one tile")
|
||||
// ErrGameOver is returned when a transition is attempted on a finished game.
|
||||
ErrGameOver = errors.New("engine: game is over")
|
||||
)
|
||||
@@ -0,0 +1,570 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// scorelessLimit is the number of consecutive scoreless turns (passes and
|
||||
// exchanges) that ends a game, per docs/ARCHITECTURE.md §6.
|
||||
const scorelessLimit = 6
|
||||
|
||||
// EndReason explains why a game finished.
|
||||
type EndReason uint8
|
||||
|
||||
const (
|
||||
// EndNotOver marks a game still in progress.
|
||||
EndNotOver EndReason = iota
|
||||
// EndOutOfTiles fires when the bag is empty and a player empties their rack.
|
||||
EndOutOfTiles
|
||||
// EndScoreless fires after scorelessLimit consecutive passes/exchanges.
|
||||
EndScoreless
|
||||
// EndResign fires when a player resigns.
|
||||
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.
|
||||
func (r EndReason) String() string {
|
||||
switch r {
|
||||
case EndNotOver:
|
||||
return "not_over"
|
||||
case EndOutOfTiles:
|
||||
return "out_of_tiles"
|
||||
case EndScoreless:
|
||||
return "scoreless"
|
||||
case EndResign:
|
||||
return "resign"
|
||||
case EndAborted:
|
||||
return "aborted"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// DropoutTiles is the per-game disposition of a dropped-out player's rack when
|
||||
// they resign or time out of a game with three or more seats: the tiles are
|
||||
// either removed from play or returned to the bag. It is agreed at game creation
|
||||
// (docs/ARCHITECTURE.md §6) and is irrelevant to a two-player game, which ends on
|
||||
// the first drop-out. In both dispositions the leaver's rack is never revealed to
|
||||
// the remaining players.
|
||||
type DropoutTiles uint8
|
||||
|
||||
const (
|
||||
// DropoutRemove removes the dropped player's tiles from play; this is the
|
||||
// default, so the zero value matches it.
|
||||
DropoutRemove DropoutTiles = iota
|
||||
// DropoutReturn returns the dropped player's tiles to the bag, where the
|
||||
// remaining players may draw them.
|
||||
DropoutReturn
|
||||
)
|
||||
|
||||
// String renders the disposition as the stable label the game domain persists.
|
||||
func (d DropoutTiles) String() string {
|
||||
if d == DropoutReturn {
|
||||
return "return"
|
||||
}
|
||||
return "remove"
|
||||
}
|
||||
|
||||
// ParseDropoutTiles maps a persisted label back to a DropoutTiles, reporting
|
||||
// ErrUnknownDropoutTiles for an unrecognised value.
|
||||
func ParseDropoutTiles(s string) (DropoutTiles, error) {
|
||||
switch s {
|
||||
case "remove":
|
||||
return DropoutRemove, nil
|
||||
case "return":
|
||||
return DropoutReturn, nil
|
||||
}
|
||||
return 0, fmt.Errorf("%w: %q", ErrUnknownDropoutTiles, s)
|
||||
}
|
||||
|
||||
// Options configures a new game.
|
||||
type Options struct {
|
||||
// Variant selects the rules and dictionary.
|
||||
Variant Variant
|
||||
// Version pins the dictionary version; empty selects the registry's latest.
|
||||
Version string
|
||||
// Players is the number of seats, 2 to 4.
|
||||
Players int
|
||||
// Seed seeds the tile bag, making the game reproducible.
|
||||
Seed int64
|
||||
// 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.
|
||||
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
|
||||
// it. It owns the board, the bag, each player's hand, the running scores, whose
|
||||
// turn it is and the decoded move log, and it detects the end of the game. It
|
||||
// performs no scheduling, persistence or I/O and is not safe for concurrent use.
|
||||
type Game struct {
|
||||
solver *scrabble.Solver
|
||||
rules *rules.Ruleset
|
||||
variant Variant
|
||||
version string
|
||||
|
||||
board *board.Board
|
||||
bag *Bag
|
||||
hands [][]byte // per player, alphabet-index bytes with blankTile for blanks
|
||||
scores []int
|
||||
toMove int
|
||||
scorelessRun int
|
||||
over bool
|
||||
reason EndReason
|
||||
resigned []bool // per seat; a resigned seat is skipped and cannot win
|
||||
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
|
||||
multipleWords bool // false = single-word rule (perpendicular cross-words ignored)
|
||||
log []MoveRecord
|
||||
}
|
||||
|
||||
// New starts a game described by opts over a dictionary from reg. It resolves
|
||||
// the solver (failing with ErrUnknownVariant/ErrUnknownVersion), builds an empty
|
||||
// board and a seeded bag, and deals each player a full rack.
|
||||
func New(reg *Registry, opts Options) (*Game, error) {
|
||||
if opts.Players < 2 || opts.Players > 4 {
|
||||
return nil, fmt.Errorf("engine: players must be between 2 and 4, got %d", opts.Players)
|
||||
}
|
||||
var (
|
||||
solver *scrabble.Solver
|
||||
version = opts.Version
|
||||
err error
|
||||
)
|
||||
if version == "" {
|
||||
version, solver, err = reg.Latest(opts.Variant)
|
||||
} else {
|
||||
solver, err = reg.Solver(opts.Variant, version)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rs := solver.Rules()
|
||||
g := &Game{
|
||||
solver: solver,
|
||||
rules: rs,
|
||||
variant: opts.Variant,
|
||||
version: version,
|
||||
board: board.New(rs.Rows, rs.Cols),
|
||||
bag: NewBag(rs, opts.Seed),
|
||||
hands: make([][]byte, opts.Players),
|
||||
scores: make([]int, opts.Players),
|
||||
resigned: make([]bool, opts.Players),
|
||||
dropoutTiles: opts.DropoutTiles,
|
||||
multipleWords: opts.MultipleWordsPerTurn,
|
||||
}
|
||||
for i := range g.hands {
|
||||
g.hands[i] = g.bag.Draw(rs.RackSize)
|
||||
}
|
||||
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
|
||||
// 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
|
||||
// player does not hold the tiles, ErrIllegalPlay when the solver rejects the
|
||||
// play, and ErrGameOver on a finished game.
|
||||
func (g *Game) Play(dir scrabble.Direction, tiles []scrabble.Placement) (MoveRecord, error) {
|
||||
if g.over {
|
||||
return MoveRecord{}, ErrGameOver
|
||||
}
|
||||
player := g.toMove
|
||||
if err := g.checkHolds(player, placementTiles(tiles)); err != nil {
|
||||
return MoveRecord{}, err
|
||||
}
|
||||
move, err := g.solver.ValidatePlayOpts(g.board, dir, tiles, g.playOpts())
|
||||
if err != nil {
|
||||
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
||||
}
|
||||
|
||||
scrabble.Apply(g.board, move)
|
||||
g.removeFromHand(player, placementTiles(tiles))
|
||||
g.scores[player] += move.Score
|
||||
g.refill(player)
|
||||
g.scorelessRun = 0
|
||||
|
||||
rec := g.recordPlay(player, move)
|
||||
g.log = append(g.log, rec)
|
||||
|
||||
if len(g.hands[player]) == 0 && g.bag.Len() == 0 {
|
||||
g.finish(EndOutOfTiles)
|
||||
} else {
|
||||
g.advance()
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// Pass forfeits the current player's turn. It extends the scoreless run, which
|
||||
// may end the game (EndScoreless), and otherwise advances the turn.
|
||||
func (g *Game) Pass() (MoveRecord, error) {
|
||||
if g.over {
|
||||
return MoveRecord{}, ErrGameOver
|
||||
}
|
||||
player := g.toMove
|
||||
g.scorelessRun++
|
||||
rec := MoveRecord{Player: player, Action: ActionPass, Total: g.scores[player]}
|
||||
g.log = append(g.log, rec)
|
||||
g.endTurnAfterScoreless()
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// Exchange swaps the current player's tiles (alphabet-index bytes, blankTile for
|
||||
// blanks) for fresh ones. It is legal only while the bag holds at least a full
|
||||
// rack. The fresh tiles are drawn before the swapped ones return, so a player
|
||||
// cannot draw back their own tiles. It extends the scoreless run, which may end
|
||||
// the game (EndScoreless).
|
||||
func (g *Game) Exchange(tiles []byte) (MoveRecord, error) {
|
||||
if g.over {
|
||||
return MoveRecord{}, ErrGameOver
|
||||
}
|
||||
if len(tiles) == 0 {
|
||||
return MoveRecord{}, ErrNothingToExchange
|
||||
}
|
||||
if g.bag.Len() < g.rules.RackSize {
|
||||
return MoveRecord{}, ErrNotEnoughTilesToExchange
|
||||
}
|
||||
player := g.toMove
|
||||
if err := g.checkHolds(player, tiles); err != nil {
|
||||
return MoveRecord{}, err
|
||||
}
|
||||
|
||||
g.removeFromHand(player, tiles)
|
||||
g.hands[player] = append(g.hands[player], g.bag.Draw(len(tiles))...)
|
||||
g.bag.Return(tiles)
|
||||
g.scorelessRun++
|
||||
|
||||
rec := MoveRecord{Player: player, Action: ActionExchange, Count: len(tiles), Total: g.scores[player]}
|
||||
g.log = append(g.log, rec)
|
||||
g.endTurnAfterScoreless()
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// Resign drops the current player out of the game. The resigner always forfeits
|
||||
// the win and keeps their accumulated score (it is neither zeroed nor docked a
|
||||
// rack adjustment), and their rack is disposed of per the game's DropoutTiles
|
||||
// setting without ever being revealed to the remaining players. In a game with
|
||||
// three or more seats the others play on with the resigned seat skipped, until
|
||||
// one active seat is left (it wins) or the game ends by the ordinary conditions;
|
||||
// the game finishes with EndResign only once a single active seat remains. A
|
||||
// two-player game therefore ends on the first resignation, the other player
|
||||
// winning regardless of score. A missed-turn timeout reuses Resign in the game
|
||||
// domain, so it inherits this win/loss.
|
||||
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 {
|
||||
return MoveRecord{}, ErrGameOver
|
||||
}
|
||||
if seat < 0 || seat >= len(g.hands) || g.resigned[seat] {
|
||||
return MoveRecord{}, ErrGameOver
|
||||
}
|
||||
g.resigned[seat] = true
|
||||
g.disposeHand(seat)
|
||||
rec := MoveRecord{Player: seat, Action: ActionResign, Total: g.scores[seat]}
|
||||
g.log = append(g.log, rec)
|
||||
if g.activeCount() <= 1 {
|
||||
g.finish(EndResign)
|
||||
} else if seat == g.toMove {
|
||||
g.advance()
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (g *Game) GenerateMoves() []scrabble.Move {
|
||||
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,
|
||||
// or the zero move and false when there is none. It is the top-1 move the
|
||||
// one-per-game hint reveals.
|
||||
func (g *Game) Hint() (scrabble.Move, bool) {
|
||||
moves := g.GenerateMoves()
|
||||
if len(moves) == 0 {
|
||||
return scrabble.Move{}, false
|
||||
}
|
||||
return moves[0], true
|
||||
}
|
||||
|
||||
// Variant returns the variant the game is played under.
|
||||
func (g *Game) Variant() Variant { return g.variant }
|
||||
|
||||
// Version returns the pinned dictionary version.
|
||||
func (g *Game) Version() string { return g.version }
|
||||
|
||||
// Players returns the number of seats in the game.
|
||||
func (g *Game) Players() int { return len(g.hands) }
|
||||
|
||||
// ToMove returns the index of the player whose turn it is. On a finished game it
|
||||
// is the player who made the final move.
|
||||
func (g *Game) ToMove() int { return g.toMove }
|
||||
|
||||
// Over reports whether the game has finished.
|
||||
func (g *Game) Over() bool { return g.over }
|
||||
|
||||
// Reason returns why the game finished, or EndNotOver while it is in progress.
|
||||
func (g *Game) Reason() EndReason { return g.reason }
|
||||
|
||||
// Score returns the current score of the player at index player.
|
||||
func (g *Game) Score(player int) int { return g.scores[player] }
|
||||
|
||||
// BagLen returns the number of tiles left in the bag.
|
||||
func (g *Game) BagLen() int { return g.bag.Len() }
|
||||
|
||||
// BoardClone returns a deep copy of the board, safe for the caller to read or
|
||||
// mutate without affecting the game.
|
||||
func (g *Game) BoardClone() *board.Board { return g.board.Clone() }
|
||||
|
||||
// Log returns a copy of the dictionary-independent move log.
|
||||
func (g *Game) Log() []MoveRecord {
|
||||
out := make([]MoveRecord, len(g.log))
|
||||
copy(out, g.log)
|
||||
return out
|
||||
}
|
||||
|
||||
// Result is the outcome of a finished game.
|
||||
type Result struct {
|
||||
Over bool
|
||||
Reason EndReason
|
||||
Scores []int
|
||||
// Winner is the index of the single highest score, or -1 on a tie or while
|
||||
// the game is unfinished.
|
||||
Winner int
|
||||
}
|
||||
|
||||
// Result reports the current outcome. Final scores already include the standard
|
||||
// end-game rack adjustment applied when the game finished.
|
||||
func (g *Game) Result() Result {
|
||||
scores := make([]int, len(g.scores))
|
||||
copy(scores, g.scores)
|
||||
return Result{Over: g.over, Reason: g.reason, Scores: scores, Winner: g.winner()}
|
||||
}
|
||||
|
||||
// finish marks the game over with reason and applies the end-game rack
|
||||
// adjustment to the scores.
|
||||
func (g *Game) finish(reason EndReason) {
|
||||
g.over = true
|
||||
g.reason = 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
|
||||
// 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
|
||||
// rack value. A resignation freezes the scores: the win is decided by winner
|
||||
// (which excludes the resigner), so no rack adjustment is applied and the
|
||||
// resigner keeps their accumulated score.
|
||||
func (g *Game) applyEndAdjustment(reason EndReason) {
|
||||
switch reason {
|
||||
case EndOutOfTiles:
|
||||
out := g.toMove
|
||||
var bonus int
|
||||
for i := range g.hands {
|
||||
if i == out {
|
||||
continue
|
||||
}
|
||||
v := g.rackValue(i)
|
||||
g.scores[i] -= v
|
||||
bonus += v
|
||||
}
|
||||
g.scores[out] += bonus
|
||||
case EndScoreless:
|
||||
for i := range g.hands {
|
||||
g.scores[i] -= g.rackValue(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// endTurnAfterScoreless ends the game when the scoreless run reaches the limit,
|
||||
// otherwise advances the turn. Used by Pass and Exchange.
|
||||
func (g *Game) endTurnAfterScoreless() {
|
||||
if g.scorelessRun >= scorelessLimit {
|
||||
g.finish(EndScoreless)
|
||||
return
|
||||
}
|
||||
g.advance()
|
||||
}
|
||||
|
||||
// advance moves play to the next active (non-resigned) seat. While a game is in
|
||||
// progress at least two seats are active, so a next active seat always exists;
|
||||
// the loop leaves toMove unchanged in the degenerate all-but-one-resigned case,
|
||||
// which Resign turns into a finished game instead.
|
||||
func (g *Game) advance() {
|
||||
n := len(g.hands)
|
||||
for i := 1; i <= n; i++ {
|
||||
next := (g.toMove + i) % n
|
||||
if !g.resigned[next] {
|
||||
g.toMove = next
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// activeCount returns the number of seats that have not resigned.
|
||||
func (g *Game) activeCount() int {
|
||||
n := 0
|
||||
for _, r := range g.resigned {
|
||||
if !r {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// disposeHand empties a resigned player's rack per the game's DropoutTiles
|
||||
// setting: it returns the tiles to the bag or removes them from play. Either way
|
||||
// the hand is cleared, so the end-game rack adjustment ignores the seat and the
|
||||
// rack is never exposed.
|
||||
func (g *Game) disposeHand(player int) {
|
||||
if g.dropoutTiles == DropoutReturn {
|
||||
g.bag.Return(g.hands[player])
|
||||
}
|
||||
g.hands[player] = nil
|
||||
}
|
||||
|
||||
// winner returns the index of the single highest-scoring player, or -1 on a tie
|
||||
// for the lead or while the game is unfinished. Resigned (dropped-out) seats are
|
||||
// always excluded, so a two-player game returns the remaining player even when
|
||||
// the resigner led on score, and a multi-player game never awards the win to a
|
||||
// seat that left.
|
||||
func (g *Game) winner() int {
|
||||
if !g.over {
|
||||
return -1
|
||||
}
|
||||
if g.reason == EndAborted {
|
||||
return -1 // an aborted game is a draw regardless of the running scores
|
||||
}
|
||||
best, tie := -1, false
|
||||
for i := range g.scores {
|
||||
if g.resigned[i] {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case best == -1 || g.scores[i] > g.scores[best]:
|
||||
best, tie = i, false
|
||||
case g.scores[i] == g.scores[best]:
|
||||
tie = true
|
||||
}
|
||||
}
|
||||
if tie {
|
||||
return -1
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// rackOf builds a generation rack from player's hand.
|
||||
func (g *Game) rackOf(player int) rack.Rack {
|
||||
r := rack.New(g.rules.Size())
|
||||
for _, t := range g.hands[player] {
|
||||
if t == blankTile {
|
||||
r.AddBlank()
|
||||
} else {
|
||||
r.Add(t)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// rackValue sums the tile values left on player's hand; blanks count zero.
|
||||
func (g *Game) rackValue(player int) int {
|
||||
var v int
|
||||
for _, t := range g.hands[player] {
|
||||
if t != blankTile {
|
||||
v += g.rules.Values[t]
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// checkHolds reports ErrTilesNotOnRack unless player holds every tile in want.
|
||||
func (g *Game) checkHolds(player int, want []byte) error {
|
||||
avail := tileCounts(g.hands[player])
|
||||
for tile, n := range tileCounts(want) {
|
||||
if avail[tile] < n {
|
||||
return ErrTilesNotOnRack
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeFromHand takes one tile per entry of used off player's hand.
|
||||
func (g *Game) removeFromHand(player int, used []byte) {
|
||||
hand := g.hands[player]
|
||||
for _, t := range used {
|
||||
for i, h := range hand {
|
||||
if h == t {
|
||||
hand = append(hand[:i], hand[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
g.hands[player] = hand
|
||||
}
|
||||
|
||||
// refill draws from the bag until player's hand is full or the bag is empty.
|
||||
func (g *Game) refill(player int) {
|
||||
if need := g.rules.RackSize - len(g.hands[player]); need > 0 {
|
||||
g.hands[player] = append(g.hands[player], g.bag.Draw(need)...)
|
||||
}
|
||||
}
|
||||
|
||||
// placementTiles maps placements to the tiles they consume (blankTile for blanks).
|
||||
func placementTiles(tiles []scrabble.Placement) []byte {
|
||||
out := make([]byte, len(tiles))
|
||||
for i, p := range tiles {
|
||||
if p.Blank {
|
||||
out[i] = blankTile
|
||||
} else {
|
||||
out[i] = p.Letter
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tileCounts tallies a multiset of tiles by value.
|
||||
func tileCounts(tiles []byte) map[byte]int {
|
||||
m := make(map[byte]int, len(tiles))
|
||||
for _, t := range tiles {
|
||||
m[t]++
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// newEnglishGame starts a two-player English game with the given seed.
|
||||
func newEnglishGame(t *testing.T, seed int64) *Game {
|
||||
t.Helper()
|
||||
g, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 2, Seed: seed})
|
||||
if err != nil {
|
||||
t.Fatalf("new game: %v", err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// openingGame returns a two-player English game whose opening rack has at least
|
||||
// one legal move, searching a deterministic range of seeds.
|
||||
func openingGame(t *testing.T) *Game {
|
||||
t.Helper()
|
||||
for seed := int64(1); seed <= 100; seed++ {
|
||||
g := newEnglishGame(t, seed)
|
||||
if len(g.GenerateMoves()) > 0 {
|
||||
return g
|
||||
}
|
||||
}
|
||||
t.Fatal("no opening move found in seeds 1..100")
|
||||
return nil
|
||||
}
|
||||
|
||||
// boardsEqual reports whether two boards have identical dimensions and cells.
|
||||
func boardsEqual(a, b *board.Board) bool {
|
||||
if a.Rows() != b.Rows() || a.Cols() != b.Cols() {
|
||||
return false
|
||||
}
|
||||
for r := range a.Rows() {
|
||||
for c := range a.Cols() {
|
||||
if a.At(r, c) != b.At(r, c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestNewDealsRacks checks the initial state of a fresh game.
|
||||
func TestNewDealsRacks(t *testing.T) {
|
||||
g := newEnglishGame(t, 1)
|
||||
if g.Players() != 2 {
|
||||
t.Errorf("players = %d, want 2", g.Players())
|
||||
}
|
||||
if g.ToMove() != 0 {
|
||||
t.Errorf("to move = %d, want 0", g.ToMove())
|
||||
}
|
||||
if g.Over() {
|
||||
t.Error("a fresh game must not be over")
|
||||
}
|
||||
if g.Score(0) != 0 || g.Score(1) != 0 {
|
||||
t.Errorf("scores = (%d, %d), want (0, 0)", g.Score(0), g.Score(1))
|
||||
}
|
||||
rackSize := g.rules.RackSize
|
||||
if len(g.hands[0]) != rackSize || len(g.hands[1]) != rackSize {
|
||||
t.Fatalf("hand sizes = (%d, %d), want %d each", len(g.hands[0]), len(g.hands[1]), rackSize)
|
||||
}
|
||||
if want := len(allTiles(g.rules)) - 2*rackSize; g.BagLen() != want {
|
||||
t.Errorf("bag len = %d, want %d", g.BagLen(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewRejectsBadPlayerCount rejects player counts outside 2..4.
|
||||
func TestNewRejectsBadPlayerCount(t *testing.T) {
|
||||
for _, n := range []int{0, 1, 5} {
|
||||
if _, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: n, Seed: 1}); err == nil {
|
||||
t.Errorf("players=%d: expected an error", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewUnknownVariant surfaces the registry's not-found error.
|
||||
func TestNewUnknownVariant(t *testing.T) {
|
||||
if _, err := New(testReg, Options{Variant: Variant(99), Version: testVersion, Players: 2}); !errors.Is(err, ErrUnknownVariant) {
|
||||
t.Fatalf("got %v, want ErrUnknownVariant", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayScoresAndAdvances plays the top opening move and checks the score,
|
||||
// running total, refill and turn advance.
|
||||
func TestPlayScoresAndAdvances(t *testing.T) {
|
||||
g := openingGame(t)
|
||||
move := g.GenerateMoves()[0]
|
||||
played := len(move.Tiles)
|
||||
bagBefore := g.BagLen()
|
||||
|
||||
rec, err := g.Play(move.Dir, move.Tiles)
|
||||
if err != nil {
|
||||
t.Fatalf("play: %v", err)
|
||||
}
|
||||
if rec.Action != ActionPlay {
|
||||
t.Errorf("action = %v, want play", rec.Action)
|
||||
}
|
||||
if rec.Score != move.Score || g.Score(0) != move.Score {
|
||||
t.Errorf("score: rec=%d game=%d, want %d", rec.Score, g.Score(0), move.Score)
|
||||
}
|
||||
if rec.Total != move.Score {
|
||||
t.Errorf("running total = %d, want %d", rec.Total, move.Score)
|
||||
}
|
||||
if len(rec.Tiles) != played {
|
||||
t.Errorf("recorded tiles = %d, want %d", len(rec.Tiles), played)
|
||||
}
|
||||
if g.ToMove() != 1 {
|
||||
t.Errorf("to move = %d, want 1", g.ToMove())
|
||||
}
|
||||
if len(g.hands[0]) != g.rules.RackSize {
|
||||
t.Errorf("hand refilled to %d, want %d", len(g.hands[0]), g.rules.RackSize)
|
||||
}
|
||||
if g.BagLen() != bagBefore-played {
|
||||
t.Errorf("bag len = %d, want %d", g.BagLen(), bagBefore-played)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayRejectsTilesNotOnRack rejects a play using tiles the player lacks.
|
||||
func TestPlayRejectsTilesNotOnRack(t *testing.T) {
|
||||
g := newEnglishGame(t, 1)
|
||||
row, col := centre(g.rules)
|
||||
ps := placementsForWord(t, g.rules, row, col, scrabble.Horizontal, "cat")
|
||||
// Clear the hand so the player provably lacks the tiles; the holds check
|
||||
// must reject the play before any dictionary check.
|
||||
g.hands[0] = nil
|
||||
if _, err := g.Play(scrabble.Horizontal, ps); !errors.Is(err, ErrTilesNotOnRack) {
|
||||
t.Fatalf("got %v, want ErrTilesNotOnRack", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeSwapsTiles exchanges two tiles and checks the bag and turn state.
|
||||
func TestExchangeSwapsTiles(t *testing.T) {
|
||||
g := newEnglishGame(t, 1)
|
||||
bagBefore := g.BagLen()
|
||||
swap := append([]byte(nil), g.hands[0][:2]...)
|
||||
|
||||
rec, err := g.Exchange(swap)
|
||||
if err != nil {
|
||||
t.Fatalf("exchange: %v", err)
|
||||
}
|
||||
if rec.Action != ActionExchange || rec.Count != 2 {
|
||||
t.Errorf("record = %+v, want exchange of 2", rec)
|
||||
}
|
||||
if len(g.hands[0]) != g.rules.RackSize {
|
||||
t.Errorf("hand size = %d, want %d", len(g.hands[0]), g.rules.RackSize)
|
||||
}
|
||||
if g.BagLen() != bagBefore {
|
||||
t.Errorf("bag len = %d, want %d (draw and return cancel)", g.BagLen(), bagBefore)
|
||||
}
|
||||
if g.ToMove() != 1 {
|
||||
t.Errorf("to move = %d, want 1", g.ToMove())
|
||||
}
|
||||
if g.scorelessRun != 1 {
|
||||
t.Errorf("scoreless run = %d, want 1", g.scorelessRun)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeNeedsFullBag rejects an exchange once the bag is below a rack.
|
||||
func TestExchangeNeedsFullBag(t *testing.T) {
|
||||
g := newEnglishGame(t, 1)
|
||||
g.bag.Draw(g.bag.Len()) // drain the bag
|
||||
if _, err := g.Exchange(g.hands[0][:1]); !errors.Is(err, ErrNotEnoughTilesToExchange) {
|
||||
t.Fatalf("got %v, want ErrNotEnoughTilesToExchange", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPassEndsAfterSixScoreless ends the game after the scoreless limit and then
|
||||
// rejects further transitions.
|
||||
func TestPassEndsAfterSixScoreless(t *testing.T) {
|
||||
g := newEnglishGame(t, 1)
|
||||
for i := range scorelessLimit {
|
||||
if _, err := g.Pass(); err != nil {
|
||||
t.Fatalf("pass %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if !g.Over() {
|
||||
t.Fatal("game must be over after six scoreless turns")
|
||||
}
|
||||
if g.Reason() != EndScoreless {
|
||||
t.Errorf("reason = %v, want scoreless", g.Reason())
|
||||
}
|
||||
if _, err := g.Pass(); !errors.Is(err, ErrGameOver) {
|
||||
t.Errorf("pass after end: got %v, want ErrGameOver", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGreedyPlaythroughEndsAndReplays drives a full greedy game to its end and
|
||||
// proves the dictionary-independent replay reproduces the final board.
|
||||
func TestGreedyPlaythroughEndsAndReplays(t *testing.T) {
|
||||
g := newEnglishGame(t, 20250602)
|
||||
const maxTurns = 600
|
||||
for turn := 0; turn < maxTurns && !g.Over(); turn++ {
|
||||
if moves := g.GenerateMoves(); len(moves) > 0 {
|
||||
if _, err := g.Play(moves[0].Dir, moves[0].Tiles); err != nil {
|
||||
t.Fatalf("turn %d play: %v", turn, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := g.Pass(); err != nil {
|
||||
t.Fatalf("turn %d pass: %v", turn, err)
|
||||
}
|
||||
}
|
||||
if !g.Over() {
|
||||
t.Fatalf("game did not finish within %d turns", maxTurns)
|
||||
}
|
||||
|
||||
rs, err := Ruleset(VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("ruleset: %v", err)
|
||||
}
|
||||
replayed, err := ReplayBoard(rs, g.Log())
|
||||
if err != nil {
|
||||
t.Fatalf("replay: %v", err)
|
||||
}
|
||||
if !boardsEqual(replayed, g.BoardClone()) {
|
||||
t.Fatal("replayed board differs from the final board")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// testVersion labels the single dictionary version the tests register.
|
||||
const testVersion = "test"
|
||||
|
||||
// testReg is the shared registry of all three variants, hydrated once by
|
||||
// TestMain and reused by the read-only tests.
|
||||
var testReg *Registry
|
||||
|
||||
// TestMain loads the committed dictionaries once and shares them with every
|
||||
// test. It fails loudly when the dictionary directory is absent (per
|
||||
// docs/TESTING.md) rather than skipping coverage.
|
||||
func TestMain(m *testing.M) {
|
||||
reg, err := Open(testDictDir(), testVersion)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "engine test setup:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
testReg = reg
|
||||
code := m.Run()
|
||||
_ = reg.Close()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// testDictDir resolves the directory holding the committed scrabble-solver
|
||||
// DAWGs: BACKEND_DICT_DIR when set (used in CI), otherwise the sibling checkout
|
||||
// located relative to this test file.
|
||||
func testDictDir() string {
|
||||
if dir := os.Getenv("BACKEND_DICT_DIR"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "scrabble-solver", "dawg")
|
||||
}
|
||||
|
||||
// centre returns the centre square coordinates of rs.
|
||||
func centre(rs *rules.Ruleset) (row, col int) {
|
||||
return rs.Center / rs.Cols, rs.Center % rs.Cols
|
||||
}
|
||||
|
||||
// placementsForWord lays word out from (row, col) along dir, resolving each rune
|
||||
// through the ruleset's alphabet. It expresses no blanks.
|
||||
func placementsForWord(t *testing.T, rs *rules.Ruleset, row, col int, dir scrabble.Direction, word string) []scrabble.Placement {
|
||||
t.Helper()
|
||||
var ps []scrabble.Placement
|
||||
for i, r := range []rune(word) {
|
||||
idx, err := rs.Alphabet.Index(string(r))
|
||||
if err != nil {
|
||||
t.Fatalf("index %q: %v", string(r), err)
|
||||
}
|
||||
rr, cc := row, col
|
||||
if dir == scrabble.Horizontal {
|
||||
cc += i
|
||||
} else {
|
||||
rr += i
|
||||
}
|
||||
ps = append(ps, scrabble.Placement{Row: rr, Col: cc, Letter: idx})
|
||||
}
|
||||
return ps
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
dawg "github.com/iliadenisov/dafsa"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// dictFiles maps each variant to its committed DAWG filename, as built by
|
||||
// scrabble-solver and delivered in the dictionary directory.
|
||||
var dictFiles = map[Variant]string{
|
||||
VariantEnglish: "en_sowpods.dawg",
|
||||
VariantRussianScrabble: "ru_scrabble.dawg",
|
||||
VariantErudit: "ru_erudit.dawg",
|
||||
}
|
||||
|
||||
// entry is one resident dictionary: the loaded finder and the solver built over
|
||||
// it. The finder is retained so Close can release it.
|
||||
type entry struct {
|
||||
finder dawg.Finder
|
||||
solver *scrabble.Solver
|
||||
}
|
||||
|
||||
// Registry holds the dictionaries resident in memory, addressed by variant and
|
||||
// dictionary version, and the solvers built over them. Several versions of a
|
||||
// variant may be resident at once; a game pins the version it started on. The
|
||||
// admin reload flow registers a new version through Load.
|
||||
// Registry is safe for concurrent use.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
entries map[Variant]map[string]entry
|
||||
latest map[Variant]string
|
||||
}
|
||||
|
||||
// NewRegistry constructs an empty Registry. Use Load or Open to populate it.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
entries: make(map[Variant]map[string]entry),
|
||||
latest: make(map[Variant]string),
|
||||
}
|
||||
}
|
||||
|
||||
// Open builds a Registry by loading, at dictionary version, the committed DAWG of
|
||||
// every requested variant (or all variants when none are named) from dir. It
|
||||
// fails if a variant is unknown or its file is missing or unreadable; a
|
||||
// partially loaded registry is closed before the error is returned.
|
||||
func Open(dir, version string, variants ...Variant) (*Registry, error) {
|
||||
if len(variants) == 0 {
|
||||
variants = Variants()
|
||||
}
|
||||
r := NewRegistry()
|
||||
for _, v := range variants {
|
||||
if err := r.Load(v, version, dir); err != nil {
|
||||
_ = r.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// OpenWithVersions builds a Registry by loading the boot version from the flat
|
||||
// dir (every variant, as Open) and then every additional version held in an
|
||||
// immediate subdirectory of dir: a subdirectory named V contributes, under
|
||||
// version V, the variants whose committed DAWG it carries. This is the
|
||||
// restart-side of the admin dictionary reload — a version reloaded into dir/<V>/
|
||||
// at runtime is resident again after a restart. The flat dir's version is resolved
|
||||
// from its .seed_version marker (see resolveSeedVersion): a fresh dir records
|
||||
// bootVersion, an already-seeded dir keeps its recorded label and ignores bootVersion,
|
||||
// so a bumped build seed never relabels live bytes. A subdirectory named like the
|
||||
// resolved seed version is skipped (the flat dir already is it). A partially loaded
|
||||
// registry is closed before any error is returned.
|
||||
func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
|
||||
// Resolve the flat dir's version from its seed marker first: on an already-seeded
|
||||
// volume the marker wins and bootVersion is ignored, so a bumped build seed cannot
|
||||
// relabel live bytes (see resolveSeedVersion).
|
||||
seed, err := resolveSeedVersion(dir, bootVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := Open(dir, seed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
_ = r.Close()
|
||||
return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
// Skip non-directories, the resolved seed version (already loaded as the flat
|
||||
// dir) and dot-prefixed directories (the upload staging area, dir/.staging/).
|
||||
if !e.IsDir() || e.Name() == seed || strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); err != nil {
|
||||
_ = r.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Load reads the committed DAWG of variant from dir, builds a solver over it and
|
||||
// registers it under version. Reloading the same (variant, version) replaces the
|
||||
// previous entry, closing its finder. The most recently loaded version of a
|
||||
// variant becomes its latest.
|
||||
func (r *Registry) Load(v Variant, version, dir string) error {
|
||||
rs, ok := v.ruleset()
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %d", ErrUnknownVariant, v)
|
||||
}
|
||||
path := filepath.Join(dir, dictFiles[v])
|
||||
finder, err := dawg.Load(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("engine: load %s dictionary %q from %s: %w", v, version, path, err)
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.entries[v] == nil {
|
||||
r.entries[v] = make(map[string]entry)
|
||||
}
|
||||
if old, ok := r.entries[v][version]; ok {
|
||||
_ = old.finder.Close()
|
||||
}
|
||||
r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder)}
|
||||
r.latest[v] = version
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAvailable loads, under version, every variant whose committed DAWG is
|
||||
// present in dir, skipping a variant whose file is absent. It backs the admin
|
||||
// dictionary reload (a version subdirectory may carry only the variants that were
|
||||
// rebuilt) and OpenWithVersions' boot-time scan. It returns the variants it
|
||||
// loaded, in catalogue order, or the first load error.
|
||||
func (r *Registry) LoadAvailable(dir, version string) ([]Variant, error) {
|
||||
var loaded []Variant
|
||||
for _, v := range Variants() {
|
||||
path := filepath.Join(dir, dictFiles[v])
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return loaded, fmt.Errorf("engine: stat %s dictionary %q in %s: %w", v, version, dir, err)
|
||||
}
|
||||
if err := r.Load(v, version, dir); err != nil {
|
||||
return loaded, err
|
||||
}
|
||||
loaded = append(loaded, v)
|
||||
}
|
||||
return loaded, nil
|
||||
}
|
||||
|
||||
// Solver returns the solver for the (variant, version) pair, or ErrUnknownVariant
|
||||
// when the variant is absent and ErrUnknownVersion when only the version is.
|
||||
func (r *Registry) Solver(v Variant, version string) (*scrabble.Solver, 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.solver, nil
|
||||
}
|
||||
|
||||
// Latest returns the most recently loaded version of variant and its solver, or
|
||||
// ErrUnknownVariant when none is resident.
|
||||
func (r *Registry) Latest(v Variant) (string, *scrabble.Solver, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
version, ok := r.latest[v]
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
|
||||
}
|
||||
return version, r.entries[v][version].solver, nil
|
||||
}
|
||||
|
||||
// Versions returns the dictionary versions resident for variant, sorted, or nil
|
||||
// when none are.
|
||||
func (r *Registry) Versions(v Variant) []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if len(r.entries[v]) == 0 {
|
||||
return nil
|
||||
}
|
||||
versions := make([]string, 0, len(r.entries[v]))
|
||||
for ver := range r.entries[v] {
|
||||
versions = append(versions, ver)
|
||||
}
|
||||
sort.Strings(versions)
|
||||
return versions
|
||||
}
|
||||
|
||||
// Lookup reports whether word is present in the (variant, version) dictionary,
|
||||
// backing the unlimited word-check tool. It returns ErrUnknownVariant or
|
||||
// ErrUnknownVersion when that dictionary is not resident, and an error when word
|
||||
// contains a character outside the variant's alphabet. The word is matched as
|
||||
// given; callers normalise case to the variant's alphabet first.
|
||||
func (r *Registry) Lookup(v Variant, version, word string) (bool, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
versions, ok := r.entries[v]
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
|
||||
}
|
||||
e, ok := versions[version]
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version)
|
||||
}
|
||||
idx, err := e.finder.IndexOf(word)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("engine: lookup %q in %s/%s: %w", word, v, version, err)
|
||||
}
|
||||
return idx >= 0, nil
|
||||
}
|
||||
|
||||
// Close releases every resident dictionary and empties the registry. It is safe
|
||||
// to call more than once; the first close error is returned after all finders
|
||||
// have been closed.
|
||||
func (r *Registry) Close() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var firstErr error
|
||||
for v, versions := range r.entries {
|
||||
for ver, e := range versions {
|
||||
if err := e.finder.Close(); err != nil && firstErr == nil {
|
||||
firstErr = fmt.Errorf("engine: close %s/%s dictionary: %w", v, ver, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
r.entries = make(map[Variant]map[string]entry)
|
||||
r.latest = make(map[Variant]string)
|
||||
return firstErr
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// TestRegistryOpensEveryVariant checks that Open loads all three variants at the
|
||||
// requested version and reports them through Latest and Versions.
|
||||
func TestRegistryOpensEveryVariant(t *testing.T) {
|
||||
for _, v := range Variants() {
|
||||
version, solver, err := testReg.Latest(v)
|
||||
if err != nil {
|
||||
t.Fatalf("latest %s: %v", v, err)
|
||||
}
|
||||
if version != testVersion {
|
||||
t.Errorf("latest %s version = %q, want %q", v, version, testVersion)
|
||||
}
|
||||
if solver == nil {
|
||||
t.Errorf("latest %s solver is nil", v)
|
||||
}
|
||||
if got := testReg.Versions(v); len(got) != 1 || got[0] != testVersion {
|
||||
t.Errorf("versions %s = %v, want [%q]", v, got, testVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryValidatesKnownWords is the per-variant smoke test: a known word
|
||||
// laid over the centre validates against the loaded dictionary, including the
|
||||
// Эрудит variant.
|
||||
func TestRegistryValidatesKnownWords(t *testing.T) {
|
||||
cases := []struct {
|
||||
variant Variant
|
||||
word string
|
||||
}{
|
||||
{VariantEnglish, "cat"},
|
||||
{VariantRussianScrabble, "кот"},
|
||||
{VariantErudit, "кот"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.variant.String(), func(t *testing.T) {
|
||||
solver, err := testReg.Solver(tc.variant, testVersion)
|
||||
if err != nil {
|
||||
t.Fatalf("solver: %v", err)
|
||||
}
|
||||
rs := solver.Rules()
|
||||
row, col := centre(rs)
|
||||
ps := placementsForWord(t, rs, row, col, scrabble.Horizontal, tc.word)
|
||||
if _, err := solver.ValidatePlay(board.New(rs.Rows, rs.Cols), scrabble.Horizontal, ps); err != nil {
|
||||
t.Fatalf("validate %q against %s: %v", tc.word, tc.variant, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryUnknownLookups covers the not-found error taxonomy.
|
||||
func TestRegistryUnknownLookups(t *testing.T) {
|
||||
reg, err := Open(testDictDir(), testVersion, VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("open scrabble_en-only registry: %v", err)
|
||||
}
|
||||
defer reg.Close()
|
||||
|
||||
if _, err := reg.Solver(VariantEnglish, "absent"); !errors.Is(err, ErrUnknownVersion) {
|
||||
t.Errorf("solver with bad version: got %v, want ErrUnknownVersion", err)
|
||||
}
|
||||
if _, err := reg.Solver(VariantErudit, testVersion); !errors.Is(err, ErrUnknownVariant) {
|
||||
t.Errorf("solver for unloaded variant: got %v, want ErrUnknownVariant", err)
|
||||
}
|
||||
if _, _, err := reg.Latest(VariantErudit); !errors.Is(err, ErrUnknownVariant) {
|
||||
t.Errorf("latest for unloaded variant: got %v, want ErrUnknownVariant", err)
|
||||
}
|
||||
if got := reg.Versions(VariantErudit); got != nil {
|
||||
t.Errorf("versions for unloaded variant = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryCloseIdempotent verifies Close may be called more than once.
|
||||
func TestRegistryCloseIdempotent(t *testing.T) {
|
||||
reg, err := Open(testDictDir(), testVersion, VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := reg.Close(); err != nil {
|
||||
t.Fatalf("first close: %v", err)
|
||||
}
|
||||
if err := reg.Close(); err != nil {
|
||||
t.Fatalf("second close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryOpenMissingDir fails when a dictionary file is absent.
|
||||
func TestRegistryOpenMissingDir(t *testing.T) {
|
||||
if _, err := Open(t.TempDir(), testVersion, VariantEnglish); err == nil {
|
||||
t.Fatal("expected an error opening a registry over an empty directory")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// copyDawg copies the committed DAWG for v from srcDir into dstDir (creating
|
||||
// dstDir). It is the fixture builder for the dictionary-reload tests, which need
|
||||
// real DAWG files laid out in temporary version directories.
|
||||
func copyDawg(t *testing.T, srcDir, dstDir string, v Variant) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dstDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", dstDir, err)
|
||||
}
|
||||
name := dictFiles[v]
|
||||
src, err := os.Open(filepath.Join(srcDir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("open source dawg %s: %v", name, err)
|
||||
}
|
||||
defer func() { _ = src.Close() }()
|
||||
dst, err := os.Create(filepath.Join(dstDir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("create dest dawg %s: %v", name, err)
|
||||
}
|
||||
defer func() { _ = dst.Close() }()
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
t.Fatalf("copy dawg %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAvailableLoadsPresentSkipsAbsent verifies LoadAvailable registers only
|
||||
// the variants whose DAWG is present in the directory, under the given version.
|
||||
func TestLoadAvailableLoadsPresentSkipsAbsent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
copyDawg(t, testDictDir(), dir, VariantEnglish) // only English present
|
||||
|
||||
reg := NewRegistry()
|
||||
defer func() { _ = reg.Close() }()
|
||||
loaded, err := reg.LoadAvailable(dir, "v2")
|
||||
if err != nil {
|
||||
t.Fatalf("load available: %v", err)
|
||||
}
|
||||
if len(loaded) != 1 || loaded[0] != VariantEnglish {
|
||||
t.Fatalf("loaded = %v, want [scrabble_en]", loaded)
|
||||
}
|
||||
if _, err := reg.Solver(VariantEnglish, "v2"); err != nil {
|
||||
t.Errorf("scrabble_en v2 solver: %v", err)
|
||||
}
|
||||
if _, err := reg.Solver(VariantRussianScrabble, "v2"); !errors.Is(err, ErrUnknownVariant) {
|
||||
t.Errorf("scrabble_ru v2 should be absent: got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenWithVersionsScansSubdirs verifies the boot helper loads the flat boot
|
||||
// version plus every version subdirectory, the subdir version becoming the
|
||||
// variant's latest while the boot version stays resident.
|
||||
func TestOpenWithVersionsScansSubdirs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, v := range Variants() { // flat boot version: all three variants
|
||||
copyDawg(t, testDictDir(), dir, v)
|
||||
}
|
||||
copyDawg(t, testDictDir(), filepath.Join(dir, "v2"), VariantEnglish) // v2 subdir: English only
|
||||
|
||||
reg, err := OpenWithVersions(dir, "v1")
|
||||
if err != nil {
|
||||
t.Fatalf("open with versions: %v", err)
|
||||
}
|
||||
defer func() { _ = reg.Close() }()
|
||||
|
||||
for _, v := range Variants() {
|
||||
if _, err := reg.Solver(v, "v1"); err != nil {
|
||||
t.Errorf("boot solver %s/v1: %v", v, err)
|
||||
}
|
||||
}
|
||||
if got := reg.Versions(VariantEnglish); len(got) != 2 {
|
||||
t.Errorf("scrabble_en versions = %v, want two", got)
|
||||
}
|
||||
latest, _, err := reg.Latest(VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("latest scrabble_en: %v", err)
|
||||
}
|
||||
if latest != "v2" {
|
||||
t.Errorf("latest scrabble_en = %q, want v2", latest)
|
||||
}
|
||||
if got := reg.Versions(VariantRussianScrabble); len(got) != 1 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReloadRegistersNewVersion verifies Load adds a second version to a variant
|
||||
// already resident, moves the latest pointer and keeps the earlier version.
|
||||
func TestReloadRegistersNewVersion(t *testing.T) {
|
||||
reg, err := Open(testDictDir(), "v1", VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer func() { _ = reg.Close() }()
|
||||
|
||||
if err := reg.Load(VariantEnglish, "v2", testDictDir()); err != nil {
|
||||
t.Fatalf("reload v2: %v", err)
|
||||
}
|
||||
if got := reg.Versions(VariantEnglish); len(got) != 2 {
|
||||
t.Fatalf("versions = %v, want two", got)
|
||||
}
|
||||
latest, _, err := reg.Latest(VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("latest: %v", err)
|
||||
}
|
||||
if latest != "v2" {
|
||||
t.Errorf("latest = %q, want v2", latest)
|
||||
}
|
||||
if _, err := reg.Solver(VariantEnglish, "v1"); err != nil {
|
||||
t.Errorf("v1 still resident: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package engine
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestResignLeadingPlayerStillLoses is the core of the resignation fix: a player
|
||||
// who resigns loses even when leading on score, the remaining player wins, and
|
||||
// the resigner's score is frozen (no end-game rack adjustment).
|
||||
func TestResignLeadingPlayerStillLoses(t *testing.T) {
|
||||
g := openingGame(t)
|
||||
|
||||
hint, ok := g.HintView()
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
played, err := g.SubmitPlay(hint.Tiles)
|
||||
if err != nil {
|
||||
t.Fatalf("player 0 play: %v", err)
|
||||
}
|
||||
if played.Score == 0 {
|
||||
t.Fatal("opening play scored 0; pick a different seed")
|
||||
}
|
||||
|
||||
if _, err := g.Pass(); err != nil { // player 1
|
||||
t.Fatalf("player 1 pass: %v", err)
|
||||
}
|
||||
|
||||
// Player 0 is now on turn and leads 0:played.Score; resigning must still lose.
|
||||
if _, err := g.Resign(); err != nil {
|
||||
t.Fatalf("player 0 resign: %v", err)
|
||||
}
|
||||
|
||||
if !g.Over() || g.Reason() != EndResign {
|
||||
t.Fatalf("game over=%v reason=%v, want over with resign", g.Over(), g.Reason())
|
||||
}
|
||||
res := g.Result()
|
||||
if res.Winner != 1 {
|
||||
t.Errorf("winner = %d, want 1 (the non-resigner) despite the resigner leading", res.Winner)
|
||||
}
|
||||
if g.Score(0) != played.Score {
|
||||
t.Errorf("resigner score = %d, want frozen at %d (no rack adjustment)", g.Score(0), played.Score)
|
||||
}
|
||||
if g.Score(1) != 0 {
|
||||
t.Errorf("opponent score = %d, want 0", g.Score(1))
|
||||
}
|
||||
if g.Score(0) <= g.Score(1) {
|
||||
t.Fatal("test precondition: resigner should lead on raw score")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResignTrailingPlayerLoses covers the ordinary case: the trailing player
|
||||
// resigns and the leader wins.
|
||||
func TestResignTrailingPlayerLoses(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 scores
|
||||
t.Fatalf("player 0 play: %v", err)
|
||||
}
|
||||
|
||||
// Player 1 (trailing 0 points) resigns.
|
||||
if _, err := g.Resign(); err != nil {
|
||||
t.Fatalf("player 1 resign: %v", err)
|
||||
}
|
||||
if res := g.Result(); res.Winner != 0 {
|
||||
t.Errorf("winner = %d, want 0", res.Winner)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestResignOnFinishedGame(t *testing.T) {
|
||||
g := newEnglishGame(t, 1)
|
||||
if _, err := g.Resign(); err != nil {
|
||||
t.Fatalf("first resign: %v", err)
|
||||
}
|
||||
if _, err := g.Resign(); err == nil {
|
||||
t.Error("resign on a finished game must error")
|
||||
}
|
||||
}
|
||||
|
||||
// openingGameN returns a players-seat English game whose opening rack has a legal
|
||||
// move, searching a deterministic range of seeds.
|
||||
func openingGameN(t *testing.T, players int, dt DropoutTiles) *Game {
|
||||
t.Helper()
|
||||
for seed := int64(1); seed <= 100; seed++ {
|
||||
g, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: players, Seed: seed, DropoutTiles: dt})
|
||||
if err != nil {
|
||||
t.Fatalf("new game: %v", err)
|
||||
}
|
||||
if len(g.GenerateMoves()) > 0 {
|
||||
return g
|
||||
}
|
||||
}
|
||||
t.Fatal("no opening move found in seeds 1..100")
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestMultiplayerResignContinues proves that in a three-player game one
|
||||
// resignation does not end the game and the resigned seat is skipped in rotation.
|
||||
func TestMultiplayerResignContinues(t *testing.T) {
|
||||
g := openingGameN(t, 3, DropoutRemove)
|
||||
if _, err := g.Resign(); err != nil { // seat 0
|
||||
t.Fatalf("seat 0 resign: %v", err)
|
||||
}
|
||||
if g.Over() {
|
||||
t.Fatal("a three-player game must continue after one resignation")
|
||||
}
|
||||
if g.ToMove() != 1 {
|
||||
t.Errorf("to move = %d, want 1 (seat 0 skipped)", g.ToMove())
|
||||
}
|
||||
if _, err := g.Pass(); err != nil { // seat 1
|
||||
t.Fatalf("seat 1 pass: %v", err)
|
||||
}
|
||||
if g.ToMove() != 2 {
|
||||
t.Errorf("to move = %d, want 2", g.ToMove())
|
||||
}
|
||||
if _, err := g.Pass(); err != nil { // seat 2
|
||||
t.Fatalf("seat 2 pass: %v", err)
|
||||
}
|
||||
if g.ToMove() != 1 {
|
||||
t.Errorf("to move = %d, want 1 (seat 0 skipped on wrap)", g.ToMove())
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiplayerLastActiveWins proves that as seats drop out the sole survivor
|
||||
// wins even when trailing, and resigners keep their (frozen) scores.
|
||||
func TestMultiplayerLastActiveWins(t *testing.T) {
|
||||
g := openingGameN(t, 3, DropoutRemove)
|
||||
hint, ok := g.HintView()
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
played, err := g.SubmitPlay(hint.Tiles) // seat 0 takes the lead
|
||||
if err != nil {
|
||||
t.Fatalf("seat 0 play: %v", err)
|
||||
}
|
||||
if played.Score == 0 {
|
||||
t.Fatal("opening play scored 0; pick a different seed")
|
||||
}
|
||||
if _, err := g.Pass(); err != nil { // seat 1
|
||||
t.Fatalf("seat 1 pass: %v", err)
|
||||
}
|
||||
if _, err := g.Pass(); err != nil { // seat 2
|
||||
t.Fatalf("seat 2 pass: %v", err)
|
||||
}
|
||||
if _, err := g.Resign(); err != nil { // seat 0 (leader) drops out
|
||||
t.Fatalf("seat 0 resign: %v", err)
|
||||
}
|
||||
if g.Over() {
|
||||
t.Fatal("game must continue with two active seats")
|
||||
}
|
||||
if g.ToMove() != 1 {
|
||||
t.Fatalf("to move = %d, want 1", g.ToMove())
|
||||
}
|
||||
if _, err := g.Resign(); err != nil { // seat 1 drops out, leaving only seat 2
|
||||
t.Fatalf("seat 1 resign: %v", err)
|
||||
}
|
||||
if !g.Over() || g.Reason() != EndResign {
|
||||
t.Fatalf("over=%v reason=%v, want over with resign", g.Over(), g.Reason())
|
||||
}
|
||||
res := g.Result()
|
||||
if res.Winner != 2 {
|
||||
t.Errorf("winner = %d, want 2 (sole survivor) despite trailing", res.Winner)
|
||||
}
|
||||
if g.Score(0) != played.Score {
|
||||
t.Errorf("resigner seat 0 score = %d, want frozen at %d", g.Score(0), played.Score)
|
||||
}
|
||||
if g.Score(2) != 0 {
|
||||
t.Errorf("survivor seat 2 score = %d, want 0", g.Score(2))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropoutTileDisposition proves the per-game setting governs the bag: remove
|
||||
// leaves it unchanged, return adds the leaver's full rack back.
|
||||
func TestDropoutTileDisposition(t *testing.T) {
|
||||
const seed = 7
|
||||
remove, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 3, Seed: seed, DropoutTiles: DropoutRemove})
|
||||
if err != nil {
|
||||
t.Fatalf("new remove game: %v", err)
|
||||
}
|
||||
ret, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 3, Seed: seed, DropoutTiles: DropoutReturn})
|
||||
if err != nil {
|
||||
t.Fatalf("new return game: %v", err)
|
||||
}
|
||||
bagBefore := remove.BagLen()
|
||||
if ret.BagLen() != bagBefore {
|
||||
t.Fatalf("identical seeds must start with equal bags: %d vs %d", remove.BagLen(), ret.BagLen())
|
||||
}
|
||||
rackSize := remove.rules.RackSize // seat 0 holds a full rack on the opening turn
|
||||
|
||||
if _, err := remove.Resign(); err != nil {
|
||||
t.Fatalf("remove resign: %v", err)
|
||||
}
|
||||
if _, err := ret.Resign(); err != nil {
|
||||
t.Fatalf("return resign: %v", err)
|
||||
}
|
||||
if remove.BagLen() != bagBefore {
|
||||
t.Errorf("remove: bag = %d, want unchanged %d", remove.BagLen(), bagBefore)
|
||||
}
|
||||
if ret.BagLen() != bagBefore+rackSize {
|
||||
t.Errorf("return: bag = %d, want %d (rack returned)", ret.BagLen(), bagBefore+rackSize)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResignedSeatExcludedFromWinOnScorelessEnd proves a resigned seat never wins
|
||||
// even when the game ends by the scoreless limit rather than by the resignation.
|
||||
func TestResignedSeatExcludedFromWinOnScorelessEnd(t *testing.T) {
|
||||
g := openingGameN(t, 3, DropoutRemove)
|
||||
hint, ok := g.HintView()
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
played, err := g.SubmitPlay(hint.Tiles) // seat 0 leads
|
||||
if err != nil {
|
||||
t.Fatalf("seat 0 play: %v", err)
|
||||
}
|
||||
if played.Score == 0 {
|
||||
t.Fatal("opening play scored 0; pick a different seed")
|
||||
}
|
||||
if _, err := g.Pass(); err != nil { // seat 1
|
||||
t.Fatalf("seat 1 pass: %v", err)
|
||||
}
|
||||
if _, err := g.Pass(); err != nil { // seat 2
|
||||
t.Fatalf("seat 2 pass: %v", err)
|
||||
}
|
||||
if _, err := g.Resign(); err != nil { // seat 0 drops out while leading
|
||||
t.Fatalf("seat 0 resign: %v", err)
|
||||
}
|
||||
for !g.Over() { // seats 1 and 2 pass until the six-scoreless limit ends it
|
||||
if _, err := g.Pass(); err != nil {
|
||||
t.Fatalf("pass: %v", err)
|
||||
}
|
||||
}
|
||||
if g.Reason() != EndScoreless {
|
||||
t.Fatalf("reason = %v, want scoreless", g.Reason())
|
||||
}
|
||||
if res := g.Result(); res.Winner == 0 {
|
||||
t.Error("winner = 0, but the resigned leader must be excluded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFourPlayerDropToTwoContinues proves two drop-outs in a four-player game
|
||||
// leave the remaining two playing on, skipping both resigned seats.
|
||||
func TestFourPlayerDropToTwoContinues(t *testing.T) {
|
||||
g, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 4, Seed: 3, DropoutTiles: DropoutRemove})
|
||||
if err != nil {
|
||||
t.Fatalf("new game: %v", err)
|
||||
}
|
||||
if _, err := g.Resign(); err != nil { // seat 0
|
||||
t.Fatalf("seat 0 resign: %v", err)
|
||||
}
|
||||
if g.ToMove() != 1 {
|
||||
t.Fatalf("to move = %d, want 1", g.ToMove())
|
||||
}
|
||||
if _, err := g.Resign(); err != nil { // seat 1
|
||||
t.Fatalf("seat 1 resign: %v", err)
|
||||
}
|
||||
if g.Over() {
|
||||
t.Fatal("game with two active seats must continue")
|
||||
}
|
||||
if g.ToMove() != 2 {
|
||||
t.Errorf("to move = %d, want 2", g.ToMove())
|
||||
}
|
||||
if _, err := g.Pass(); err != nil { // seat 2
|
||||
t.Fatalf("seat 2 pass: %v", err)
|
||||
}
|
||||
if g.ToMove() != 3 {
|
||||
t.Errorf("to move = %d, want 3", g.ToMove())
|
||||
}
|
||||
if _, err := g.Pass(); err != nil { // seat 3
|
||||
t.Fatalf("seat 3 pass: %v", err)
|
||||
}
|
||||
if g.ToMove() != 2 {
|
||||
t.Errorf("to move = %d, want 2 (seats 0,1 skipped)", g.ToMove())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user