Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+16
-12
@@ -1,6 +1,6 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
# Single gated pipeline for the test contour (Stage 16/17). Gitea cannot express
|
# Single gated pipeline for the test contour. Gitea cannot express
|
||||||
# cross-workflow `needs`, so the full test suite and the auto test-deploy live in
|
# cross-workflow `needs`, so the full test suite and the auto test-deploy live in
|
||||||
# one workflow.
|
# one workflow.
|
||||||
#
|
#
|
||||||
@@ -9,9 +9,9 @@ name: CI
|
|||||||
# `development` or `master` (the full test suite — the merge gate) and on a push
|
# `development` or `master` (the full test suite — the merge gate) and on a push
|
||||||
# to `development` (after a merge). The deploy job runs only for `development`
|
# to `development` (after a merge). The deploy job runs only for `development`
|
||||||
# (PR or merge), so a PR into `master` is test-only; the prod deploy is a manual
|
# (PR or merge), so a PR into `master` is test-only; the prod deploy is a manual
|
||||||
# workflow (Stage 18).
|
# workflow.
|
||||||
#
|
#
|
||||||
# Path-conditional jobs (Stage 17): `unit`/`integration`/`ui` run only when their
|
# Path-conditional jobs: `unit`/`integration`/`ui` run only when their
|
||||||
# code changed (the `changes` job decides). Because a skipped required check would
|
# code changed (the `changes` job decides). Because a skipped required check would
|
||||||
# block a merge under branch protection, the always-running `gate` job aggregates
|
# block a merge under branch protection, the always-running `gate` job aggregates
|
||||||
# their results and is the ONLY required status check; it passes when every
|
# their results and is the ONLY required status check; it passes when every
|
||||||
@@ -64,7 +64,7 @@ jobs:
|
|||||||
if [ "$files" != "__DIFF_FAILED__" ]; then
|
if [ "$files" != "__DIFF_FAILED__" ]; then
|
||||||
echo "changed files:"; echo "$files"
|
echo "changed files:"; echo "$files"
|
||||||
go=false; ui=false
|
go=false; ui=false
|
||||||
if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|go\.work)'; then go=true; fi
|
if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|loadtest/|go\.work)'; then go=true; fi
|
||||||
if echo "$files" | grep -qE '^ui/'; then ui=true; fi
|
if echo "$files" | grep -qE '^ui/'; then ui=true; fi
|
||||||
# A workflow or deploy change re-runs everything as a safety net.
|
# A workflow or deploy change re-runs everything as a safety net.
|
||||||
if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi
|
if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi
|
||||||
@@ -112,15 +112,15 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: vet
|
- name: vet
|
||||||
run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/...
|
||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/...
|
||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
env:
|
env:
|
||||||
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
|
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
|
||||||
run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/...
|
||||||
|
|
||||||
integration:
|
integration:
|
||||||
needs: changes
|
needs: changes
|
||||||
@@ -263,7 +263,7 @@ jobs:
|
|||||||
TELEGRAM_GAME_CHANNEL_ID_EN: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_EN }}
|
TELEGRAM_GAME_CHANNEL_ID_EN: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_EN }}
|
||||||
TELEGRAM_GAME_CHANNEL_ID_RU: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_RU }}
|
TELEGRAM_GAME_CHANNEL_ID_RU: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_RU }}
|
||||||
# The test contour always uses Telegram's test environment — pinned here,
|
# The test contour always uses Telegram's test environment — pinned here,
|
||||||
# not an operator variable. Stage 18's prod workflow leaves it false.
|
# not an operator variable. The prod workflow leaves it false.
|
||||||
TELEGRAM_TEST_ENV: "true"
|
TELEGRAM_TEST_ENV: "true"
|
||||||
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
||||||
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||||
@@ -298,17 +298,21 @@ jobs:
|
|||||||
# pick up the fresh config.
|
# pick up the fresh config.
|
||||||
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
|
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
|
||||||
|
|
||||||
- name: Probe the gateway through caddy
|
- name: Probe the landing and the gateway through caddy
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
|
# Two probes through the contour caddy: "/" is the static
|
||||||
|
# landing container, "/app/" is the gateway-served SPA shell.
|
||||||
for i in $(seq 1 20); do
|
for i in $(seq 1 20); do
|
||||||
if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/; then
|
if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/ &&
|
||||||
echo "healthy: GET http://scrabble/"
|
docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/app/; then
|
||||||
|
echo "healthy: GET http://scrabble/ (landing) + /app/ (gateway)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
sleep 3
|
sleep 3
|
||||||
done
|
done
|
||||||
echo "probe failed; recent gateway logs:"
|
echo "probe failed; recent landing + gateway logs:"
|
||||||
|
docker logs --tail 50 scrabble-landing || true
|
||||||
docker logs --tail 50 scrabble-gateway || true
|
docker logs --tail 50 scrabble-gateway || true
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
|
|||||||
@@ -16,3 +16,6 @@
|
|||||||
# Local, unstaged env overrides
|
# Local, unstaged env overrides
|
||||||
**/.env.local
|
**/.env.local
|
||||||
**/.env.*.local
|
**/.env.*.local
|
||||||
|
|
||||||
|
# Claude Code harness runtime artifacts
|
||||||
|
.claude/scheduled_tasks.lock
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ conversation memory — is the source of continuity. Keep it that way.
|
|||||||
|
|
||||||
- [`PLAN.md`](PLAN.md) — staged plan + **stage tracker** + per-stage *open
|
- [`PLAN.md`](PLAN.md) — staged plan + **stage tracker** + per-stage *open
|
||||||
details to interview*.
|
details to interview*.
|
||||||
|
- [`PRERELEASE.md`](PRERELEASE.md) — pre-release hardening tracker (phases R1–R7
|
||||||
|
before Stage 18); same per-phase *interview + bake-back* discipline as `PLAN.md`.
|
||||||
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport,
|
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport,
|
||||||
security, the decision record. Always describes current state.
|
security, the decision record. Always describes current state.
|
||||||
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)
|
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)
|
||||||
@@ -124,8 +126,9 @@ backend/ # module scrabble/backend
|
|||||||
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
|
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
|
||||||
gateway/ ui/ pkg/ # added by their stages
|
gateway/ ui/ pkg/ # added by their stages
|
||||||
platform/telegram/ # Telegram connector side-service (Stage 9): bot + gRPC API
|
platform/telegram/ # Telegram connector side-service (Stage 9): bot + gRPC API
|
||||||
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile # multi-stage distroless (Stage 16)
|
loadtest/ # module scrabble/loadtest: the pre-release stress harness (R2)
|
||||||
deploy/ # docker-compose + caddy + otelcol/prometheus/tempo/grafana (Stage 16)
|
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless (Stage 16; loadtest R2); gateway/Dockerfile also has the `landing` target (R3)
|
||||||
|
deploy/ # docker-compose (per-service limits, R7) + caddy + landing + otelcol (OTLP + docker_stats per-container metrics) + prometheus/tempo/grafana + postgres_exporter
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build & test
|
## Build & test
|
||||||
@@ -141,8 +144,9 @@ go run ./backend/cmd/backend # /healthz, /readyz on :8080
|
|||||||
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+)
|
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+)
|
||||||
pnpm start # UI mock mode: lobby -> game, no backend
|
pnpm start # UI mock mode: lobby -> game, no backend
|
||||||
|
|
||||||
docker build -f backend/Dockerfile -t scrabble-backend . # images (Stage 16); gateway embeds the UI
|
docker build -f backend/Dockerfile -t scrabble-backend . # images (Stage 16); gateway embeds the SPA
|
||||||
docker build -f gateway/Dockerfile -t scrabble-gateway .
|
docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway .
|
||||||
|
docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing (R3)
|
||||||
docker compose -f deploy/docker-compose.yml config # validate the full contour
|
docker compose -f deploy/docker-compose.yml config # validate the full contour
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1387,6 +1387,94 @@ provided cert) at the contour caddy; prod VPN; rollback.
|
|||||||
`kind='message'`, the source via a SQL `CASE`), reusing the now-exported `account.LikePattern`
|
`kind='message'`, the source via a SQL `CASE`), reusing the now-exported `account.LikePattern`
|
||||||
glob helper. Owner decisions: messages only (no nudges), separate name/ext masks (matching the
|
glob helper. Owner decisions: messages only (no nudges), separate name/ext masks (matching the
|
||||||
Users section), a top-level nav entry plus the card deep-links.
|
Users section), a top-level nav entry plus the card deep-links.
|
||||||
|
- **Round-6 follow-up — UX polish + client-IP fix (this PR):**
|
||||||
|
- **Client IP through the edge.** The compose caddy now sets `trusted_proxies static
|
||||||
|
private_ranges`, so the real client IP survives the host-caddy hop (it was logging the
|
||||||
|
docker-network caddy hop `172.18.0.x` for chat moderation, and bucketing the gateway's
|
||||||
|
per-IP rate limiter on it). Correct + spoof-safe in **both** contours (prod has no host
|
||||||
|
caddy → public clients untrusted → real peer used). `peerIP` unit-tested.
|
||||||
|
- **Ad banner** gated **off** behind a compile-time `SHOW_AD_BANNER=false` in `Screen.svelte`
|
||||||
|
— the `{#if}` branch, the `AdBanner` import and `banner.ts` are tree-shaken out of the prod
|
||||||
|
bundle (code kept for post-release polish).
|
||||||
|
- **Landing** Telegram entry is now just the **64px logo** (clickable, no button/caption).
|
||||||
|
- **TG-fullscreen header** reworked again: title + menu are one **centred pair** (hamburger
|
||||||
|
right of the title) pinned to the **bottom** of the TG nav band, lining up with Telegram's
|
||||||
|
own controls.
|
||||||
|
- **Edge-swipe back** (`Screen.svelte`): a left-edge rightward drag navigates to `back`
|
||||||
|
(touch/pen only, armed only from ≤24px so it never fights the board's gestures; skipped
|
||||||
|
inside Telegram, which has its own back).
|
||||||
|
- **Chat + word-check are now their own routed screens** (`/game/:id/chat`,
|
||||||
|
`/game/:id/check`, header back to the game, no tab-bar) so the soft keyboard simply resizes
|
||||||
|
the **visible viewport** — mirrored into a `--vvh` CSS var the `Screen` height uses, since
|
||||||
|
iOS doesn't shrink `dvh` for the keyboard — with the input pinned to the bottom: no modal
|
||||||
|
relayout, no page jump (this superseded a first bottom-sheet-`Modal` attempt). New chat
|
||||||
|
messages raise an **unread badge** on the in-game hamburger + the Chat menu row (per game,
|
||||||
|
cleared on open), mirroring the lobby badge; the chat screen is routable for a future
|
||||||
|
Telegram deep-link. The TG-fullscreen header was also finalised over a couple of review
|
||||||
|
passes: title + menu as a centred pair inside Telegram's nav band (between `--tg-safe-top`
|
||||||
|
and `--tg-content-top`), with a small padding bump so the native controls aren't flush.
|
||||||
|
- **Tests backfilled** for the merged round-6 work: e2e for the in-game "✓ in friends" item
|
||||||
|
and a board→board tile relocation; codec units for `last_activity_unix` + `OutgoingRequestList`.
|
||||||
|
- **Hide finished games (#5, shipped):** a player can remove a finished game from their own
|
||||||
|
*my games* list — **per-account, finished-only and irreversible** (the game stays for the
|
||||||
|
other players; there is no un-hide). On a finished row a **swipe-left** (touch) or a tap on
|
||||||
|
its **kebab ⋮** (the desktop affordance) reveals a **❌** that hides it; active rows carry an
|
||||||
|
inert **›** chevron purely to keep the right-edge icons aligned. New table
|
||||||
|
`game_hidden(account_id, game_id)` + migration `00012`; `ListGamesForAccount` filters the
|
||||||
|
hidden set; `POST /api/v1/user/games/:id/hide` behind the `game.hide` edge op (reusing
|
||||||
|
`GameActionRequest` → an `Ack`); the lobby drops the card optimistically and keeps the cache
|
||||||
|
in sync. Covered by an integration test (active→`ErrGameActive`, outsider→`ErrNotAPlayer`,
|
||||||
|
per-account visibility, idempotent), a gateway transcode test, and a mock e2e (kebab → ❌).
|
||||||
|
- **Enriched out-of-app push (#4, shipped):** the "your turn" Telegram notification now names
|
||||||
|
the opponent and recaps their last move — voiced as the opponent, `«{name}: my move —
|
||||||
|
«WORD». Score 120:95»` for a scoring play, or a short "swapped / passed, your turn" — and a
|
||||||
|
new **game-over** notification reports the result + final score when a game ends (any path:
|
||||||
|
closing play, all-pass, resign, timeout). Scores are **recipient-first** (the reader's
|
||||||
|
score leads), 2- to 4-player (`120:95:80`). `YourTurnEvent` gained `opponent_name`/
|
||||||
|
`last_action`/`last_word`/`score_line` (appended, backward-compatible) and a new
|
||||||
|
`GameOverEvent` carries `result`/`score_line`; both emit per-recipient from the game commit
|
||||||
|
(`emitMove`), join the out-of-app whitelist, and render per language (en/ru) in the Telegram
|
||||||
|
connector. The backend resolves the mover's display name (the score line and result are
|
||||||
|
built per recipient). Covered by notify round-trip, emit, connector-render (en/ru) and
|
||||||
|
routing tests.
|
||||||
|
- **No-op drain of all bot updates (#1, investigated — no change):** confirmed the Telegram bot
|
||||||
|
already long-polls and the library advances the offset for every delivered update (the default
|
||||||
|
handler no-ops anything but `/start`), so the queue never piles up. Telegram withholds only
|
||||||
|
`message_reaction` / `message_reaction_count` / `chat_member` by default, and — being
|
||||||
|
unrequested — those never queue either. Owner chose to leave `allowed_updates` at the default
|
||||||
|
(zero risk) rather than hand-maintain a full whitelist (a wrong/stale type would break
|
||||||
|
`getUpdates` entirely); a specific type will be requested when a concrete handler needs it.
|
||||||
|
- **Reconnect UX — "Connecting…" + soft-disable (#2, shipped):** connectivity failures became
|
||||||
|
**state, not toasts**. A global `online` signal (`lib/connection.svelte.ts`) flips on a
|
||||||
|
transport `unavailable` / `rate_limited` (and on the live stream's drop), driving a pure-CSS
|
||||||
|
header **spinner + "Connecting…"** in place of the title and softly disabling the in-game
|
||||||
|
server actions (commit / exchange / pass / hint; local board/rack/reset stay live). The
|
||||||
|
transport (`exec`) **auto-retries with capped backoff** — every op on a rate-limit, **reads
|
||||||
|
only** on `unavailable` (mutations are not blindly re-sent; their buttons are disabled while
|
||||||
|
offline, so the player re-issues on reconnect — the idempotency caveat the owner accepted). A
|
||||||
|
reachability **watcher** (`profile.get` probe) and any successful traffic clear the signal; the
|
||||||
|
old red `error.unavailable` toast is gone (the indicator replaces it). A server-data screen
|
||||||
|
still **opens with the spinner** and fills on reconnect (global indicator + read auto-retry),
|
||||||
|
so navigation is never dead. Pure policy unit-tested (`retry.ts`); a mock-only `window.__conn`
|
||||||
|
hook drives a Chromium+WebKit e2e (indicator appears offline, the action disables, both clear
|
||||||
|
on reconnect). The visual soft-disable spans the server-action buttons across the app: the
|
||||||
|
game bar (commit/exchange/pass/hint/resign), chat send + nudge, profile save / link / merge,
|
||||||
|
friends (request/respond/unfriend/block/code), New Game (auto-match + invite) and the lobby
|
||||||
|
hide ❌; purely local controls (board/rack/reset, menu, navigation, settings) stay live.
|
||||||
|
- **Nudge defects (owner-reported, shipped):** two from a live contour game.
|
||||||
|
**(A) Frequency** — the robot's proactive nudge fired hourly for 12 h+ (12 h idle threshold +
|
||||||
|
the 1 h cooldown, uncapped). Replaced with a **lengthening, randomized schedule** in the
|
||||||
|
robot strategy/driver: the first nudge ~60-90 min into the human's turn, each later gap
|
||||||
|
growing toward 1-6 h (the gap is a uniform sample in `[60 min, ceil]`, `ceil` ramping from
|
||||||
|
90 min to 6 h over 12 h of idle, measured from the previous nudge), so a long wait gets a
|
||||||
|
handful of increasingly-spaced reminders. **(B) Language** — the out-of-app push routed by
|
||||||
|
the recipient's **global `service_language`** (last-login-wins), so after re-logging through
|
||||||
|
the RU bot an English game's nudges came from the RU bot. Now a game push (your_turn,
|
||||||
|
game_over, nudge, match_found) carries the **game's own language** (`engine.Variant.Language`)
|
||||||
|
on `push.Event`, and the gateway routes by it (falling back to `service_language` for
|
||||||
|
non-game pushes); the New-Game variant-gating guarantees deliverability. Covered by the
|
||||||
|
`proactiveNudgeGap` unit test, the retimed `TestRobotProactiveNudge`, `TestVariantLanguage`,
|
||||||
|
emit (`your_turn`/`game_over` language) and a `TestNudgeRoutedByGameLanguage` integration test.
|
||||||
|
|
||||||
## Deferred TODOs (cross-stage)
|
## Deferred TODOs (cross-stage)
|
||||||
|
|
||||||
|
|||||||
+415
@@ -0,0 +1,415 @@
|
|||||||
|
# Pre-release plan — hardening before Stage 18
|
||||||
|
|
||||||
|
Living tracker for the pre-release hardening pass that runs **before Stage 18** (the
|
||||||
|
prod cutover). Same discipline as [`PLAN.md`](PLAN.md): one phase per session,
|
||||||
|
**interview the owner on the open details** at the start of each phase, bake every
|
||||||
|
decision back into `PLAN.md` / `docs/` / the affected `README`s / Go Doc comments in
|
||||||
|
the **same** PR, get CI green, then mark the phase done. Phases run as
|
||||||
|
`feature/* → development` PRs (the Stage 16 branch model); the owner approves+merges.
|
||||||
|
|
||||||
|
**Why now:** the system is feature-complete through Stage 17 and the test contour is
|
||||||
|
green, but there is **no prod data yet** — schema, wire labels and the dictionary
|
||||||
|
layout can still change for free. These phases spend that one-time freedom and harden
|
||||||
|
the edge before prod. Each phase maps back to the owner's raw pre-release TODO list
|
||||||
|
(numbers in the tracker).
|
||||||
|
|
||||||
|
## Phase tracker
|
||||||
|
|
||||||
|
| # | Phase | Raw TODOs | Status |
|
||||||
|
|---|-------|-----------|--------|
|
||||||
|
| R1 | Schema & naming reset | 1 + 10 | **done** |
|
||||||
|
| R2 | Stress harness + contour observability + early run | 9a | **done** |
|
||||||
|
| R3 | Edge hardening | 2 + 8 + 3 | **done** |
|
||||||
|
| R4 | Push enrichment + kill the last poll | 4 + 5 | **done** |
|
||||||
|
| R5 | Bundle slimming | 6 | **done** |
|
||||||
|
| R6 | Refactor + docs reconciliation + de-staging | 7 | **done** |
|
||||||
|
| R7 | Final stress run + tuning | 9b | **done** |
|
||||||
|
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
||||||
|
|
||||||
|
## Key findings (these reshaped the raw list — read before starting a phase)
|
||||||
|
|
||||||
|
- **R1 (TODO 1 + 10) is one cheap moment, now.** Squashing the 12 goose migrations is
|
||||||
|
safe precisely because there is no prod data and the contour DB is wiped. Folding the
|
||||||
|
new variant labels (`scrabble_ru`/`scrabble_en`/`erudit_ru`) into that single baseline
|
||||||
|
makes the rename need **no data migration and no back-compat mapping**. Today's labels
|
||||||
|
(`english`/`russian_scrabble`/`erudit`) are persisted in `games.variant`,
|
||||||
|
`game_invitations.variant`, in `pkg/fbs` and the UI — ~100 files, but a mechanical sweep
|
||||||
|
on a clean DB.
|
||||||
|
- **R4 (TODO 4 + 5): the app is already push-first.** Game state refreshes on
|
||||||
|
`your_turn`/`opponent_moved`, the lobby on `notify`, chat on `chat_message`. The **only**
|
||||||
|
genuine periodic server poll is `lobby.poll` (matchmaking, 2.5 s,
|
||||||
|
`ui/src/screens/NewGame.svelte`). What remains is killing that one poll **and** enriching
|
||||||
|
push events to carry payloads so the UI stops re-fetching after each signal.
|
||||||
|
- **R3 (TODO 2): identity forgery is already mitigated.** Identity is always derived from
|
||||||
|
the session (`Authorization: Bearer` → `X-User-ID`); the client cannot inject identity,
|
||||||
|
the backend re-validates resource ownership, Telegram initData is HMAC-checked. The real
|
||||||
|
gaps are a missing **request-body size limit** (cheap DoS) and **invisible rate-limit
|
||||||
|
rejections** (no log/metric/admin view — that is TODO 8). Static landing serving is **not**
|
||||||
|
covered by the gateway token bucket (it only guards `Execute`).
|
||||||
|
- **R6 (TODO 7) scale:** ~431 `Stage N` references across ~104 files (incl. the file name
|
||||||
|
`backend/internal/inttest/stage6_test.go`). Code is the source of truth; `docs/` describe
|
||||||
|
current state; `PLAN.md` keeps the decision history.
|
||||||
|
|
||||||
|
## Locked decisions (owner interview)
|
||||||
|
|
||||||
|
- **Stress test (TODO 9):** **early + final** runs. Driver = **edge protocol** (Connect/FB
|
||||||
|
through the gateway, moves generated by the solver) **plus a separate gateway-hammer**
|
||||||
|
saturation test. Pacing = **realistic (under limits) + saturation (ramp to the knee)**.
|
||||||
|
Resource metrics = **add cAdvisor + postgres_exporter to the contour** (today only
|
||||||
|
Go-runtime metrics exist). The harness stays in the repo for repeats.
|
||||||
|
- **Push (TODO 4 + 5):** **both** — kill `lobby.poll` (use the existing `match_found`, keep
|
||||||
|
poll as the ws-down fallback) **and** enrich push events with payloads.
|
||||||
|
- **Refactor (TODO 7):** **hygiene + structural changes by a reviewed list** —
|
||||||
|
behaviour-preserving, test-gated, contentious items surfaced to the owner before applying.
|
||||||
|
- **Landing (TODO 3):** **separate static container** behind the project caddy
|
||||||
|
(`/` → landing, `/app/` + `/telegram/` → gateway); drop `landing.html` from the gateway
|
||||||
|
`go:embed`.
|
||||||
|
- **Rate-abuse (TODO 8):** metric + Grafana + admin view **plus a conservative auto-flag** —
|
||||||
|
a *soft, reversible* "suspected high-rate" marker for operator review, tunable threshold,
|
||||||
|
**no auto-ban**.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
Each phase: read this tracker + the relevant `docs/`, **interview the owner on the open
|
||||||
|
details below**, implement within scope, then update the tracker + docs/code and get CI
|
||||||
|
green before marking it done.
|
||||||
|
|
||||||
|
### R1 — Schema & naming reset *(TODO 1 + 10)* — first
|
||||||
|
Squash `backend/internal/postgres/migrations/00001..00012` into one `00001_baseline.sql`
|
||||||
|
(method: `pg_dump --schema-only` from a fully-migrated DB → wrap as the goose baseline →
|
||||||
|
prove a fresh migrate yields a schema identical to the 12-migration chain via the
|
||||||
|
integration suite → delete the old files; keep goose). Bake the new variant labels into the
|
||||||
|
baseline. Propagate `scrabble_ru`/`scrabble_en`/`erudit_ru` through the backend
|
||||||
|
(`engine.Variant`/`ParseVariant`, `registry.dictFiles`, the CHECK values), the wire
|
||||||
|
(`pkg/fbs` `variant:string`, regenerate FB) and the UI (`lib/model.ts` union, `variants.ts`,
|
||||||
|
fixtures, premium/alphabet keys, tests); i18n display keys stay display-only. Tidy
|
||||||
|
`../scrabble-dictionary` to a single source→dawg build point and align the dawg artifact
|
||||||
|
names to the new labels (crosses into `../scrabble-solver`'s committed fixtures — keep them
|
||||||
|
byte-identical). After merge, **wipe the contour DB** (drop the volume) so it re-provisions
|
||||||
|
on the next deploy.
|
||||||
|
- Critical files: `backend/internal/postgres/migrations/`,
|
||||||
|
`backend/internal/engine/{engine,registry}.go`, `pkg/fbs/scrabble.fbs`,
|
||||||
|
`ui/src/lib/{model,variants}.ts`, `../scrabble-dictionary/{Makefile,cmd/builddict,…}`.
|
||||||
|
- Open details to interview: the exact dawg filename scheme; whether the dict-repo tidy is
|
||||||
|
one PR or split; how to script the contour DB wipe in the deploy.
|
||||||
|
|
||||||
|
### R2 — Stress harness + contour observability + early run *(TODO 9, part 1)*
|
||||||
|
Build the reusable load harness as a new `loadtest` module in `go.work` (reuses `pkg/fbs`,
|
||||||
|
`connect-go`, and `scrabble-solver` for legal-move generation): a seeder that inserts
|
||||||
|
**1000 guest + 10000 durable** accounts with pre-created sessions (token hashes) directly in
|
||||||
|
the DB and hands the plaintext tokens to the client; a driver that runs N virtual users,
|
||||||
|
each in 3–5 concurrent 2–4-player games, exercising submit-play / pass / exchange / nudge /
|
||||||
|
chat / check-word / draft-move / profile-save through the **edge protocol**, in
|
||||||
|
**realistic** (under rate limits) and **saturation** (ramp) modes; plus a separate
|
||||||
|
**gateway-hammer** that deliberately exceeds limits to verify the limiter holds and measure
|
||||||
|
its cost. Add **cAdvisor + postgres_exporter** to `deploy/docker-compose.yml` and a Grafana
|
||||||
|
resource dashboard. Run the **early pass** against the freshly-wiped contour; produce a
|
||||||
|
**trip report** (logic/concurrency bugs + a resource baseline) that feeds R3 and R6.
|
||||||
|
- Critical files: new `loadtest/`, `deploy/docker-compose.yml`, `deploy/observability/*`,
|
||||||
|
`docs/TESTING.md`.
|
||||||
|
- Open details: the scale ramp steps; the move-selection policy (a mid-ranked solver move
|
||||||
|
for realistic game progress); run duration; the pass/fail bar.
|
||||||
|
|
||||||
|
### R3 — Edge hardening *(TODO 2 + 8 + 3)*
|
||||||
|
Add a **request-body size cap** at the gateway h2c mux / `Execute` (e.g. ~1 MB). Add
|
||||||
|
**rate-limit observability**: a `gateway_rate_limited_total{class}` counter + a structured
|
||||||
|
log per rejection; an **aggregate** Grafana panel (request rate + rejection rate — spikes
|
||||||
|
visible without per-user label cardinality, honouring the Stage 12/17 discipline); an
|
||||||
|
**admin-console view** of recently throttled users/IPs (in-memory ring buffer, single-
|
||||||
|
instance, reset-on-restart, like the `active_users` gauge). Add the **conservative
|
||||||
|
auto-flag**: when a user is *sustained*-throttled past a tunable threshold, set a soft,
|
||||||
|
reversible `account.flagged_high_rate_at` marker (baked into the R1 baseline) surfaced in the
|
||||||
|
admin user list/detail — **no auto-ban**; the operator clears it. Split the **landing** into
|
||||||
|
its own static container (`deploy/` + a Caddyfile route `/` → landing) and drop
|
||||||
|
`landing.html` from the gateway `go:embed`.
|
||||||
|
- Critical files: `gateway/internal/connectsrv/server.go`, `gateway/internal/ratelimit/`,
|
||||||
|
`gateway/internal/connectsrv/metrics.go`, `backend/internal/adminconsole/`,
|
||||||
|
`deploy/caddy/Caddyfile`, `deploy/docker-compose.yml`, `gateway/internal/webui/`.
|
||||||
|
- Open details: the auto-flag threshold/window + whether the marker is persisted vs
|
||||||
|
in-memory; the landing image base (caddy vs nginx).
|
||||||
|
|
||||||
|
### R4 — Push enrichment + kill the last poll *(TODO 4 + 5)*
|
||||||
|
Replace `lobby.poll` with the existing `match_found` push (keep the poll as a ws-down
|
||||||
|
fallback). Enrich `your_turn`/`opponent_moved`/`notify` to carry the state payload so the UI
|
||||||
|
renders from the event without a follow-up `game.state` (removes the lobby↔game nav latency
|
||||||
|
the owner noticed). Wire-contract change: `pkg/fbs` event payloads → backend `notify` emit →
|
||||||
|
UI stream consumers (`ui/src/lib/app.svelte.ts`), with the per-game cache as the landing
|
||||||
|
spot; regenerate FB.
|
||||||
|
- Critical files: `pkg/fbs/scrabble.fbs`, `backend/internal/notify/events.go`,
|
||||||
|
`ui/src/lib/{app.svelte,transport}.ts`, `ui/src/screens/NewGame.svelte`.
|
||||||
|
- Open details: which events carry full vs delta payloads; the fallback-poll cadence when the
|
||||||
|
stream is down.
|
||||||
|
|
||||||
|
### R5 — Bundle slimming *(TODO 6)* — done
|
||||||
|
Analysed the bundle against the 100 KB-gzip budget; **no code slimming was warranted**, and the
|
||||||
|
budget metric was retargeted to measure the app correctly. The build already minifies +
|
||||||
|
tree-shakes; the dominant cost is the Connect/FlatBuffers transport runtime + generated bindings
|
||||||
|
+ the Svelte runtime (≈⅔ of `main`'s source is third-party/generated) — irreducible within scope.
|
||||||
|
**Lazy-loading was rejected**: `bundle-size.mjs` sums every emitted chunk, so code-splitting yields
|
||||||
|
no total-size win and adds request latency (+N gateway fetches on first navigation to a split
|
||||||
|
screen). i18n lazy-load was skipped (the catalogs are a sliver of a Svelte-runtime-dominated shared
|
||||||
|
chunk, and `en` must stay bundled as the `MessageKey` type source + fallback). Instead,
|
||||||
|
`bundle-size.mjs` now measures **per HTML entry**, with three independent gates on the natural chunk
|
||||||
|
boundaries — **app entry ≤ 100 KB, the Svelte+i18n shared chunk ≤ 30 KB, the landing's own chunk
|
||||||
|
≤ 5 KB** — since the app's real payload is its entry chunk plus the shared chunk (≈97 KB), while the
|
||||||
|
landing (≈24 KB) is reported separately and kept minimal. Same CLI + exit-code contract, so the CI
|
||||||
|
step is unchanged.
|
||||||
|
- Critical files: `ui/scripts/bundle-size.mjs`; no app code changed.
|
||||||
|
|
||||||
|
### R6 — Refactor + docs reconciliation + de-staging *(TODO 7)* — done
|
||||||
|
Behaviour-preserving only. Three separable, separately-committed passes: (a) mechanical
|
||||||
|
**de-staging** — remove `Stage N`/`TODO-N` references from code, comments and service
|
||||||
|
READMEs (rename `stage6_test.go`); (b) **docs↔code reconciliation** — reconcile
|
||||||
|
`docs/ARCHITECTURE.md` / `docs/FUNCTIONAL.md`(+`_ru`) against the code-as-truth, fixing drift
|
||||||
|
and Go Doc comments; (c) **structural changes by a reviewed list** — surface a list of
|
||||||
|
proposed optimizations / test-suite consolidations to the owner, apply only the approved,
|
||||||
|
behaviour-preserving, test-gated ones. The full suite + the final stress run (R7) are the
|
||||||
|
regression gate. Incorporates the early-run (R2) bug fixes not already shipped.
|
||||||
|
- Open details: the structural-changes list itself (owner-approved before applying); the test
|
||||||
|
consolidation targets.
|
||||||
|
|
||||||
|
### R7 — Final stress run + tuning *(TODO 9, part 2)* — done
|
||||||
|
Re-run the R2 harness against the final, refactored system on a clean contour; analyse
|
||||||
|
resource consumption across **all** components (gateway, backend, Postgres, the
|
||||||
|
metrics/observability stack, docker log volume) and agree the tuning (pool sizes, rate
|
||||||
|
limits, cache TTLs, container limits, GOMAXPROCS, log levels). Apply the agreed tuning; record
|
||||||
|
the methodology + results in the repo.
|
||||||
|
|
||||||
|
→ **Stage 18** (prod contour) then proceeds per [`PLAN.md`](PLAN.md).
|
||||||
|
|
||||||
|
## Sequencing rationale
|
||||||
|
|
||||||
|
`R1` first (cheapest now; everything builds on the final schema/naming and the stress test
|
||||||
|
must run against it). `R2` builds the harness and runs the **early** pass to surface bugs and
|
||||||
|
a resource baseline that feed `R3` and `R6`. `R3`/`R4`/`R5` harden and improve the system.
|
||||||
|
`R6` (de-stage + reconcile + structural) runs near the end so it sweeps settled code once and
|
||||||
|
benefits from all accumulated bug knowledge. `R7` validates the final system and tunes it.
|
||||||
|
Then Stage 18.
|
||||||
|
|
||||||
|
## Regression-safety discipline (cross-cutting)
|
||||||
|
|
||||||
|
- Every phase is a `feature/* → development` PR; CI (`unit` + `integration` + `ui` behind the
|
||||||
|
`CI / gate` check) must be green before the owner merges; watch the post-merge contour
|
||||||
|
deploy with `gitea-ci-watch.py`.
|
||||||
|
- `R6` structural changes are behaviour-preserving, test-gated, and split from the mechanical
|
||||||
|
sweeps; contentious items are owner-approved first.
|
||||||
|
- The two stress runs (`R2` early, `R7` final) are the system-level regression gate.
|
||||||
|
|
||||||
|
## Verification (per phase)
|
||||||
|
|
||||||
|
- `go build ./<module>/...`, `go vet`, `gofmt -l .` clean, `go test -count=1 ./<module>/...`;
|
||||||
|
UI: `pnpm check && pnpm test:unit && pnpm build`; the integration suite
|
||||||
|
(`-tags integration`) for DB/schema changes; `docker compose config` for deploy changes;
|
||||||
|
green CI on the PR + a healthy contour deploy.
|
||||||
|
- `R1`: prove the squashed baseline yields a schema identical to the 12-migration chain
|
||||||
|
(integration suite on a fresh DB) **before** deleting the old files.
|
||||||
|
- `R2`/`R7`: the harness runs end-to-end against the contour; the trip report lists concrete
|
||||||
|
defects + a resource profile from the Grafana cAdvisor/postgres_exporter panels.
|
||||||
|
|
||||||
|
## Refinements logged during implementation
|
||||||
|
|
||||||
|
- **R1** (interview + implementation):
|
||||||
|
- **Variant labels** `english`/`russian_scrabble`/`erudit` → **`scrabble_en`/`scrabble_ru`/`erudit_ru`**
|
||||||
|
across the backend (`engine.Variant.String`/`ParseVariant`; the `games`/`game_invitations` `variant`
|
||||||
|
CHECK in the baseline; GCG `#lexicon` and the `variant` metric attribute both flow from `String`),
|
||||||
|
the wire (`pkg/fbs` `variant` is a `string` field — values change with **no FlatBuffers regen**) and
|
||||||
|
the UI (`model.ts` union, `variants.ts` records, `codec`/`premiums`/mocks/tests, the admin
|
||||||
|
`dictionary.gohtml`). **Kept:** the Go enum identifiers (`VariantEnglish`…, internal) and the i18n
|
||||||
|
display keys (`new.english`/`new.russian`/`new.erudit`, display-only). `complaints.variant` stays
|
||||||
|
free-text (no CHECK, as before).
|
||||||
|
- **dawg filenames kept descriptive** (`en_sowpods`/`ru_scrabble`/`ru_erudit`) — only the registry's
|
||||||
|
`Variant` key carries the rename, so `registry.go`, the published `scrabble-solver` fixtures and the
|
||||||
|
dictionary release artifact are untouched (decouples the three repos).
|
||||||
|
- **Migrations squashed** 12 → one hand-written `00001_baseline.sql`. Verified by a
|
||||||
|
`pg_dump --schema-only` diff (the chain vs the baseline are **identical** but for the two intended
|
||||||
|
variant-CHECK values) plus the green integration suite. **No data migration** (no production data).
|
||||||
|
- **Done (cross-repo + contour):** the **`scrabble-dictionary` tidy** merged (PR #2) and was re-cut as
|
||||||
|
the **byte-identical `v1.0.1`** release for clean provenance (the backend stays on `v1.0.0` — same
|
||||||
|
bytes, no rewire; the backend pulls a version-pinned release artifact, not master). Post-merge the
|
||||||
|
contour `backend` schema was wiped (`DROP SCHEMA backend CASCADE` + restart, not a volume drop) and
|
||||||
|
re-migrated to the baseline — verified the new variant CHECK (`scrabble_en/scrabble_ru/erudit_ru`),
|
||||||
|
`games`=0 and a clean boot.
|
||||||
|
|
||||||
|
- **R2** (interview + implementation):
|
||||||
|
- **Locked decisions:** game assembly via **invitations** (real path, no robots; not direct game-row
|
||||||
|
inserts); **moderate** ramp **50 → 200 → 500** at 10 min/step; **diagnostic** pass bar (no SLO gate);
|
||||||
|
run as a **one-shot container on `scrabble-internal`** in this PR.
|
||||||
|
- **Harness** = new `scrabble/loadtest` module (`use ./loadtest` + a `replace scrabble/gateway` for the
|
||||||
|
dot-free edge-proto import). It seeds 1000 guest + 10000 durable accounts + sessions **directly in
|
||||||
|
Postgres** (token hash mirrors `backend/internal/session`), drives players over the **edge protocol**,
|
||||||
|
generates **mid-ranked legal moves locally** with the embedded `scrabble-solver` by replaying
|
||||||
|
`game.history` (the edge carries no board — mirrors `engine.ReplayBoard` via the public API), and a
|
||||||
|
**gateway-hammer**. Compact CLI (`run` / `cleanup`), distroless Dockerfile (DAWGs baked), Go unit tests.
|
||||||
|
- **Adding the module broke the other images' builds** — backend/gateway/telegram Dockerfiles reduce the
|
||||||
|
workspace but still referenced `./loadtest` (not in their context); each now also
|
||||||
|
`-dropuse=./loadtest` (backend/telegram additionally `-dropreplace` the gateway replace). Caught by the
|
||||||
|
first deploy run; verified by building all four images.
|
||||||
|
- **Harness payload fixes found by the smoke pass:** the draft DTO's `rack_order` is a string (was sent
|
||||||
|
as `[]` → `bad_request`); the display-name validator forbids digits/colons, so the cleanup marker
|
||||||
|
became a letters-only `Zzloadtest` so `profile.update` resends the seeded name. `chat_not_your_turn` /
|
||||||
|
`nudge_own_turn` are **by-design** turn gates, correctly exercised.
|
||||||
|
- **Observability:** added **cAdvisor + postgres_exporter** + the **Scrabble — Resources** dashboard +
|
||||||
|
two Prometheus jobs. **Finding:** cAdvisor yields only the root cgroup on the contour host (separate
|
||||||
|
XFS `/var/lib/docker` breaks its layer-ID resolution — the existing galaxy deploy has the same limit),
|
||||||
|
so per-container CPU/RSS for the early pass was captured via `docker stats`. **R7:** adopt the otelcol
|
||||||
|
`docker_stats` receiver (already the contrib image) for per-container metrics in Grafana.
|
||||||
|
- **Early run (2026-06-09):** ramped clean to 500 players, no crash/deadlock, cleanup removed all 11000
|
||||||
|
accounts. 1.2 M edge calls, 48 870 plays, 2 798 games finished; the per-user limiter held under the
|
||||||
|
hammer (99.97 % rejected, p99 2 ms). **Top finding:** ~14 % `transport_error` on `game.state` at 500
|
||||||
|
players, under CPU saturation (backend/gateway/Postgres each ~1 core) and amplified by the harness's
|
||||||
|
single shared `http2.Transport`; the harness itself peaked at 86 % of a core on the same host, so the
|
||||||
|
figures are pessimistic. Full trip report in [`../loadtest/REPORT-R2.md`](../loadtest/REPORT-R2.md);
|
||||||
|
it feeds R3 (h2c `MaxConcurrentStreams`/timeouts, body-size cap), R6 and R7 (per-player transports,
|
||||||
|
separate hardware, pool/limit sizing).
|
||||||
|
- **CI:** `./loadtest/...` added to the path filter + vet/build/test; `go.work.sum` carries the new deps.
|
||||||
|
|
||||||
|
- **R3** (interview + implementation):
|
||||||
|
- **Locked decisions:** the flag column lands by **editing the R1 baseline** (+ a contour schema
|
||||||
|
wipe after merge — no migration chain accrues before prod); auto-flag defaults **1000 rejected /
|
||||||
|
10 min** (`BACKEND_HIGHRATE_FLAG_THRESHOLD`/`_WINDOW`, rolling window, set-once, operator clears,
|
||||||
|
no auto-ban); landing image = **caddy:2-alpine**; throttle data flows **gateway → backend** (a
|
||||||
|
30 s per-key summary POST to the new `/api/v1/internal/ratelimit/report`, the existing trusted
|
||||||
|
direction) with the episode window + flag rule in the backend (`internal/ratewatch`); rejection
|
||||||
|
logging = **Warn summary per key per window + Debug per rejection** — a deliberate deviation from
|
||||||
|
the phase's "structured log per rejection" (the R2 hammer would have logged ~522k lines in
|
||||||
|
minutes); all three R2-report tails included (explicit h2c sizing, the session-resolve failure
|
||||||
|
cause at Warn, reviving the admin limiter).
|
||||||
|
- **Body cap:** `GATEWAY_MAX_BODY_BYTES` (default 1 MiB) as both the Connect per-message read limit
|
||||||
|
and an `http.MaxBytesReader` wrap of the public mux; an oversized Execute is `resource_exhausted`.
|
||||||
|
- **Dead config found:** `AdminPerMinute`/`AdminBurst` were never wired — the gateway `/_gm` mount is
|
||||||
|
now 429-guarded per IP ahead of its Basic-Auth. The caddy-fronted contour path stays unlimited
|
||||||
|
(stock caddy has no limiter) — an accepted gap, recorded in `docs/ARCHITECTURE.md` §12.
|
||||||
|
- **Landing split:** a `landing` target in `gateway/Dockerfile` (the UI build stage is shared;
|
||||||
|
identical compose build args keep it one cached build); the gateway drops `landing.html` from the
|
||||||
|
embed and 308-redirects `/` → `/app/`; the contour caddy routes `/app/`, `/telegram/` and the
|
||||||
|
Connect path to the gateway and the catch-all to the landing container; the CI deploy probe now
|
||||||
|
checks both `/` (landing) and `/app/` (gateway).
|
||||||
|
- **Observability:** `gateway_rate_limited_total{class}` (user/public/email/admin, aggregate-only)
|
||||||
|
+ a rate-vs-rejections panel on the Edge/UX dashboard; the admin console gains the **Throttled**
|
||||||
|
page (the in-memory episode window, reset-on-restart like `active_users`, plus the flagged-account
|
||||||
|
queue) and the flag badge / clear action on the user list / card.
|
||||||
|
- The jet regen also restored the previously missing `game_drafts`/`game_hidden` generated models
|
||||||
|
(their tables were added after the last jetgen run; no behaviour change).
|
||||||
|
|
||||||
|
- **R4** (interview + implementation):
|
||||||
|
- **Locked decisions:** **delta-first**, not full snapshots — an event carries only the new move and
|
||||||
|
the UI applies it to its per-game cache, keyed on `move_count` (idempotent + gap-safe: a gap or the
|
||||||
|
actor's own move falls back to a `game.state` + `game.history` refetch). `match_found` /
|
||||||
|
`game_started` carry the recipient's **initial `StateView`** (instant lobby→game); the fallback
|
||||||
|
refetch stays the existing two calls (no merged endpoint); the matchmaking poll runs **only while
|
||||||
|
the stream is down** (2.5 s); **all** UI-state-changing events carry their payload (incl. lobby `notify`).
|
||||||
|
- **Enriched events** (`pkg/fbs` trailing fields — backward-compatible, no FB regen of *values*, only
|
||||||
|
the schema): `opponent_moved` (+`move`/`game`/`bag_len`), `your_turn` (+`move_count`), `match_found`
|
||||||
|
(+`state`), `game_over` (+`game`), `notify` (+`account`/`invitation`/`state`). The pre-R4
|
||||||
|
`opponent_moved` scalars (`seat`/`action`/`score`/`total`) stay for wire back-compat, now redundant
|
||||||
|
with `move`/`game` — slated for the R6 de-stage.
|
||||||
|
- **Encoding placement:** the `notify` package keeps ownership of the FlatBuffers encoding (a new
|
||||||
|
`encode.go` mirrors the gateway transcode but reads wire-agnostic `notify.*` input structs +
|
||||||
|
`engine.MoveRecord`); the game/lobby/social services map their domain types to those structs, so the
|
||||||
|
wire schema stays out of the domain. **Flagged for R6:** this partly duplicates the gateway encoders
|
||||||
|
(different source types) — a candidate consolidation.
|
||||||
|
- **Actor self-fetch killed too** (beyond literal "push"): the `submit_play`/`pass`/`exchange`/`resign`
|
||||||
|
**response** (`MoveResult`) now returns the actor's refilled rack + bag size, so the mover renders the
|
||||||
|
next turn from the response — `Game.svelte`'s `commit`/`pass`/`exchange`/`resign` drop their `await load()`.
|
||||||
|
- **`match_found` enrichment** needs a per-seat initial state: `lobby.GameCreator` gained `InitialState`,
|
||||||
|
and `game.Service.InitialState` builds the `notify.PlayerState` (rack re-encoded to wire indices, the
|
||||||
|
variant alphabet embedded for a first-seen variant).
|
||||||
|
- **UI:** a pure `lib/gamedelta.ts` reducer (`applyMoveDelta` / `applyGameOver` / `seedInitialState`,
|
||||||
|
unit-tested) advances the cache; `app.svelte` seeds it on `match_found` / `game_started`; `Game.svelte`
|
||||||
|
applies the delta (falling back to `load()` while composing, on a gap, or on its own move's new rack);
|
||||||
|
`NewGame.svelte` polls only when `app.streamAlive` is false and guards its teardown so a push-delivered
|
||||||
|
match is not cancelled.
|
||||||
|
- **notify (friends/invitations) scope:** the backend carries the full account / invitation payload on the
|
||||||
|
wire (per "all events → push"); the UI seeds the game cache from `game_started` but keeps its lightweight
|
||||||
|
**authoritative** badge refresh (`refreshNotifications`, on the rare `notify` event + on foreground) rather
|
||||||
|
than adding client-side friend/invitation caches — the per-move hot path is fully de-fetched, which was the
|
||||||
|
goal. Deeper lobby-cache consumption is an easy follow-up.
|
||||||
|
- **No schema change** (no migration); the contour needs no DB wipe. Tests: `notify` FB round-trips +
|
||||||
|
`emitMove` delta + the `gamedelta` reducer; the e2e mock now emits the enriched delta.
|
||||||
|
|
||||||
|
- **R5** (interview + implementation):
|
||||||
|
- **No code slimming — by analysis.** A gzip measure + sourcemap attribution of the real `dist` showed
|
||||||
|
the app bundle is already minified + tree-shaken and dominated by the Connect/FlatBuffers transport
|
||||||
|
runtime + generated FB/PB bindings (≈⅔ of `main`'s source) and the Svelte runtime — all
|
||||||
|
third-party/generated, irreducible within R5's scope. App-authored code carries no hand-trimmable fat.
|
||||||
|
- **Lazy-load rejected** (screens *and* i18n): `bundle-size.mjs` sums every emitted chunk, so
|
||||||
|
code-splitting moves bytes between chunks for **zero total-size win** while adding request latency (+N
|
||||||
|
gateway fetches on first navigation to a split screen). i18n lazy-load additionally buys ≤3 KB (en-only
|
||||||
|
users) at the cost of an async `t()`, and `en` must stay bundled (it is the `MessageKey` type source +
|
||||||
|
fallback). **Chunk-collapsing rejected** too — keeping the near-static Svelte runtime in its own
|
||||||
|
cacheable chunk is the recommended practice (an app deploy then re-busts only `main`, not the runtime),
|
||||||
|
and HTTP/2 makes the extra preload request negligible.
|
||||||
|
- **Metric retargeted to the app.** The two-entry build (`index.html` app + `landing.html`) makes Rollup
|
||||||
|
hoist the code shared by both (Svelte runtime + i18n + `aboutContent`) into one preloaded chunk, so the
|
||||||
|
app actually loads its entry chunk **+ the shared chunk** (≈74 + ≈23 = **≈97 KB**), never `landing.js`
|
||||||
|
(≈1.6 KB). The old script summed all three chunks (98.8 KB), over-counting the app by `landing.js`.
|
||||||
|
`bundle-size.mjs` now parses each built HTML for the JS it eagerly loads and gates three parts
|
||||||
|
independently — **app entry ≤ 100 KB, shared (Svelte+i18n) ≤ 30 KB, landing-own ≤ 5 KB** — reporting the
|
||||||
|
app total (≈97) and landing total (≈24.5). Same CLI + exit-code contract, so the CI step is unchanged.
|
||||||
|
- **No app/source/build change** (`App.svelte`, `lib/i18n/`, `vite.config.ts` untouched); no schema
|
||||||
|
change, no contour wipe. The stale "~82 KB" figure was corrected in `bundle-size.mjs` and `ui/README.md`.
|
||||||
|
|
||||||
|
- **R6** (interview + implementation):
|
||||||
|
- **Locked decisions:** apply **both** wire/code structural changes (**B** + **A**) and **only C1+C2** of
|
||||||
|
the test consolidation (not C3/C5); strip the `*(Stage N)*` tags from **all current-state docs**
|
||||||
|
(ARCHITECTURE / FUNCTIONAL+`_ru` / TESTING / UI_DESIGN), keeping PLAN.md / PRERELEASE.md / CLAUDE.md as
|
||||||
|
history; **split `stage6_test.go`** by domain. The `h2cMaxConcurrentStreams` sizing stays an **R7**
|
||||||
|
concern (tuning, not behaviour-preserving); the R2 early run forced no code fix, so nothing was carried in.
|
||||||
|
- **(a) De-staging:** removed the `Stage N` / `TODO-N` / `(RN)` references across code, comments, service
|
||||||
|
READMEs and the current-state docs, rewording narratives to present tense (no technical content lost).
|
||||||
|
Renamed the only stage-named identifiers (`registerStage8`→`registerSocialOps`,
|
||||||
|
`registerStage11`→`registerLinkOps`) and split `stage6_test.go` (`TestEmailLoginFlow`→`email_test.go`;
|
||||||
|
`TestGuestAutoMatchLeavesNoStats`+`provisionGuest`→`account_test.go`). De-staged the `.fbs`/`.proto`
|
||||||
|
comments and regenerated: only the `.proto`-derived Go docstrings (`*_grpc.pb.go`, `push.pb.go`) changed —
|
||||||
|
flatc strips schema comments, so the FB Go/TS bindings were untouched.
|
||||||
|
- **(b) Reconciliation:** the docs were accurate (each R-phase baked its own); the one drift was a stale
|
||||||
|
"guest-reaping deferred (TODO-3)" note in `ARCHITECTURE.md` §3 — guest reaping is implemented, so the
|
||||||
|
note was replaced with the current behaviour (FUNCTIONAL/TESTING already described it).
|
||||||
|
- **(c) B — dead `opponent_moved` scalars:** removed `seat/action/score/total` from `OpponentMovedEvent`
|
||||||
|
(`pkg/fbs/scrabble.fbs` + the `notify` emit + the round-trip test); regenerated FB Go + TS. No reader
|
||||||
|
used them (the UI codec/mock take `move`/`game`/`bag_len`; the gateway forwards the payload verbatim).
|
||||||
|
A pre-release wire-slot renumber — free with no prod data, no DB change.
|
||||||
|
- **(c) A — shared FB builders:** new `scrabble/pkg/wire` holds the single definition of the nested wire
|
||||||
|
tables (GameView / MoveRecord / StateView / AccountRef / Invitation) shared by the backend `notify`
|
||||||
|
encoder and the gateway `transcode`; both map their own source types to neutral `wire.*` structs and
|
||||||
|
delegate. **Honest tradeoff:** the verbose `Start/Add/End` + reverse-prepend boilerplate is now written
|
||||||
|
once, but the field *set* is still mapped per side, and the new package makes the change net **+~145 LOC**
|
||||||
|
— a single-source / anti-drift win for the fiddly mechanics rather than a line-count cut. Behaviour-
|
||||||
|
preserving: the two sides' field sets were verified identical and the round-trip tests pass unchanged.
|
||||||
|
- **(c) C1+C2 — inttest fixtures:** moved the cross-file service/game fixtures (`newGameService` was used by
|
||||||
|
10 files) into `backend/internal/inttest/helpers.go`; single-file helpers stay local. Pure relocation.
|
||||||
|
- **No schema change → no contour DB wipe.** Regression gate: the full unit + integration + UI suites plus
|
||||||
|
the R7 stress run.
|
||||||
|
|
||||||
|
- **R7** (interview + implementation):
|
||||||
|
- **Locked decisions:** run the harness **same-host** (one-shot container on `scrabble-internal`, capped
|
||||||
|
`--cpus=3` so the contour keeps spare cores); **apply container limits + `GOMAXPROCS` now** (not just a
|
||||||
|
prod recommendation); **replace cAdvisor with the otelcol `docker_stats` receiver** (it resolved only the
|
||||||
|
root cgroup on this host); keep rate-limit / h2c knobs **compiled-in** (change values only if the data
|
||||||
|
demands — it did not).
|
||||||
|
- **Harness refinements (pre-run):** each virtual player builds its **own `edge.Client`** (its own h2c
|
||||||
|
connection for its Subscribe stream + Execute calls) instead of all players sharing one `http2.Transport` —
|
||||||
|
the R2 `transport_error` artifact; and `playTurn` now reports a **finished** game so the player drops it
|
||||||
|
from rotation. Effect, measured: `game.state` `transport_error` 14 % (R2) → **2.49 %**; `game_finished` on
|
||||||
|
chat ≈ 3 900 → **35**.
|
||||||
|
- **Observability:** added the `docker_stats` receiver to `otelcol` (`api_version: "1.44"` — the daemon's
|
||||||
|
minimum is 1.40; the receiver defaults to 1.25 and crash-looped until pinned), mounted the docker socket
|
||||||
|
read-only with `group_add` (the contrib image runs as UID 10001), dropped the cAdvisor service + its
|
||||||
|
Prometheus job, and retargeted the **Scrabble — Resources** dashboard to the docker_stats metric names
|
||||||
|
(`container_cpu_utilization`/100 == cores). Cross-checked against `docker stats` within sampling error.
|
||||||
|
- **Profile (final run, 500 players, limits in force):** the **gateway is the binding constraint** — with
|
||||||
|
one connection per player it bursts into its 2-core cap (the residual 2.49 % `transport_error`); backend
|
||||||
|
~0.85 core and postgres ~1.4 cores had headroom; **tempo reached its 1 GiB cap**; the backend pool sat at
|
||||||
|
its `MaxOpenConns=25` cap (28 backends); docker logs were unbounded (~14 MiB / 30 min on the backend at
|
||||||
|
info). Full write-up in [`../loadtest/REPORT-R7.md`](../loadtest/REPORT-R7.md).
|
||||||
|
- **Round-2 tuning (owner-agreed, all in `deploy/docker-compose.yml`, no code change):** gateway **2 → 3
|
||||||
|
cores + `GOMAXPROCS=3`**; tempo memory **1 → 2 GiB**; backend `MAX_OPEN_CONNS` **25 → 40**; a json-file
|
||||||
|
**log-rotation** default (10m × 3) applied contour-wide via a YAML anchor (level stays info).
|
||||||
|
backend/postgres kept at 2 cores / 512 MiB (headroom is cheap on the shared host).
|
||||||
|
- **Validation:** the same gradual ramp on the tuned contour cut `game.state` `transport_error` to **0.72 %**
|
||||||
|
(gateway ~2 cores, now under the 3-core cap, no throttle; tempo ~1.27 GiB, under 2 GiB). A separate
|
||||||
|
**burst** run (a single 100 → 500 jump) pegged the gateway at 3 cores (≈296 % sustained, 9.27 % error),
|
||||||
|
confirming it is **connection-CPU-bound** — a true arrival spike is a **horizontal-scaling** lever, not
|
||||||
|
more cores per node (recorded in the prod-sizing recommendation).
|
||||||
|
- **No schema change → no contour DB wipe.** Bake-back: `loadtest/REPORT-R7.md` (new), `loadtest/README.md`,
|
||||||
|
`docs/TESTING.md`, the telemetry/observability section of `docs/ARCHITECTURE.md`, the repo-layout line in `CLAUDE.md`.
|
||||||
@@ -8,14 +8,13 @@ supports English Scrabble, Russian Scrabble and Эрудит.
|
|||||||
|
|
||||||
- **`gateway`** — the only public ingress: anti-abuse, platform authentication
|
- **`gateway`** — the only public ingress: anti-abuse, platform authentication
|
||||||
(resolves the player and injects `X-User-ID`), routing to `backend`, and an
|
(resolves the player and injects `X-User-ID`), routing to `backend`, and an
|
||||||
admin surface behind Basic Auth. *(added in a later stage)*
|
admin surface behind Basic Auth.
|
||||||
- **`backend`** — internal-only service that owns every domain concern and
|
- **`backend`** — internal-only service that owns every domain concern and
|
||||||
embeds the [`scrabble-solver`](../scrabble-solver) engine library in-process.
|
embeds the [`scrabble-solver`](../scrabble-solver) engine library in-process.
|
||||||
- **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite) over Connect-RPC
|
- **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite) over Connect-RPC
|
||||||
+ FlatBuffers, embeddable in platform webviews and packageable to native via
|
+ FlatBuffers, embeddable in platform webviews and packageable to native via
|
||||||
Capacitor. See [`ui/README.md`](ui/README.md).
|
Capacitor. See [`ui/README.md`](ui/README.md).
|
||||||
- **`platform/*`** — per-platform side-services (e.g. the Telegram bot).
|
- **`platform/*`** — per-platform side-services (e.g. the Telegram bot).
|
||||||
*(added in a later stage)*
|
|
||||||
|
|
||||||
## Documentation (sources of truth)
|
## Documentation (sources of truth)
|
||||||
|
|
||||||
@@ -98,6 +97,6 @@ docker compose -f deploy/docker-compose.yml config # validate (needs the
|
|||||||
|
|
||||||
CI auto-deploys the **test contour** on a PR into — or push to — `development`
|
CI auto-deploys the **test contour** on a PR into — or push to — `development`
|
||||||
(`.gitea/workflows/ci.yaml`); the **prod contour** is a manual deploy after
|
(`.gitea/workflows/ci.yaml`); the **prod contour** is a manual deploy after
|
||||||
`development → master` (Stage 18). Env reference: [`deploy/.env.example`](deploy/.env.example);
|
`development → master`. Env reference: [`deploy/.env.example`](deploy/.env.example);
|
||||||
the topology and the two-contour model are in
|
the topology and the two-contour model are in
|
||||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §13.
|
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §13.
|
||||||
|
|||||||
+4
-3
@@ -2,7 +2,7 @@
|
|||||||
# a golang-alpine builder yields a static binary shipped on distroless nonroot.
|
# a golang-alpine builder yields a static binary shipped on distroless nonroot.
|
||||||
#
|
#
|
||||||
# The dictionary DAWGs are baked in from the scrabble-dictionary release artifact
|
# The dictionary DAWGs are baked in from the scrabble-dictionary release artifact
|
||||||
# (Stage 14) — the same set the Go CI downloads — and BACKEND_DICT_DIR points the
|
# — the same set the Go CI downloads — and BACKEND_DICT_DIR points the
|
||||||
# binary at them. The published solver module is fetched directly from Gitea
|
# binary at them. The published solver module is fetched directly from Gitea
|
||||||
# (GOPRIVATE), so the build stage needs git and network.
|
# (GOPRIVATE), so the build stage needs git and network.
|
||||||
#
|
#
|
||||||
@@ -30,8 +30,9 @@ COPY go.work go.work.sum ./
|
|||||||
COPY pkg ./pkg
|
COPY pkg ./pkg
|
||||||
COPY backend ./backend
|
COPY backend ./backend
|
||||||
|
|
||||||
# Reduce the workspace to what the backend needs: backend + pkg.
|
# Reduce the workspace to what the backend needs: backend + pkg. loadtest and the
|
||||||
RUN go work edit -dropuse=./gateway -dropuse=./platform/telegram
|
# gateway replace it requires are not in this context, so drop both.
|
||||||
|
RUN go work edit -dropuse=./gateway -dropuse=./platform/telegram -dropuse=./loadtest -dropreplace=scrabble/gateway@v0.0.0
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/backend ./backend/cmd/backend
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/backend ./backend/cmd/backend
|
||||||
|
|
||||||
# --- runtime -----------------------------------------------------------------
|
# --- runtime -----------------------------------------------------------------
|
||||||
|
|||||||
+57
-44
@@ -1,24 +1,24 @@
|
|||||||
# backend
|
# backend
|
||||||
|
|
||||||
Internal-only domain service for the Scrabble platform (module `scrabble/backend`).
|
Internal-only domain service for the Scrabble platform (module `scrabble/backend`).
|
||||||
It owns identity/sessions, accounts, and — in later stages — the lobby, game
|
It owns identity/sessions, accounts, the lobby, game runtime, robot, chat, history
|
||||||
runtime, robot, chat, history and administration. Its only network consumers are
|
and administration. Its only network consumers are the `gateway` and the platform
|
||||||
the `gateway` and the platform side-services; it is never exposed publicly.
|
side-services; it is never exposed publicly.
|
||||||
|
|
||||||
As of Stage 1 the backend provides the foundation: configuration, the HTTP
|
The backend provides the foundation: configuration, the HTTP listener with the
|
||||||
listener with the `/api/v1` route-group skeleton and probes, the Postgres pool
|
`/api/v1` route-group skeleton and probes, the Postgres pool with embedded goose
|
||||||
with embedded goose migrations, OpenTelemetry wiring, an in-memory session cache,
|
migrations, OpenTelemetry wiring, an in-memory session cache, and the durable
|
||||||
and the durable accounts / identities / sessions data model. The session and
|
accounts / identities / sessions data model. The session and account REST
|
||||||
account REST endpoints are added with the `gateway` (Stage 6); Stage 1 ships the
|
endpoints live in the `gateway`; the backend ships the store/service layer they
|
||||||
store/service layer they will call.
|
call.
|
||||||
|
|
||||||
Stage 2 adds `internal/engine`, the in-process bridge to the `scrabble-solver`
|
`internal/engine` is the in-process bridge to the `scrabble-solver`
|
||||||
library: a versioned dictionary registry, a deterministic tile bag, and a pure
|
library: a versioned dictionary registry, a deterministic tile bag, and a pure
|
||||||
rules `Game` (legal plays, passes, exchanges, resignations and end-condition
|
rules `Game` (legal plays, passes, exchanges, resignations and end-condition
|
||||||
detection) that emits dictionary-independent move records. It is a library only;
|
detection) that emits dictionary-independent move records. It is a library only;
|
||||||
the game domain wires it into the process in Stage 3.
|
the game domain wires it into the process.
|
||||||
|
|
||||||
Stage 3 adds `internal/game`, the game domain over the engine. Active games are
|
`internal/game` is the game domain over the engine. Active games are
|
||||||
event-sourced: a `games` row plus an append-only decoded move journal, with the
|
event-sourced: a `games` row plus an append-only decoded move journal, with the
|
||||||
live `engine.Game` kept warm in a cache and rebuilt by replay on a miss. It
|
live `engine.Game` kept warm in a cache and rebuilt by replay on a miss. It
|
||||||
provides create, the play/pass/exchange/resign transitions, an unlimited
|
provides create, the play/pass/exchange/resign transitions, an unlimited
|
||||||
@@ -26,10 +26,9 @@ score/legality preview, the hint (per-game allowance plus a profile wallet), the
|
|||||||
word-check tool with complaint capture, per-player game state, history and GCG
|
word-check tool with complaint capture, per-player game state, history and GCG
|
||||||
export, per-account statistics on finish, and a background turn-timeout sweeper
|
export, per-account statistics on finish, and a background turn-timeout sweeper
|
||||||
that auto-resigns overdue turns (honouring each player's daily away window). Like
|
that auto-resigns overdue turns (honouring each player's daily away window). Like
|
||||||
Stages 1–2 it is a service/store layer; the HTTP surface lands with the
|
the engine it is a service/store layer; the HTTP surface lives in the `gateway`.
|
||||||
`gateway` (Stage 6).
|
|
||||||
|
|
||||||
Stage 4 adds the lobby and social fabric. `internal/lobby` holds an in-memory
|
The lobby and social fabric. `internal/lobby` holds an in-memory
|
||||||
matchmaking pool (FIFO per variant, pairs two humans into an auto-match) and
|
matchmaking pool (FIFO per variant, pairs two humans into an auto-match) and
|
||||||
friend-game invitations (invite → accept, starting a 2–4 player game once every
|
friend-game invitations (invite → accept, starting a 2–4 player game once every
|
||||||
invitee accepts). `internal/social` owns the friend graph (request/accept),
|
invitee accepts). `internal/social` owns the friend graph (request/accept),
|
||||||
@@ -41,10 +40,10 @@ development log mailer). The engine now also handles **multi-player drop-out**:
|
|||||||
a 3–4 player game a resignation or timeout drops that seat and the rest play on
|
a 3–4 player game a resignation or timeout drops that seat and the rest play on
|
||||||
(the tile disposition is a per-game setting), the game ending when one active seat
|
(the tile disposition is a per-game setting), the game ending when one active seat
|
||||||
remains. As before this is a service/store layer — chat and nudges are persisted
|
remains. As before this is a service/store layer — chat and nudges are persisted
|
||||||
but their live delivery, and all REST endpoints, arrive with the `gateway`
|
but their live delivery, and all REST endpoints, live in the `gateway`; the
|
||||||
(Stage 6); the services are exposed via `Server` accessors for those handlers.
|
services are exposed via `Server` accessors for those handlers.
|
||||||
|
|
||||||
Stage 5 adds the robot opponent (`internal/robot`). A pool of durable accounts —
|
The robot opponent (`internal/robot`). A pool of durable accounts —
|
||||||
each a `kind='robot'` identity, provisioned at startup with chat and friend
|
each a `kind='robot'` identity, provisioned at startup with chat and friend
|
||||||
requests blocked — backs human-like, per-language composed names. A background driver plays the
|
requests blocked — backs human-like, per-language composed names. A background driver plays the
|
||||||
robot's moves through the public game API as an ordinary seated player (so only
|
robot's moves through the public game API as an ordinary seated player (so only
|
||||||
@@ -53,41 +52,42 @@ win (≈ 40%), targets a small score margin, and times its moves with a move-num
|
|||||||
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
|
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
|
||||||
behaviour — all derived deterministically from the game seed, so it keeps no extra
|
behaviour — all derived deterministically from the game seed, so it keeps no extra
|
||||||
state. The matchmaker now substitutes a pooled robot (matching the game's language) after a 10-second wait and
|
state. The matchmaker now substitutes a pooled robot (matching the game's language) after a 10-second wait and
|
||||||
exposes `Poll` so a waiting player can collect the started game (the live
|
exposes `Poll` so a waiting player can collect the started game — it is the **stream-down
|
||||||
match-found notification arrives with the `gateway`).
|
fallback**: while the client is streaming, the live match-found push (enriched with the recipient's
|
||||||
|
initial game state) drives it instead.
|
||||||
|
|
||||||
Stage 6 opens the backend to the edge. The route groups gain their first
|
The backend opens to the edge. The route groups gain their first
|
||||||
handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under
|
handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under
|
||||||
`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a
|
`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a
|
||||||
slice of authenticated `/api/v1/user` operations (profile, submit play, game
|
slice of authenticated `/api/v1/user` operations (profile, submit play, game
|
||||||
state, lobby enqueue/poll, chat). Stage 8 fills in the social/account/history
|
state, lobby enqueue/poll, chat). The social/account/history operations under
|
||||||
operations under `/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
|
`/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
|
||||||
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
|
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
|
||||||
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`,
|
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`,
|
||||||
`stats`, and `games/:id/gcg` (finished-only). A new `internal/notify` hub feeds a
|
`stats`, and `games/:id/gcg` (finished-only). The `internal/notify` hub feeds a
|
||||||
second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
|
second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
|
||||||
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
|
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
|
||||||
gateway. Stage 9 adds the gateway-only `POST /api/v1/internal/push-target` (a user's
|
gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's
|
||||||
Telegram `external_id`, language and `notifications_in_app_only` flag) that the gateway
|
Telegram `external_id`, language and `notifications_in_app_only` flag) lets the gateway
|
||||||
uses to route out-of-app push to the Telegram connector, extends the Telegram login to
|
route out-of-app push to the Telegram connector; the Telegram login
|
||||||
seed a new account's language and display name from the launch fields, and adds
|
seeds a new account's language and display name from the launch fields, and the
|
||||||
migration `00007` (`accounts.notifications_in_app_only`, default true).
|
`accounts.notifications_in_app_only` flag (default true).
|
||||||
Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row
|
`accounts.is_guest` marks an ephemeral guest — a durable row
|
||||||
with no identity, excluded from statistics. **Stage 10** adds the server-rendered
|
with no identity, excluded from statistics. The server-rendered
|
||||||
**admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`;
|
**admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`;
|
||||||
the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the
|
the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the
|
||||||
**complaint resolution** lifecycle (migration `00008` adds `disposition`/`resolution_note`/
|
**complaint resolution** lifecycle (the `complaints` `disposition`/`resolution_note`/
|
||||||
`resolved_at`/`applied_in_version` + the `status` CHECK) feeding a dictionary-change
|
`resolved_at`/`applied_in_version` columns + the `status` CHECK) feeding a dictionary-change
|
||||||
pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR/<version>/`
|
pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR/<version>/`
|
||||||
(`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a
|
(`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a
|
||||||
backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each
|
backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each
|
||||||
broadcast picks the delivering bot by an operator-chosen language. **Stage 15** adds
|
broadcast picks the delivering bot by an operator-chosen language. `accounts.service_language`
|
||||||
migration `00010` (`accounts.service_language`): the language tag of the bot a Telegram
|
holds the language tag of the bot a Telegram
|
||||||
user last signed in through, written on every login and returned by
|
user last signed in through, written on every login and returned by
|
||||||
`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes
|
`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes
|
||||||
to the right bot. The shared wire contracts live in the sibling [`../pkg`](../pkg) module.
|
to the right bot. The shared wire contracts live in the sibling [`../pkg`](../pkg) module.
|
||||||
|
|
||||||
Stage 11 adds **account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
||||||
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is
|
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is
|
||||||
attached to the current account, and when the identity already has its own account
|
attached to the current account, and when the identity already has its own account
|
||||||
the two are merged in one transaction (`internal/accountmerge`) — stats and the hint
|
the two are merged in one transaction (`internal/accountmerge`) — stats and the hint
|
||||||
@@ -96,8 +96,16 @@ friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (s
|
|||||||
shared finished game's foreign keys hold); a shared **active** game blocks the merge.
|
shared finished game's foreign keys hold); a shared **active** game blocks the merge.
|
||||||
The current account is primary, except a guest initiator whose linked identity has a
|
The current account is primary, except a guest initiator whose linked identity has a
|
||||||
durable owner — then the durable account wins and a fresh session is minted for it.
|
durable owner — then the durable account wins and a fresh session is minted for it.
|
||||||
Migration `00009` adds `paid_account`/`merged_into`/`merged_at`. This supersedes the
|
The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the
|
||||||
Stage 8 `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
|
former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
|
||||||
|
|
||||||
|
Rate-limit observability: the gateway posts its periodic rejection
|
||||||
|
summaries to `POST /api/v1/internal/ratelimit/report`; `internal/ratewatch` keeps a
|
||||||
|
bounded in-memory episode window for the console's **Throttled** page and applies the
|
||||||
|
conservative auto-flag — an account sustaining `BACKEND_HIGHRATE_FLAG_THRESHOLD`
|
||||||
|
rejected calls within `BACKEND_HIGHRATE_FLAG_WINDOW` gets the soft, reversible
|
||||||
|
`accounts.flagged_high_rate_at` marker (set-once; a badge in the user list and a
|
||||||
|
**Clear** action on the user card; never an automatic ban).
|
||||||
|
|
||||||
## Package layout
|
## Package layout
|
||||||
|
|
||||||
@@ -110,8 +118,8 @@ internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations
|
|||||||
migrations/ # embedded *.sql (goose), schema `backend`
|
migrations/ # embedded *.sql (goose), schema `backend`
|
||||||
jet/ # generated go-jet models + table builders (committed)
|
jet/ # generated go-jet models + table builders (committed)
|
||||||
internal/account/ # durable accounts + platform/email identities (store) + email/identity link primitives
|
internal/account/ # durable accounts + platform/email identities (store) + email/identity link primitives
|
||||||
internal/accountmerge/ # single-transaction merge of a secondary account into a primary (Stage 11)
|
internal/accountmerge/ # single-transaction merge of a secondary account into a primary
|
||||||
internal/link/ # link/merge orchestrator over account + accountmerge + session (Stage 11)
|
internal/link/ # link/merge orchestrator over account + accountmerge + session
|
||||||
internal/session/ # opaque tokens, sessions store, write-through cache, service (incl. RevokeAllForAccount)
|
internal/session/ # opaque tokens, sessions store, write-through cache, service (incl. RevokeAllForAccount)
|
||||||
internal/server/ # gin engine, route groups, X-User-ID middleware, probes
|
internal/server/ # gin engine, route groups, X-User-ID middleware, probes
|
||||||
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
|
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
|
||||||
@@ -121,6 +129,7 @@ internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + frien
|
|||||||
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
|
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
|
||||||
internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
|
internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
|
||||||
internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts)
|
internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts)
|
||||||
|
internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration (environment)
|
## Configuration (environment)
|
||||||
@@ -153,6 +162,8 @@ internal/connector/ # backend gRPC client to the Telegram connector (operator b
|
|||||||
| `BACKEND_CONNECTOR_ADDR` | — | Telegram connector gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
|
| `BACKEND_CONNECTOR_ADDR` | — | Telegram connector gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
|
||||||
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
|
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
|
||||||
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
|
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
|
||||||
|
| `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged. |
|
||||||
|
| `BACKEND_HIGHRATE_FLAG_WINDOW` | `10m` | The rolling window those rejections accumulate over. |
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -176,7 +187,10 @@ warmed.
|
|||||||
## Migrations & generated code
|
## Migrations & generated code
|
||||||
|
|
||||||
Migrations are plain goose SQL under `internal/postgres/migrations` (sequential
|
Migrations are plain goose SQL under `internal/postgres/migrations` (sequential
|
||||||
`NNNNN_name.sql`), embedded and applied at startup. After changing the schema,
|
`NNNNN_name.sql`), embedded and applied at startup. The incremental history was
|
||||||
|
squashed into a single `00001_baseline.sql` before the first production deploy
|
||||||
|
(there was no production data); new schema changes append as `00002_*` onward.
|
||||||
|
After changing the schema,
|
||||||
regenerate the committed go-jet code (needs Docker):
|
regenerate the committed go-jet code (needs Docker):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -194,9 +208,8 @@ local solver co-development you may add a temporary replace — see `go.work`).
|
|||||||
(`en_sowpods.dawg`, `ru_scrabble.dawg`, `ru_erudit.dawg`) ship as a **release artifact**
|
(`en_sowpods.dawg`, `ru_scrabble.dawg`, `ru_erudit.dawg`) ship as a **release artifact**
|
||||||
from the [`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary)
|
from the [`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary)
|
||||||
repo (one semver per set); the engine loads them by `(variant, dict_version)` from
|
repo (one semver per set); the engine loads them by `(variant, dict_version)` from
|
||||||
`BACKEND_DICT_DIR`. Since Stage 3 the backend loads them at startup as a hard dependency
|
`BACKEND_DICT_DIR`. The backend loads them at startup as a hard dependency
|
||||||
(a missing dictionary aborts the boot). See [`../PLAN.md`](../PLAN.md) Stage 14
|
(a missing dictionary aborts the boot).
|
||||||
(TODO-1/TODO-2).
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
"scrabble/backend/internal/postgres"
|
"scrabble/backend/internal/postgres"
|
||||||
"scrabble/backend/internal/pushgrpc"
|
"scrabble/backend/internal/pushgrpc"
|
||||||
|
"scrabble/backend/internal/ratewatch"
|
||||||
"scrabble/backend/internal/robot"
|
"scrabble/backend/internal/robot"
|
||||||
"scrabble/backend/internal/server"
|
"scrabble/backend/internal/server"
|
||||||
"scrabble/backend/internal/session"
|
"scrabble/backend/internal/session"
|
||||||
@@ -107,7 +108,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
zap.String("dir", cfg.Game.DictDir),
|
zap.String("dir", cfg.Game.DictDir),
|
||||||
zap.String("version", cfg.Game.DictVersion))
|
zap.String("version", cfg.Game.DictVersion))
|
||||||
|
|
||||||
// Stage 10 admin console: an optional backend client to the Telegram connector
|
// Admin console: an optional backend client to the Telegram connector
|
||||||
// side-service for operator broadcasts. Unset (BACKEND_CONNECTOR_ADDR empty)
|
// side-service for operator broadcasts. Unset (BACKEND_CONNECTOR_ADDR empty)
|
||||||
// leaves broadcasts disabled — the console shows a "not configured" notice.
|
// leaves broadcasts disabled — the console shows a "not configured" notice.
|
||||||
var conn *connector.Client
|
var conn *connector.Client
|
||||||
@@ -140,7 +141,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
logger.Info("game turn-timeout sweeper started",
|
logger.Info("game turn-timeout sweeper started",
|
||||||
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
|
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
|
||||||
|
|
||||||
// Stage 12 TODO-3: reap abandoned guest accounts (no game seat, account age past
|
// Reap abandoned guest accounts (no game seat, account age past
|
||||||
// the retention window). Dependent rows fall away via ON DELETE CASCADE.
|
// the retention window). Dependent rows fall away via ON DELETE CASCADE.
|
||||||
guestReaper := account.NewGuestReaper(accounts, cfg.GuestRetention, logger)
|
guestReaper := account.NewGuestReaper(accounts, cfg.GuestRetention, logger)
|
||||||
go guestReaper.Run(ctx, cfg.GuestReapInterval)
|
go guestReaper.Run(ctx, cfg.GuestReapInterval)
|
||||||
@@ -148,19 +149,18 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
zap.Duration("interval", cfg.GuestReapInterval),
|
zap.Duration("interval", cfg.GuestReapInterval),
|
||||||
zap.Duration("retention", cfg.GuestRetention))
|
zap.Duration("retention", cfg.GuestRetention))
|
||||||
|
|
||||||
// Stage 4 lobby & social domains. Their REST and stream surface is added with
|
// Lobby & social domains. Their REST and stream surface lives in the gateway,
|
||||||
// the gateway in Stage 6, so they are handed to the server (like the route
|
// so they are handed to the server (like the route groups) for the handlers.
|
||||||
// groups) for the handlers to come.
|
|
||||||
mailer := newMailer(cfg.SMTP, logger)
|
mailer := newMailer(cfg.SMTP, logger)
|
||||||
emails := account.NewEmailService(accounts, mailer)
|
emails := account.NewEmailService(accounts, mailer)
|
||||||
// Stage 11 account linking & merge: the orchestrator over the account, merge and
|
// Account linking & merge: the orchestrator over the account, merge and
|
||||||
// session layers. Wired to the /api/v1/user/link REST surface below.
|
// session layers. Wired to the /api/v1/user/link REST surface below.
|
||||||
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
|
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
|
||||||
socialSvc := social.NewService(social.NewStore(db), accounts, games)
|
socialSvc := social.NewService(social.NewStore(db), accounts, games)
|
||||||
socialSvc.SetNotifier(hub)
|
socialSvc.SetNotifier(hub)
|
||||||
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
|
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
|
||||||
|
|
||||||
// Stage 5 robot opponent: provision its durable account pool (a hard startup
|
// Robot opponent: provision its durable account pool (a hard startup
|
||||||
// dependency, like the dictionaries) and start its move driver. The matchmaker
|
// dependency, like the dictionaries) and start its move driver. The matchmaker
|
||||||
// substitutes a pooled robot for a missing human after the wait window.
|
// substitutes a pooled robot for a missing human after the wait window.
|
||||||
robots := robot.NewService(games, accounts, socialSvc, tel.MeterProvider().Meter("scrabble/backend/robot"), logger)
|
robots := robot.NewService(games, accounts, socialSvc, tel.MeterProvider().Meter("scrabble/backend/robot"), logger)
|
||||||
@@ -177,6 +177,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
invitations.SetNotifier(hub)
|
invitations.SetNotifier(hub)
|
||||||
logger.Info("lobby and social domains ready", zap.Duration("robot_wait", cfg.Lobby.RobotWait))
|
logger.Info("lobby and social domains ready", zap.Duration("robot_wait", cfg.Lobby.RobotWait))
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
|
||||||
srv := server.New(cfg.HTTPAddr, server.Deps{
|
srv := server.New(cfg.HTTPAddr, server.Deps{
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
DB: db,
|
DB: db,
|
||||||
@@ -193,6 +200,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
Registry: registry,
|
Registry: registry,
|
||||||
DictDir: cfg.Game.DictDir,
|
DictDir: cfg.Game.DictDir,
|
||||||
Connector: conn,
|
Connector: conn,
|
||||||
|
RateWatch: rateWatch,
|
||||||
})
|
})
|
||||||
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ import (
|
|||||||
|
|
||||||
// Identity kinds recognised by the backend. Email is modelled as an identity
|
// Identity kinds recognised by the backend. Email is modelled as an identity
|
||||||
// alongside platform identities; its confirmed flag is driven by the email
|
// alongside platform identities; its confirmed flag is driven by the email
|
||||||
// confirm-code flow in a later stage. Robot is a synthetic kind: each pooled
|
// confirm-code flow. Robot is a synthetic kind: each pooled
|
||||||
// robot opponent is a durable account bound to one robot identity (Stage 5).
|
// robot opponent is a durable account bound to one robot identity.
|
||||||
const (
|
const (
|
||||||
KindTelegram = "telegram"
|
KindTelegram = "telegram"
|
||||||
KindEmail = "email"
|
KindEmail = "email"
|
||||||
@@ -66,16 +66,21 @@ type Account struct {
|
|||||||
IsGuest bool
|
IsGuest bool
|
||||||
// NotificationsInAppOnly confines notifications to the in-app live stream when
|
// NotificationsInAppOnly confines notifications to the in-app live stream when
|
||||||
// true (the default): the platform side-service skips out-of-app push for the
|
// true (the default): the platform side-service skips out-of-app push for the
|
||||||
// account (Stage 9).
|
// account.
|
||||||
NotificationsInAppOnly bool
|
NotificationsInAppOnly bool
|
||||||
// PaidAccount marks a lifetime one-time-payment account. It is a service field
|
// PaidAccount marks a lifetime one-time-payment account. It is a service field
|
||||||
// (no purchase flow yet); an account linking & merge ORs it so a paid status is
|
// (no purchase flow yet); an account linking & merge ORs it so a paid status is
|
||||||
// never lost when accounts are consolidated (Stage 11).
|
// never lost when accounts are consolidated.
|
||||||
PaidAccount bool
|
PaidAccount bool
|
||||||
// MergedInto is the primary account a retired (merged) secondary points at, or
|
// MergedInto is the primary account a retired (merged) secondary points at, or
|
||||||
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
|
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
|
||||||
// foreign keys of a shared finished game stay valid (Stage 11).
|
// foreign keys of a shared finished game stay valid.
|
||||||
MergedInto uuid.UUID
|
MergedInto uuid.UUID
|
||||||
|
// FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the
|
||||||
|
// zero time for an unflagged account, otherwise when the gateway-reported
|
||||||
|
// rate-limiter rejections first crossed the sustained threshold. An
|
||||||
|
// operator clears it in the admin console; it never gates any request.
|
||||||
|
FlaggedHighRateAt time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -422,6 +427,43 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
|
|||||||
return n > 0, nil
|
return n > 0, 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
|
||||||
|
}
|
||||||
|
|
||||||
// SetServiceLanguage records the service language (en/ru) of the bot a Telegram
|
// SetServiceLanguage records the service language (en/ru) of the bot a Telegram
|
||||||
// user authenticated through. It is called on every Telegram login — new and
|
// user authenticated through. It is called on every Telegram login — new and
|
||||||
// existing accounts — so it tracks the bot the user last came through (last-login-
|
// existing accounts — so it tracks the bot the user last came through (last-login-
|
||||||
@@ -452,6 +494,10 @@ func modelToAccount(row model.Accounts) Account {
|
|||||||
if row.ServiceLanguage != nil {
|
if row.ServiceLanguage != nil {
|
||||||
serviceLanguage = *row.ServiceLanguage
|
serviceLanguage = *row.ServiceLanguage
|
||||||
}
|
}
|
||||||
|
var flaggedHighRateAt time.Time
|
||||||
|
if row.FlaggedHighRateAt != nil {
|
||||||
|
flaggedHighRateAt = *row.FlaggedHighRateAt
|
||||||
|
}
|
||||||
return Account{
|
return Account{
|
||||||
ID: row.AccountID,
|
ID: row.AccountID,
|
||||||
DisplayName: row.DisplayName,
|
DisplayName: row.DisplayName,
|
||||||
@@ -467,6 +513,7 @@ func modelToAccount(row model.Accounts) Account {
|
|||||||
NotificationsInAppOnly: row.NotificationsInAppOnly,
|
NotificationsInAppOnly: row.NotificationsInAppOnly,
|
||||||
PaidAccount: row.PaidAccount,
|
PaidAccount: row.PaidAccount,
|
||||||
MergedInto: mergedInto,
|
MergedInto: mergedInto,
|
||||||
|
FlaggedHighRateAt: flaggedHighRateAt,
|
||||||
CreatedAt: row.CreatedAt,
|
CreatedAt: row.CreatedAt,
|
||||||
UpdatedAt: row.UpdatedAt,
|
UpdatedAt: row.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ var (
|
|||||||
// ErrInvalidEmail is returned for an unparseable email address.
|
// ErrInvalidEmail is returned for an unparseable email address.
|
||||||
ErrInvalidEmail = errors.New("account: invalid email address")
|
ErrInvalidEmail = errors.New("account: invalid email address")
|
||||||
// ErrEmailTaken is returned when the email is already confirmed by another
|
// ErrEmailTaken is returned when the email is already confirmed by another
|
||||||
// account; binding it would be a merge, which Stage 11 owns.
|
// account; binding it would be a merge, which the link/merge flow owns.
|
||||||
ErrEmailTaken = errors.New("account: email already confirmed by another account")
|
ErrEmailTaken = errors.New("account: email already confirmed by another account")
|
||||||
// ErrAlreadyConfirmed is returned when the email is already confirmed by the
|
// ErrAlreadyConfirmed is returned when the email is already confirmed by the
|
||||||
// requesting account.
|
// requesting account.
|
||||||
@@ -52,8 +52,8 @@ var (
|
|||||||
// Mailer and verifies it, binding a confirmed email identity to the requesting
|
// Mailer and verifies it, binding a confirmed email identity to the requesting
|
||||||
// account. Only the SHA-256 hash of a code is stored (never the plaintext),
|
// account. Only the SHA-256 hash of a code is stored (never the plaintext),
|
||||||
// matching the session model. Binding an email already confirmed by a different
|
// matching the session model. Binding an email already confirmed by a different
|
||||||
// account is refused (ErrEmailTaken) — merging two accounts is Stage 11 — and
|
// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
|
||||||
// using an email as a login is Stage 6, which reuses this mechanism.
|
// and using an email as a login reuses this mechanism.
|
||||||
type EmailService struct {
|
type EmailService struct {
|
||||||
store *Store
|
store *Store
|
||||||
mailer Mailer
|
mailer Mailer
|
||||||
@@ -128,7 +128,7 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
|
|||||||
|
|
||||||
// RequestLoginCode issues a login confirm-code to the account that owns email,
|
// RequestLoginCode issues a login confirm-code to the account that owns email,
|
||||||
// provisioning a fresh (unconfirmed) durable account when the email is new. It is
|
// provisioning a fresh (unconfirmed) durable account when the email is new. It is
|
||||||
// the unauthenticated email-login entry point (Stage 6) and, unlike RequestCode,
|
// the unauthenticated email-login entry point and, unlike RequestCode,
|
||||||
// does not refuse an already-confirmed email — that is the ordinary returning-user
|
// does not refuse an already-confirmed email — that is the ordinary returning-user
|
||||||
// login. The code is mailed to the address, so only its real owner can complete
|
// login. The code is mailed to the address, so only its real owner can complete
|
||||||
// the login. It returns the target account id for the subsequent LoginWithCode.
|
// the login. It returns the target account id for the subsequent LoginWithCode.
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ErrIdentityTaken is returned when a platform identity being linked already
|
// ErrIdentityTaken is returned when a platform identity being linked already
|
||||||
// belongs to another account; the caller turns it into a merge (Stage 11).
|
// belongs to another account; the caller turns it into a merge.
|
||||||
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
|
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
|
||||||
|
|
||||||
// RequestLinkCode issues and mails a confirm-code for email to accountID,
|
// RequestLinkCode issues and mails a confirm-code for email to accountID,
|
||||||
// replacing any prior pending code. Unlike RequestCode it never refuses up front
|
// replacing any prior pending code. Unlike RequestCode it never refuses up front
|
||||||
// (taken or already-confirmed): possession of the address is the authorization for
|
// (taken or already-confirmed): possession of the address is the authorization for
|
||||||
// a later link or merge, and the merge is only revealed once the code is verified,
|
// a later link or merge, and the merge is only revealed once the code is verified,
|
||||||
// so a probe cannot learn whether an address is registered (Stage 11).
|
// so a probe cannot learn whether an address is registered.
|
||||||
func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error {
|
func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error {
|
||||||
addr, err := normalizeEmail(email)
|
addr, err := normalizeEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -94,7 +94,7 @@ func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUI
|
|||||||
|
|
||||||
// AccountIDByIdentity returns the account owning (kind, externalID) and true, or
|
// AccountIDByIdentity returns the account owning (kind, externalID) and true, or
|
||||||
// (uuid.Nil, false) when the identity is free. It backs the platform-identity link
|
// (uuid.Nil, false) when the identity is free. It backs the platform-identity link
|
||||||
// flow (Stage 11).
|
// flow.
|
||||||
func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string) (uuid.UUID, bool, error) {
|
func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string) (uuid.UUID, bool, error) {
|
||||||
acc, err := s.findByIdentity(ctx, kind, externalID)
|
acc, err := s.findByIdentity(ctx, kind, externalID)
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
@@ -109,7 +109,7 @@ func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string
|
|||||||
// AttachIdentity links a new (kind, externalID) identity to an existing account.
|
// AttachIdentity links a new (kind, externalID) identity to an existing account.
|
||||||
// A unique-constraint violation means the identity was taken meanwhile, surfaced
|
// A unique-constraint violation means the identity was taken meanwhile, surfaced
|
||||||
// as ErrIdentityTaken. It is used to attach a platform identity (e.g. Telegram)
|
// as ErrIdentityTaken. It is used to attach a platform identity (e.g. Telegram)
|
||||||
// to the current account during linking (Stage 11).
|
// to the current account during linking.
|
||||||
func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, externalID string, confirmed bool) error {
|
func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, externalID string, confirmed bool) error {
|
||||||
id, err := uuid.NewV7()
|
id, err := uuid.NewV7()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -129,7 +129,7 @@ func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ClearGuest removes the is_guest flag from accountID, promoting an ephemeral guest
|
// ClearGuest removes the is_guest flag from accountID, promoting an ephemeral guest
|
||||||
// to a durable account once it gains its first identity (Stage 11). It is a no-op
|
// to a durable account once it gains its first identity. It is a no-op
|
||||||
// for an already-durable account.
|
// for an already-durable account.
|
||||||
func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
|
func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
|
||||||
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt).
|
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt).
|
||||||
|
|||||||
@@ -23,13 +23,18 @@ import (
|
|||||||
// is unbounded; auto-provisioned platform names bypass this editor validation).
|
// is unbounded; auto-provisioned platform names bypass this editor validation).
|
||||||
const maxDisplayName = 32
|
const maxDisplayName = 32
|
||||||
|
|
||||||
|
// maxDisplayNameSpecials caps the total special characters (the "." / "_" separators —
|
||||||
|
// every name rune that is neither a letter nor a space) an editable display name may
|
||||||
|
// carry, so a still-well-formed name cannot be made of mostly punctuation.
|
||||||
|
const maxDisplayNameSpecials = 5
|
||||||
|
|
||||||
// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware).
|
// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware).
|
||||||
const maxAwayWindow = 12 * time.Hour
|
const maxAwayWindow = 12 * time.Hour
|
||||||
|
|
||||||
// displayNameRe enforces the editable display-name format (Stage 8): Unicode letters
|
// displayNameRe enforces the editable display-name format: Unicode letters
|
||||||
// joined by single space / "." / "_" separators, where a "." or "_" may be followed
|
// joined by single space / "." / "_" separators, where a "." or "_" may be followed
|
||||||
// by a single space. No leading separator and no two adjacent separators (except
|
// by a single space. No leading separator and no two adjacent separators (except
|
||||||
// "<dot|underscore> <space>"); a single trailing "." is allowed (Stage 17), so
|
// "<dot|underscore> <space>"); a single trailing "." is allowed, so
|
||||||
// "Name_P. Last" and "Anna B." are valid, "Name P._Last" is not.
|
// "Name_P. Last" and "Anna B." are valid, "Name P._Last" is not.
|
||||||
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$`)
|
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$`)
|
||||||
|
|
||||||
@@ -110,6 +115,15 @@ func ValidateDisplayName(raw string) (string, error) {
|
|||||||
if !displayNameRe.MatchString(name) {
|
if !displayNameRe.MatchString(name) {
|
||||||
return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile)
|
return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile)
|
||||||
}
|
}
|
||||||
|
specials := 0
|
||||||
|
for _, r := range name {
|
||||||
|
if r != ' ' && !unicode.IsLetter(r) {
|
||||||
|
specials++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if specials > maxDisplayNameSpecials {
|
||||||
|
return "", fmt.Errorf("%w: display name has more than %d special characters", ErrInvalidProfile, maxDisplayNameSpecials)
|
||||||
|
}
|
||||||
return name, nil
|
return name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// TestUpdateProfileValidation checks that bad fields are rejected before any
|
// TestUpdateProfileValidation checks that bad fields are rejected before any
|
||||||
// database access, so a nil-backed Store is enough to exercise the guards. It also
|
// database access, so a nil-backed Store is enough to exercise the guards. It also
|
||||||
// confirms UpdateProfile wires the Stage 8 validators (name format, away window,
|
// confirms UpdateProfile wires the validators (name format, away window,
|
||||||
// offset/IANA timezone), not just their unit tests in validate_test.go.
|
// offset/IANA timezone), not just their unit tests in validate_test.go.
|
||||||
func TestUpdateProfileValidation(t *testing.T) {
|
func TestUpdateProfileValidation(t *testing.T) {
|
||||||
s := &Store{}
|
s := &Store{}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// offsetZoneRe matches a fixed UTC offset like "+03:00" or "-05:30" — the form the
|
// offsetZoneRe matches a fixed UTC offset like "+03:00" or "-05:30" — the form the
|
||||||
// Stage 8 profile editor stores (an offset dropdown rather than an IANA name).
|
// profile editor stores (an offset dropdown rather than an IANA name).
|
||||||
var offsetZoneRe = regexp.MustCompile(`^([+-])(\d{2}):(\d{2})$`)
|
var offsetZoneRe = regexp.MustCompile(`^([+-])(\d{2}):(\d{2})$`)
|
||||||
|
|
||||||
// parseOffsetZone parses a "±HH:MM" offset into a fixed-offset location, reporting
|
// parseOffsetZone parses a "±HH:MM" offset into a fixed-offset location, reporting
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package account
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,6 +19,9 @@ type UserListItem struct {
|
|||||||
PreferredLanguage string
|
PreferredLanguage string
|
||||||
IsGuest bool
|
IsGuest bool
|
||||||
IsRobot bool
|
IsRobot bool
|
||||||
|
// FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown
|
||||||
|
// as a badge in the console list.
|
||||||
|
FlaggedHighRateAt time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +69,7 @@ func userListWhere(f UserFilter) (string, []any) {
|
|||||||
// ListUsers returns the filtered admin user list, newest first, paginated.
|
// ListUsers returns the filtered admin user list, newest first, paginated.
|
||||||
func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) {
|
func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) {
|
||||||
where, args := userListWhere(f)
|
where, args := userListWhere(f)
|
||||||
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.created_at, ` + robotExists + ` AS is_robot
|
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot
|
||||||
FROM backend.accounts a WHERE ` + where +
|
FROM backend.accounts a WHERE ` + where +
|
||||||
fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
@@ -77,14 +81,51 @@ FROM backend.accounts a WHERE ` + where +
|
|||||||
var out []UserListItem
|
var out []UserListItem
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var it UserListItem
|
var it UserListItem
|
||||||
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &it.CreatedAt, &it.IsRobot); err != nil {
|
var flagged sql.NullTime
|
||||||
|
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot); err != nil {
|
||||||
return nil, fmt.Errorf("account: scan user: %w", err)
|
return nil, fmt.Errorf("account: scan user: %w", err)
|
||||||
}
|
}
|
||||||
|
if flagged.Valid {
|
||||||
|
it.FlaggedHighRateAt = flagged.Time
|
||||||
|
}
|
||||||
out = append(out, it)
|
out = append(out, it)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlaggedAccount is one row of the console's high-rate review queue.
|
||||||
|
type FlaggedAccount struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
DisplayName string
|
||||||
|
FlaggedHighRateAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// flaggedListCap bounds the console's flagged-account list; the operator clears
|
||||||
|
// flags as they are reviewed, so the queue stays short in practice.
|
||||||
|
const flaggedListCap = 200
|
||||||
|
|
||||||
|
// ListFlaggedHighRate returns the accounts carrying the high-rate flag, most
|
||||||
|
// recently flagged first.
|
||||||
|
func (s *Store) ListFlaggedHighRate(ctx context.Context) ([]FlaggedAccount, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT account_id, display_name, flagged_high_rate_at
|
||||||
|
FROM backend.accounts WHERE flagged_high_rate_at IS NOT NULL
|
||||||
|
ORDER BY flagged_high_rate_at DESC LIMIT $1`, flaggedListCap)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("account: list flagged: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []FlaggedAccount
|
||||||
|
for rows.Next() {
|
||||||
|
var fa FlaggedAccount
|
||||||
|
if err := rows.Scan(&fa.ID, &fa.DisplayName, &fa.FlaggedHighRateAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("account: scan flagged: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, fa)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// CountUsers counts the filtered admin user list, for pagination.
|
// CountUsers counts the filtered admin user list, for pagination.
|
||||||
func (s *Store) CountUsers(ctx context.Context, f UserFilter) (int, error) {
|
func (s *Store) CountUsers(ctx context.Context, f UserFilter) (int, error) {
|
||||||
where, args := userListWhere(f)
|
where, args := userListWhere(f)
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ func TestValidateDisplayName(t *testing.T) {
|
|||||||
"digit rejected": {"Name2", "", false},
|
"digit rejected": {"Name2", "", false},
|
||||||
"blank": {" ", "", false},
|
"blank": {" ", "", false},
|
||||||
"too long": {strings.Repeat("a", 33), "", false},
|
"too long": {strings.Repeat("a", 33), "", false},
|
||||||
|
"five specials ok": {"a.a.a.a.a.a", "a.a.a.a.a.a", true}, // 5 dots
|
||||||
|
"six specials": {"a.a.a.a.a.a.a", "", false}, // 6 dots
|
||||||
|
"initials spaces ok": {"J. R. R. Tolkien", "J. R. R. Tolkien", true}, // 3 dots; spaces don't count
|
||||||
}
|
}
|
||||||
for name, tc := range cases {
|
for name, tc := range cases {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints
|
// transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints
|
||||||
// the secondary's identities, transfers its games/chat/complaints/invitations,
|
// the secondary's identities, transfers its games/chat/complaints/invitations,
|
||||||
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
|
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
|
||||||
// (accounts.merged_into). It is the data core of Stage 11 account linking & merge
|
// (accounts.merged_into). It is the data core of account linking & merge
|
||||||
// (ARCHITECTURE.md §4); session revocation and any session switch are orchestrated
|
// (ARCHITECTURE.md §4); session revocation and any session switch are orchestrated
|
||||||
// one layer up (the link service), since the in-memory session cache lives there.
|
// one layer up (the link service), since the in-memory session cache lives there.
|
||||||
package accountmerge
|
package accountmerge
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
|
|||||||
.subnav a.active { color: var(--ink); }
|
.subnav a.active { color: var(--ink); }
|
||||||
|
|
||||||
.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.4rem; }
|
.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.4rem; }
|
||||||
|
.form .export { margin-left: auto; align-self: center; color: var(--accent); white-space: nowrap; }
|
||||||
.form.col { flex-direction: column; align-items: stretch; max-width: 540px; }
|
.form.col { flex-direction: column; align-items: stretch; max-width: 540px; }
|
||||||
.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
|
.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
|
||||||
.form input, .form select, .form textarea {
|
.form input, .form select, .form textarea {
|
||||||
|
|||||||
@@ -20,15 +20,21 @@ func TestRendererRendersEveryPage(t *testing.T) {
|
|||||||
data any
|
data any
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
|
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
|
||||||
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya"}}, Pager: NewPager(1, 50, 1)}, "Kaya"},
|
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"},
|
||||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
|
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
|
||||||
{"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "english", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"},
|
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
|
||||||
{"game_detail", GameDetailView{ID: "g1", Variant: "english", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
|
{"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"},
|
||||||
|
{"game_detail", GameDetailView{ID: "g1", Variant: "scrabble_en", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
|
||||||
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
|
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
|
||||||
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"},
|
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"},
|
||||||
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "english"}, "Resolve"},
|
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"},
|
||||||
{"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "english", Word: "qi", Action: "add"}}}, "Hot-reload"},
|
{"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Hot-reload"},
|
||||||
{"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"},
|
{"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"},
|
||||||
{"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"},
|
{"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
|
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
|
||||||
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
|
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
|
||||||
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
|
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
|
||||||
|
<a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</a>
|
||||||
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
||||||
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
||||||
<a href="/_gm/grafana/">Grafana ↗</a>
|
<a href="/_gm/grafana/">Grafana ↗</a>
|
||||||
|
|||||||
@@ -30,9 +30,9 @@
|
|||||||
<form class="form" method="post" action="/_gm/dictionary/changes/apply">
|
<form class="form" method="post" action="/_gm/dictionary/changes/apply">
|
||||||
<label>Mark applied for variant
|
<label>Mark applied for variant
|
||||||
<select name="variant">
|
<select name="variant">
|
||||||
<option value="english">english</option>
|
<option value="scrabble_en">scrabble_en</option>
|
||||||
<option value="russian_scrabble">russian_scrabble</option>
|
<option value="scrabble_ru">scrabble_ru</option>
|
||||||
<option value="erudit">erudit</option>
|
<option value="erudit_ru">erudit_ru</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>In version <input type="text" name="version" placeholder="v2" required></label>
|
<label>In version <input type="text" name="version" placeholder="v2" required></label>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<input name="name" value="{{.NameMask}}" placeholder="sender name mask (* ?)">
|
<input name="name" value="{{.NameMask}}" placeholder="sender name mask (* ?)">
|
||||||
<input name="ext" value="{{.ExtMask}}" placeholder="sender external id mask (* ?)">
|
<input name="ext" value="{{.ExtMask}}" placeholder="sender external id mask (* ?)">
|
||||||
<button type="submit">Filter</button>
|
<button type="submit">Filter</button>
|
||||||
|
<a class="export" href="/_gm/messages.csv?{{.FilterQuery}}">Export CSV ↓</a>
|
||||||
</form>
|
</form>
|
||||||
{{if or .GameID .UserID}}
|
{{if or .GameID .UserID}}
|
||||||
<p class="note">Filtered{{if .GameID}} to game <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a>{{end}}{{if .UserID}} from <a href="/_gm/users/{{.UserID}}">sender</a>{{end}} · <a href="/_gm/messages">clear</a></p>
|
<p class="note">Filtered{{if .GameID}} to game <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a>{{end}}{{if .UserID}} from <a href="/_gm/users/{{.UserID}}">sender</a>{{end}} · <a href="/_gm/messages">clear</a></p>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{{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>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}}
|
||||||
@@ -13,8 +13,14 @@
|
|||||||
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
|
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
|
||||||
<li><b>Hint wallet</b> {{.HintBalance}}</li>
|
<li><b>Hint wallet</b> {{.HintBalance}}</li>
|
||||||
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
|
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
|
||||||
|
{{if .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}}
|
||||||
<li><b>Created</b> {{.CreatedAt}}</li>
|
<li><b>Created</b> {{.CreatedAt}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
{{if .FlaggedHighRateAt}}
|
||||||
|
<form class="form" method="post" action="/_gm/users/{{.ID}}/clear-high-rate-flag">
|
||||||
|
<button type="submit">Clear high-rate flag</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
<section class="panel"><h2>Statistics</h2>
|
<section class="panel"><h2>Statistics</h2>
|
||||||
{{if .HasStats}}
|
{{if .HasStats}}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
{{range .Items}}
|
{{range .Items}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
|
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
|
||||||
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}</td>
|
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</td>
|
||||||
<td>{{.Kind}}</td>
|
<td>{{.Kind}}</td>
|
||||||
<td>{{.Language}}</td>
|
<td>{{.Language}}</td>
|
||||||
<td>{{.CreatedAt}}</td>
|
<td>{{.CreatedAt}}</td>
|
||||||
|
|||||||
@@ -59,13 +59,15 @@ type UsersView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
|
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
|
||||||
// pre-formatted move-duration summary (empty when it has no timed move).
|
// pre-formatted move-duration summary (empty when it has no timed move);
|
||||||
|
// FlaggedHighRate marks the soft high-rate badge.
|
||||||
type UserRow struct {
|
type UserRow struct {
|
||||||
ID string
|
ID string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
Kind string
|
Kind string
|
||||||
Language string
|
Language string
|
||||||
Guest bool
|
Guest bool
|
||||||
|
FlaggedHighRate bool
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
HasMoveStats bool
|
HasMoveStats bool
|
||||||
MoveMin string
|
MoveMin string
|
||||||
@@ -109,8 +111,11 @@ type UserDetailView struct {
|
|||||||
NotificationsInAppOnly bool
|
NotificationsInAppOnly bool
|
||||||
PaidAccount bool
|
PaidAccount bool
|
||||||
// MergedInto is the primary account id when this account has been retired by a
|
// MergedInto is the primary account id when this account has been retired by a
|
||||||
// merge (Stage 11), or empty for a live account.
|
// merge, or empty for a live account.
|
||||||
MergedInto string
|
MergedInto string
|
||||||
|
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
|
||||||
|
// empty for an unflagged account; the card shows it with the Clear action.
|
||||||
|
FlaggedHighRateAt string
|
||||||
HintBalance int
|
HintBalance int
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
HasStats bool
|
HasStats bool
|
||||||
@@ -247,6 +252,35 @@ type BroadcastView struct {
|
|||||||
ConnectorEnabled bool
|
ConnectorEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ThrottledView is the rate-limit observability page: the recent gateway-reported
|
||||||
|
// throttle episodes (in-memory, reset on restart) and the accounts currently
|
||||||
|
// carrying the high-rate flag. FlagThreshold and FlagWindow caption the active
|
||||||
|
// auto-flag tuning.
|
||||||
|
type ThrottledView struct {
|
||||||
|
Episodes []ThrottleEpisodeRow
|
||||||
|
Flagged []FlaggedAccountRow
|
||||||
|
FlagThreshold int
|
||||||
|
FlagWindow 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
|
||||||
|
}
|
||||||
|
|
||||||
// MessageView is the result page shown after a POST action.
|
// MessageView is the result page shown after a POST action.
|
||||||
type MessageView struct {
|
type MessageView struct {
|
||||||
Heading string
|
Heading string
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/postgres"
|
"scrabble/backend/internal/postgres"
|
||||||
|
"scrabble/backend/internal/ratewatch"
|
||||||
"scrabble/backend/internal/robot"
|
"scrabble/backend/internal/robot"
|
||||||
"scrabble/backend/internal/telemetry"
|
"scrabble/backend/internal/telemetry"
|
||||||
)
|
)
|
||||||
@@ -35,6 +36,9 @@ type Config struct {
|
|||||||
Lobby lobby.Config
|
Lobby lobby.Config
|
||||||
// Robot configures the robot opponent driver (scan cadence).
|
// Robot configures the robot opponent driver (scan cadence).
|
||||||
Robot robot.Config
|
Robot robot.Config
|
||||||
|
// RateWatch tunes the conservative high-rate auto-flag applied to the
|
||||||
|
// gateway's rate-limiter rejection reports.
|
||||||
|
RateWatch ratewatch.Config
|
||||||
// SMTP configures the email relay used for confirm-codes. An empty Host
|
// SMTP configures the email relay used for confirm-codes. An empty Host
|
||||||
// selects the development log mailer (the code is logged, not sent).
|
// selects the development log mailer (the code is logged, not sent).
|
||||||
SMTP account.SMTPConfig
|
SMTP account.SMTPConfig
|
||||||
@@ -105,6 +109,14 @@ func Load() (Config, error) {
|
|||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rw := ratewatch.DefaultConfig()
|
||||||
|
if rw.FlagThreshold, err = envInt("BACKEND_HIGHRATE_FLAG_THRESHOLD", rw.FlagThreshold); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if rw.FlagWindow, err = envDuration("BACKEND_HIGHRATE_FLAG_WINDOW", rw.FlagWindow); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
guestReapInterval, err := envDuration("BACKEND_GUEST_REAP_INTERVAL", defaultGuestReapInterval)
|
guestReapInterval, err := envDuration("BACKEND_GUEST_REAP_INTERVAL", defaultGuestReapInterval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
@@ -131,6 +143,7 @@ func Load() (Config, error) {
|
|||||||
Game: gm,
|
Game: gm,
|
||||||
Lobby: lb,
|
Lobby: lb,
|
||||||
Robot: rb,
|
Robot: rb,
|
||||||
|
RateWatch: rw,
|
||||||
SMTP: smtp,
|
SMTP: smtp,
|
||||||
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
||||||
GuestReapInterval: guestReapInterval,
|
GuestReapInterval: guestReapInterval,
|
||||||
@@ -170,6 +183,9 @@ func (c Config) validate() error {
|
|||||||
if err := c.Robot.Validate(); err != nil {
|
if err := c.Robot.Validate(); err != nil {
|
||||||
return fmt.Errorf("config: %w", err)
|
return fmt.Errorf("config: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := c.RateWatch.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("config: %w", err)
|
||||||
|
}
|
||||||
if c.GuestReapInterval <= 0 {
|
if c.GuestReapInterval <= 0 {
|
||||||
return fmt.Errorf("config: BACKEND_GUEST_REAP_INTERVAL must be positive")
|
return fmt.Errorf("config: BACKEND_GUEST_REAP_INTERVAL must be positive")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
// AlphabetEntry is one letter of a variant's alphabet: its alphabet-index byte, the
|
// AlphabetEntry is one letter of a variant's alphabet: its alphabet-index byte, the
|
||||||
// concrete character and its tile point value. It is the dictionary-independent display
|
// concrete character and its tile point value. It is the dictionary-independent display
|
||||||
// table the edge sends to the client (Stage 13), produced from the variant's solver
|
// table the edge sends to the client, produced from the variant's solver
|
||||||
// ruleset (its alphabet and value table) and so pinned by the solver version, not by any
|
// ruleset (its alphabet and value table) and so pinned by the solver version, not by any
|
||||||
// dictionary.
|
// dictionary.
|
||||||
type AlphabetEntry struct {
|
type AlphabetEntry struct {
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import (
|
|||||||
|
|
||||||
// TestAlphabetTableEnglish pins the English table against the solver ruleset: 26 letters,
|
// TestAlphabetTableEnglish pins the English table against the solver ruleset: 26 letters,
|
||||||
// contiguous indices, the concrete lower-case characters the solver emits and the standard
|
// contiguous indices, the concrete lower-case characters the solver emits and the standard
|
||||||
// tile values. This is the real parity check the UI no longer carries (Stage 13).
|
// tile values. This is the real parity check the UI no longer carries.
|
||||||
func TestAlphabetTableEnglish(t *testing.T) {
|
func TestAlphabetTableEnglish(t *testing.T) {
|
||||||
tab, err := AlphabetTable(VariantEnglish)
|
tab, err := AlphabetTable(VariantEnglish)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AlphabetTable(english): %v", err)
|
t.Fatalf("AlphabetTable(scrabble_en): %v", err)
|
||||||
}
|
}
|
||||||
if len(tab) != 26 {
|
if len(tab) != 26 {
|
||||||
t.Fatalf("size = %d, want 26", len(tab))
|
t.Fatalf("size = %d, want 26", len(tab))
|
||||||
@@ -40,23 +40,23 @@ func TestAlphabetTableEnglish(t *testing.T) {
|
|||||||
func TestAlphabetTableRussianVariants(t *testing.T) {
|
func TestAlphabetTableRussianVariants(t *testing.T) {
|
||||||
ru, err := AlphabetTable(VariantRussianScrabble)
|
ru, err := AlphabetTable(VariantRussianScrabble)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AlphabetTable(russian_scrabble): %v", err)
|
t.Fatalf("AlphabetTable(scrabble_ru): %v", err)
|
||||||
}
|
}
|
||||||
er, err := AlphabetTable(VariantErudit)
|
er, err := AlphabetTable(VariantErudit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AlphabetTable(erudit): %v", err)
|
t.Fatalf("AlphabetTable(erudit_ru): %v", err)
|
||||||
}
|
}
|
||||||
if len(ru) != 33 || len(er) != 33 {
|
if len(ru) != 33 || len(er) != 33 {
|
||||||
t.Fatalf("sizes = %d/%d, want 33/33", len(ru), len(er))
|
t.Fatalf("sizes = %d/%d, want 33/33", len(ru), len(er))
|
||||||
}
|
}
|
||||||
if ru[0].Letter != "а" || ru[0].Value != 1 {
|
if ru[0].Letter != "а" || ru[0].Value != 1 {
|
||||||
t.Errorf("russian entry 0 = %q/%d, want а/1", ru[0].Letter, ru[0].Value)
|
t.Errorf("scrabble_ru entry 0 = %q/%d, want а/1", ru[0].Letter, ru[0].Value)
|
||||||
}
|
}
|
||||||
if ru[6].Letter != "ё" || ru[6].Value != 3 {
|
if ru[6].Letter != "ё" || ru[6].Value != 3 {
|
||||||
t.Errorf("russian ё (entry 6) = %q/%d, want ё/3", ru[6].Letter, ru[6].Value)
|
t.Errorf("scrabble_ru ё (entry 6) = %q/%d, want ё/3", ru[6].Letter, ru[6].Value)
|
||||||
}
|
}
|
||||||
if er[6].Letter != "ё" || er[6].Value != 0 {
|
if er[6].Letter != "ё" || er[6].Value != 0 {
|
||||||
t.Errorf("erudit ё (entry 6) = %q/%d, want ё/0", er[6].Letter, er[6].Value)
|
t.Errorf("erudit_ru ё (entry 6) = %q/%d, want ё/0", er[6].Letter, er[6].Value)
|
||||||
}
|
}
|
||||||
if ru[32].Letter != "я" || er[32].Letter != "я" {
|
if ru[32].Letter != "я" || er[32].Letter != "я" {
|
||||||
t.Errorf("last letter = %q/%q, want я/я", ru[32].Letter, er[32].Letter)
|
t.Errorf("last letter = %q/%q, want я/я", ru[32].Letter, er[32].Letter)
|
||||||
|
|||||||
@@ -168,10 +168,10 @@ func TestRegistryLookup(t *testing.T) {
|
|||||||
word string
|
word string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{"english hit", VariantEnglish, "cat", true},
|
{"scrabble_en hit", VariantEnglish, "cat", true},
|
||||||
{"english miss", VariantEnglish, "zzzz", false},
|
{"scrabble_en miss", VariantEnglish, "zzzz", false},
|
||||||
{"russian hit", VariantRussianScrabble, "кот", true},
|
{"scrabble_ru hit", VariantRussianScrabble, "кот", true},
|
||||||
{"erudit hit", VariantErudit, "кот", true},
|
{"erudit_ru hit", VariantErudit, "кот", true},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -38,15 +38,25 @@ const (
|
|||||||
func (v Variant) String() string {
|
func (v Variant) String() string {
|
||||||
switch v {
|
switch v {
|
||||||
case VariantEnglish:
|
case VariantEnglish:
|
||||||
return "english"
|
return "scrabble_en"
|
||||||
case VariantRussianScrabble:
|
case VariantRussianScrabble:
|
||||||
return "russian_scrabble"
|
return "scrabble_ru"
|
||||||
case VariantErudit:
|
case VariantErudit:
|
||||||
return "erudit"
|
return "erudit_ru"
|
||||||
}
|
}
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Language returns the variant's interface/bot language tag: "en" for English Scrabble, "ru" for
|
||||||
|
// the Russian variants (Russian Scrabble and Erudite). It routes a game's out-of-app push to the
|
||||||
|
// matching per-language Telegram bot — by the game, not the recipient's last-login bot.
|
||||||
|
func (v Variant) Language() string {
|
||||||
|
if v == VariantEnglish {
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
return "ru"
|
||||||
|
}
|
||||||
|
|
||||||
// ruleset returns the scrabble-solver ruleset backing the variant and true, or
|
// ruleset returns the scrabble-solver ruleset backing the variant and true, or
|
||||||
// (nil, false) for an unrecognised variant.
|
// (nil, false) for an unrecognised variant.
|
||||||
func (v Variant) ruleset() (*rules.Ruleset, bool) {
|
func (v Variant) ruleset() (*rules.Ruleset, bool) {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func TestRegistryValidatesKnownWords(t *testing.T) {
|
|||||||
func TestRegistryUnknownLookups(t *testing.T) {
|
func TestRegistryUnknownLookups(t *testing.T) {
|
||||||
reg, err := Open(testDictDir(), testVersion, VariantEnglish)
|
reg, err := Open(testDictDir(), testVersion, VariantEnglish)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open english-only registry: %v", err)
|
t.Fatalf("open scrabble_en-only registry: %v", err)
|
||||||
}
|
}
|
||||||
defer reg.Close()
|
defer reg.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -45,13 +45,13 @@ func TestLoadAvailableLoadsPresentSkipsAbsent(t *testing.T) {
|
|||||||
t.Fatalf("load available: %v", err)
|
t.Fatalf("load available: %v", err)
|
||||||
}
|
}
|
||||||
if len(loaded) != 1 || loaded[0] != VariantEnglish {
|
if len(loaded) != 1 || loaded[0] != VariantEnglish {
|
||||||
t.Fatalf("loaded = %v, want [english]", loaded)
|
t.Fatalf("loaded = %v, want [scrabble_en]", loaded)
|
||||||
}
|
}
|
||||||
if _, err := reg.Solver(VariantEnglish, "v2"); err != nil {
|
if _, err := reg.Solver(VariantEnglish, "v2"); err != nil {
|
||||||
t.Errorf("english v2 solver: %v", err)
|
t.Errorf("scrabble_en v2 solver: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := reg.Solver(VariantRussianScrabble, "v2"); !errors.Is(err, ErrUnknownVariant) {
|
if _, err := reg.Solver(VariantRussianScrabble, "v2"); !errors.Is(err, ErrUnknownVariant) {
|
||||||
t.Errorf("russian v2 should be absent: got %v", err)
|
t.Errorf("scrabble_ru v2 should be absent: got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,17 +77,17 @@ func TestOpenWithVersionsScansSubdirs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if got := reg.Versions(VariantEnglish); len(got) != 2 {
|
if got := reg.Versions(VariantEnglish); len(got) != 2 {
|
||||||
t.Errorf("english versions = %v, want two", got)
|
t.Errorf("scrabble_en versions = %v, want two", got)
|
||||||
}
|
}
|
||||||
latest, _, err := reg.Latest(VariantEnglish)
|
latest, _, err := reg.Latest(VariantEnglish)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("latest english: %v", err)
|
t.Fatalf("latest scrabble_en: %v", err)
|
||||||
}
|
}
|
||||||
if latest != "v2" {
|
if latest != "v2" {
|
||||||
t.Errorf("latest english = %q, want v2", latest)
|
t.Errorf("latest scrabble_en = %q, want v2", latest)
|
||||||
}
|
}
|
||||||
if got := reg.Versions(VariantRussianScrabble); len(got) != 1 {
|
if got := reg.Versions(VariantRussianScrabble); len(got) != 1 {
|
||||||
t.Errorf("russian versions = %v, want one (no v2 file)", got)
|
t.Errorf("scrabble_ru versions = %v, want one (no v2 file)", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ type DraftTile struct {
|
|||||||
Blank bool `json:"blank"`
|
Blank bool `json:"blank"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draft is a player's persisted client-side composition for a game (Stage 17): the
|
// Draft is a player's persisted client-side composition for a game: the
|
||||||
// preferred rack tile order and the board tiles laid but not yet submitted. The server
|
// preferred rack tile order and the board tiles laid but not yet submitted. The server
|
||||||
// keeps it so a reload or a second device resumes the same arrangement.
|
// keeps it so a reload or a second device resumes the same arrangement.
|
||||||
type Draft struct {
|
type Draft struct {
|
||||||
@@ -101,7 +101,7 @@ func (s *Store) clearDraft(ctx context.Context, gameID, accountID uuid.UUID) err
|
|||||||
|
|
||||||
// resetConflictingBoardDrafts clears the board_tiles of every OTHER player's draft that has
|
// resetConflictingBoardDrafts clears the board_tiles of every OTHER player's draft that has
|
||||||
// a tile on one of the just-committed cells, since that draft can no longer be placed; the
|
// a tile on one of the just-committed cells, since that draft can no longer be placed; the
|
||||||
// rack order is kept (Stage 17 #6).
|
// rack order is kept.
|
||||||
func (s *Store) resetConflictingBoardDrafts(ctx context.Context, gameID, actorID uuid.UUID, cells []DraftTile) error {
|
func (s *Store) resetConflictingBoardDrafts(ctx context.Context, gameID, actorID uuid.UUID, cells []DraftTile) error {
|
||||||
if len(cells) == 0 {
|
if len(cells) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package game
|
package game
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// recordingPublisher captures every published intent for assertions.
|
// recordingPublisher captures every published intent for assertions.
|
||||||
@@ -29,13 +31,17 @@ func TestEmitMoveNotifiesActor(t *testing.T) {
|
|||||||
ToMove: 1,
|
ToMove: 1,
|
||||||
TurnStartedAt: time.Now(),
|
TurnStartedAt: time.Now(),
|
||||||
TurnTimeout: time.Hour,
|
TurnTimeout: time.Hour,
|
||||||
Seats: []Seat{{Seat: 0, AccountID: actor}, {Seat: 1, AccountID: opp}},
|
Seats: []Seat{{Seat: 0, AccountID: actor, Score: 19}, {Seat: 1, AccountID: opp, Score: 13}},
|
||||||
}
|
}
|
||||||
svc.emitMove(g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Score: 10, Total: 10})
|
svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Words: []string{"HELLO"}, Score: 10, Total: 19}, 80)
|
||||||
|
|
||||||
kinds := map[uuid.UUID][]string{}
|
kinds := map[uuid.UUID][]string{}
|
||||||
|
var yourTurn notify.Intent
|
||||||
for _, in := range pub.intents {
|
for _, in := range pub.intents {
|
||||||
kinds[in.UserID] = append(kinds[in.UserID], in.Kind)
|
kinds[in.UserID] = append(kinds[in.UserID], in.Kind)
|
||||||
|
if in.UserID == opp && in.Kind == notify.KindYourTurn {
|
||||||
|
yourTurn = in
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !slices.Contains(kinds[actor], notify.KindOpponentMoved) {
|
if !slices.Contains(kinds[actor], notify.KindOpponentMoved) {
|
||||||
t.Errorf("actor should get opponent_moved, got %v", kinds[actor])
|
t.Errorf("actor should get opponent_moved, got %v", kinds[actor])
|
||||||
@@ -49,4 +55,58 @@ func TestEmitMoveNotifiesActor(t *testing.T) {
|
|||||||
if slices.Contains(kinds[actor], notify.KindYourTurn) {
|
if slices.Contains(kinds[actor], notify.KindYourTurn) {
|
||||||
t.Errorf("actor is not next to move, should not get your_turn")
|
t.Errorf("actor is not next to move, should not get your_turn")
|
||||||
}
|
}
|
||||||
|
// The your_turn push is enriched: the last move's action and word, and a recipient-first
|
||||||
|
// score line (the next mover, seat 1, first). The opponent name needs the account store and
|
||||||
|
// is left empty by this store-less unit (covered at the render layer).
|
||||||
|
yt := fb.GetRootAsYourTurnEvent(yourTurn.Payload, 0)
|
||||||
|
if got := string(yt.LastAction()); got != "play" {
|
||||||
|
t.Errorf("your_turn last_action = %q, want play", got)
|
||||||
|
}
|
||||||
|
if got := string(yt.LastWord()); got != "HELLO" {
|
||||||
|
t.Errorf("your_turn last_word = %q, want HELLO", got)
|
||||||
|
}
|
||||||
|
if got := string(yt.ScoreLine()); got != "13:19" { // seat 1 (recipient) first, then seat 0
|
||||||
|
t.Errorf("your_turn score_line = %q, want 13:19", got)
|
||||||
|
}
|
||||||
|
// Routed out-of-app by the game's language (the default Variant is English).
|
||||||
|
if yourTurn.Language != "en" {
|
||||||
|
t.Errorf("your_turn language = %q, want en", yourTurn.Language)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmitMoveAnnouncesGameOver checks the closing move sends a game_over push to every seat,
|
||||||
|
// each with its own outcome and a recipient-first final score.
|
||||||
|
func TestEmitMoveAnnouncesGameOver(t *testing.T) {
|
||||||
|
winner, loser := uuid.New(), uuid.New()
|
||||||
|
pub := &recordingPublisher{}
|
||||||
|
svc := &Service{pub: pub}
|
||||||
|
g := Game{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Status: StatusFinished,
|
||||||
|
Players: 2,
|
||||||
|
EndReason: "out_of_tiles",
|
||||||
|
Seats: []Seat{{Seat: 0, AccountID: winner, Score: 120, IsWinner: true}, {Seat: 1, AccountID: loser, Score: 95}},
|
||||||
|
}
|
||||||
|
svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Total: 120}, 0)
|
||||||
|
|
||||||
|
over := map[uuid.UUID]notify.Intent{}
|
||||||
|
for _, in := range pub.intents {
|
||||||
|
if in.Kind == notify.KindGameOver {
|
||||||
|
over[in.UserID] = in
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(over) != 2 {
|
||||||
|
t.Fatalf("game_over should reach both seats, got %d", len(over))
|
||||||
|
}
|
||||||
|
w := fb.GetRootAsGameOverEvent(over[winner].Payload, 0)
|
||||||
|
if string(w.Result()) != "won" || string(w.ScoreLine()) != "120:95" {
|
||||||
|
t.Errorf("winner game_over = %q / %q, want won / 120:95", w.Result(), w.ScoreLine())
|
||||||
|
}
|
||||||
|
l := fb.GetRootAsGameOverEvent(over[loser].Payload, 0)
|
||||||
|
if string(l.Result()) != "lost" || string(l.ScoreLine()) != "95:120" {
|
||||||
|
t.Errorf("loser game_over = %q / %q, want lost / 95:120", l.Result(), l.ScoreLine())
|
||||||
|
}
|
||||||
|
if over[winner].Language != "en" || over[loser].Language != "en" {
|
||||||
|
t.Errorf("game_over languages = %q/%q, want en/en", over[winner].Language, over[loser].Language)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The mappers below project the game domain into the wire-agnostic notify.* input
|
||||||
|
// structs the enriched live events carry. They keep the wire schema out of the
|
||||||
|
// game package: notify owns the FlatBuffers encoding, this file only resolves the
|
||||||
|
// values (seat display names, last-activity sort key) into its input shapes.
|
||||||
|
|
||||||
|
// gameSummary projects a game.Game into the notify.GameSummary embedded in enriched
|
||||||
|
// events. names is the seat-indexed display-name slice from seatNames; LastActivityUnix
|
||||||
|
// mirrors the gateway view (the current turn's start while active, the finish time once
|
||||||
|
// finished).
|
||||||
|
func gameSummary(g Game, names []string) notify.GameSummary {
|
||||||
|
seats := make([]notify.SeatStanding, 0, len(g.Seats))
|
||||||
|
for _, s := range g.Seats {
|
||||||
|
name := ""
|
||||||
|
if s.Seat >= 0 && s.Seat < len(names) {
|
||||||
|
name = names[s.Seat]
|
||||||
|
}
|
||||||
|
seats = append(seats, notify.SeatStanding{
|
||||||
|
Seat: s.Seat,
|
||||||
|
AccountID: s.AccountID.String(),
|
||||||
|
DisplayName: name,
|
||||||
|
Score: s.Score,
|
||||||
|
HintsUsed: s.HintsUsed,
|
||||||
|
IsWinner: s.IsWinner,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
last := g.TurnStartedAt
|
||||||
|
if g.FinishedAt != nil {
|
||||||
|
last = *g.FinishedAt
|
||||||
|
}
|
||||||
|
return notify.GameSummary{
|
||||||
|
ID: g.ID.String(),
|
||||||
|
Variant: g.Variant.String(),
|
||||||
|
DictVersion: g.DictVersion,
|
||||||
|
Status: g.Status,
|
||||||
|
Players: g.Players,
|
||||||
|
ToMove: g.ToMove,
|
||||||
|
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||||
|
MoveCount: g.MoveCount,
|
||||||
|
EndReason: g.EndReason,
|
||||||
|
Seats: seats,
|
||||||
|
LastActivityUnix: last.Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// playerState projects a StateView into the notify.PlayerState carried by the
|
||||||
|
// match_found / game_started events. The rack is re-encoded to wire alphabet indices;
|
||||||
|
// the variant alphabet display table is embedded when includeAlphabet is set (an
|
||||||
|
// initial view whose recipient may not have cached the variant yet).
|
||||||
|
func playerState(v StateView, names []string, includeAlphabet bool) (notify.PlayerState, error) {
|
||||||
|
rack, err := engine.EncodeRack(v.Game.Variant, v.Rack)
|
||||||
|
if err != nil {
|
||||||
|
return notify.PlayerState{}, err
|
||||||
|
}
|
||||||
|
ps := notify.PlayerState{
|
||||||
|
Game: gameSummary(v.Game, names),
|
||||||
|
Seat: v.Seat,
|
||||||
|
Rack: rack,
|
||||||
|
BagLen: v.BagLen,
|
||||||
|
HintsRemaining: v.HintsRemaining,
|
||||||
|
}
|
||||||
|
if includeAlphabet {
|
||||||
|
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||||
|
if err != nil {
|
||||||
|
return notify.PlayerState{}, err
|
||||||
|
}
|
||||||
|
ps.Alphabet = make([]notify.AlphabetLetter, len(tab))
|
||||||
|
for i, e := range tab {
|
||||||
|
ps.Alphabet[i] = notify.AlphabetLetter{Index: int(e.Index), Letter: e.Letter, Value: e.Value}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ps, nil
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ func TestWriteGCG(t *testing.T) {
|
|||||||
"#character-encoding UTF-8",
|
"#character-encoding UTF-8",
|
||||||
"#player1 p1 Alice",
|
"#player1 p1 Alice",
|
||||||
"#player2 p2 Bob",
|
"#player2 p2 Bob",
|
||||||
"#lexicon english/v1",
|
"#lexicon scrabble_en/v1",
|
||||||
"#title game 00000000-0000-7000-8000-000000000001",
|
"#title game 00000000-0000-7000-8000-000000000001",
|
||||||
">p1: CATSER? 8H CAT +10 10",
|
">p1: CATSER? 8H CAT +10 10",
|
||||||
">p2: AS?E I8 .s +2 2",
|
">p2: AS?E I8 .s +2 2",
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func TestGameCacheEviction(t *testing.T) {
|
|||||||
cur := time.Unix(1_700_000_000, 0)
|
cur := time.Unix(1_700_000_000, 0)
|
||||||
cache := newGameCache(time.Hour, func() time.Time { return cur })
|
cache := newGameCache(time.Hour, func() time.Time { return cur })
|
||||||
id := uuid.New()
|
id := uuid.New()
|
||||||
cache.put(id, nil, "english")
|
cache.put(id, nil, "scrabble_en")
|
||||||
if _, ok := cache.get(id); !ok {
|
if _, ok := cache.get(id); !ok {
|
||||||
t.Fatal("game must be resident after put")
|
t.Fatal("game must be resident after put")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
const meterName = "scrabble/backend/game"
|
const meterName = "scrabble/backend/game"
|
||||||
|
|
||||||
// gameMetrics holds the game domain's operational instruments. Every game-scoped
|
// gameMetrics holds the game domain's operational instruments. Every game-scoped
|
||||||
// measurement carries a "variant" attribute (english/russian/erudit). The
|
// measurement carries a "variant" attribute (scrabble_en/scrabble_ru/erudit_ru). The
|
||||||
// instruments default to no-ops (see defaultGameMetrics), so recording is always
|
// instruments default to no-ops (see defaultGameMetrics), so recording is always
|
||||||
// safe; SetMetrics installs the real meter during startup wiring.
|
// safe; SetMetrics installs the real meter during startup wiring.
|
||||||
type gameMetrics struct {
|
type gameMetrics struct {
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ func TestGameMetrics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
started := counterByAttr(t, rm, "games_started_total", "variant")
|
started := counterByAttr(t, rm, "games_started_total", "variant")
|
||||||
if started["english"] != 2 || started["russian_scrabble"] != 1 {
|
if started["scrabble_en"] != 2 || started["scrabble_ru"] != 1 {
|
||||||
t.Errorf("games_started_total = %v, want english:2 russian_scrabble:1", started)
|
t.Errorf("games_started_total = %v, want scrabble_en:2 scrabble_ru:1", started)
|
||||||
}
|
}
|
||||||
if abandoned := counterByAttr(t, rm, "games_abandoned_total", "variant"); abandoned["erudit"] != 1 {
|
if abandoned := counterByAttr(t, rm, "games_abandoned_total", "variant"); abandoned["erudit_ru"] != 1 {
|
||||||
t.Errorf("games_abandoned_total = %v, want erudit:1", abandoned)
|
t.Errorf("games_abandoned_total = %v, want erudit_ru:1", abandoned)
|
||||||
}
|
}
|
||||||
if c := histogramCount(t, rm, "game_replay_duration"); c != 1 {
|
if c := histogramCount(t, rm, "game_replay_duration"); c != 1 {
|
||||||
t.Errorf("game_replay_duration observations = %d, want 1", c)
|
t.Errorf("game_replay_duration observations = %d, want 1", c)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -216,11 +217,21 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo
|
|||||||
|
|
||||||
// GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet
|
// GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet
|
||||||
// indices to concrete letters before delegating to the letter-based play, exchange and
|
// indices to concrete letters before delegating to the letter-based play, exchange and
|
||||||
// word-check methods (Stage 13), keeping a single domain path shared with the robot.
|
// word-check methods, keeping a single domain path shared with the robot.
|
||||||
func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.Variant, error) {
|
func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.Variant, error) {
|
||||||
return svc.store.GetGameVariant(ctx, gameID)
|
return svc.store.GetGameVariant(ctx, gameID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GameLanguage returns the game's language tag ("en"/"ru"), derived from its variant, so a
|
||||||
|
// game push routes out-of-app to the game's own bot rather than the recipient's last-login bot.
|
||||||
|
func (svc *Service) GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error) {
|
||||||
|
v, err := svc.GameVariant(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return v.Language(), nil
|
||||||
|
}
|
||||||
|
|
||||||
// RobotSchedule returns a game's bag seed and turn-start time, for the admin console's
|
// RobotSchedule returns a game's bag seed and turn-start time, for the admin console's
|
||||||
// robot-schedule panel (the deterministic play-to-win intent and next-move ETA).
|
// robot-schedule panel (the deterministic play-to-win intent and next-move ETA).
|
||||||
func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed int64, turnStartedAt time.Time, err error) {
|
func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed int64, turnStartedAt time.Time, err error) {
|
||||||
@@ -229,7 +240,7 @@ func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed i
|
|||||||
|
|
||||||
// LastMoveAt returns the time of an account's most recent move in a game (and whether it
|
// LastMoveAt returns the time of an account's most recent move in a game (and whether it
|
||||||
// has moved). The social service uses it to reset the nudge cooldown once a player has
|
// has moved). The social service uses it to reset the nudge cooldown once a player has
|
||||||
// taken a turn (Stage 17).
|
// taken a turn.
|
||||||
func (svc *Service) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
|
func (svc *Service) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
|
||||||
return svc.store.LastMoveAt(ctx, gameID, accountID)
|
return svc.store.LastMoveAt(ctx, gameID, accountID)
|
||||||
}
|
}
|
||||||
@@ -279,10 +290,10 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
|
|||||||
// Record the seat's think time (turn start to commit) for the move-duration
|
// Record the seat's think time (turn start to commit) for the move-duration
|
||||||
// metric; the timeout path commits separately and is excluded by design.
|
// metric; the timeout path commits separately and is excluded by design.
|
||||||
svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt))
|
svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt))
|
||||||
return MoveResult{Move: rec, Game: post}, nil
|
return MoveResult{Move: rec, Game: post, Rack: g.Hand(seat), BagLen: g.BagLen()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// afterCommitDrafts maintains the Stage 17 drafts after a committed move: the actor's own
|
// afterCommitDrafts maintains the drafts after a committed move: the actor's own
|
||||||
// composition is consumed, so clear it; a play's tiles may overlap an opponent's board
|
// composition is consumed, so clear it; a play's tiles may overlap an opponent's board
|
||||||
// draft, which is then reset. Best-effort — the move is already committed, so a draft
|
// draft, which is then reset. Best-effort — the move is already committed, so a draft
|
||||||
// cleanup failure is logged rather than failing the move.
|
// cleanup failure is logged rather than failing the move.
|
||||||
@@ -350,7 +361,7 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Game{}, err
|
return Game{}, err
|
||||||
}
|
}
|
||||||
svc.emitMove(post, rec)
|
svc.emitMove(ctx, post, rec, g.BagLen())
|
||||||
return post, nil
|
return post, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,20 +372,102 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
|
|||||||
// out-of-app push), so the actor is not notified out of band about their own move.
|
// out-of-app push), so the actor is not notified out of band about their own move.
|
||||||
// Delivery is best-effort (notify.Publisher never blocks) and the gateway fans each
|
// Delivery is best-effort (notify.Publisher never blocks) and the gateway fans each
|
||||||
// event out to all of the recipient's live streams.
|
// event out to all of the recipient's live streams.
|
||||||
func (svc *Service) emitMove(post Game, rec engine.MoveRecord) {
|
func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveRecord, bagLen int) {
|
||||||
intents := make([]notify.Intent, 0, len(post.Seats)+1)
|
// Resolve the seat names once and reuse them for every recipient's enriched summary.
|
||||||
|
names := svc.seatNames(ctx, post)
|
||||||
|
summary := gameSummary(post, names)
|
||||||
|
intents := make([]notify.Intent, 0, 2*len(post.Seats))
|
||||||
for _, s := range post.Seats {
|
for _, s := range post.Seats {
|
||||||
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total))
|
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen))
|
||||||
}
|
}
|
||||||
if post.Status == StatusActive {
|
// Game pushes are routed out-of-app by the game's own language, not the recipient's
|
||||||
|
// last-login bot.
|
||||||
|
lang := post.Variant.Language()
|
||||||
|
switch post.Status {
|
||||||
|
case StatusActive:
|
||||||
if next, ok := seatAccount(post.Seats, post.ToMove); ok {
|
if next, ok := seatAccount(post.Seats, post.ToMove); ok {
|
||||||
deadline := post.TurnStartedAt.Add(post.TurnTimeout)
|
deadline := post.TurnStartedAt.Add(post.TurnTimeout)
|
||||||
intents = append(intents, notify.YourTurn(next, post.ID, deadline))
|
action := rec.Action.String()
|
||||||
|
word := ""
|
||||||
|
if action == "play" && len(rec.Words) > 0 {
|
||||||
|
word = rec.Words[0]
|
||||||
|
}
|
||||||
|
opponent := svc.displayName(ctx, post.Seats, rec.Player)
|
||||||
|
yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove), post.MoveCount)
|
||||||
|
yourTurn.Language = lang
|
||||||
|
intents = append(intents, yourTurn)
|
||||||
|
}
|
||||||
|
case StatusFinished:
|
||||||
|
// The game just ended (any path: a closing play, all-pass, resign or timeout). Tell every
|
||||||
|
// seat, each with their own perspective + recipient-first score, so an offline player gets
|
||||||
|
// an out-of-app "game over" push (online players take it from the in-app refresh).
|
||||||
|
for _, s := range post.Seats {
|
||||||
|
over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary)
|
||||||
|
over.Language = lang
|
||||||
|
intents = append(intents, over)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
svc.pub.Publish(intents...)
|
svc.pub.Publish(intents...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// displayName resolves the display name of the account at the given seat, or "" when the seat
|
||||||
|
// is absent or the lookup fails (the enriched push then falls back to its plain text).
|
||||||
|
func (svc *Service) displayName(ctx context.Context, seats []Seat, seat int) string {
|
||||||
|
if svc.accounts == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
id, ok := seatAccount(seats, seat)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
acc, err := svc.accounts.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return acc.DisplayName
|
||||||
|
}
|
||||||
|
|
||||||
|
// scoreLine formats the running scores with recipientSeat's score first, then the remaining
|
||||||
|
// seats in seat order, colon-joined (e.g. "120:95:80") — the recipient-first form used in the
|
||||||
|
// out-of-app notifications.
|
||||||
|
func scoreLine(g Game, recipientSeat int) string {
|
||||||
|
n := len(g.Seats)
|
||||||
|
bySeat := make([]int, n)
|
||||||
|
for _, s := range g.Seats {
|
||||||
|
if s.Seat >= 0 && s.Seat < n {
|
||||||
|
bySeat[s.Seat] = s.Score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, n)
|
||||||
|
if recipientSeat >= 0 && recipientSeat < n {
|
||||||
|
parts = append(parts, strconv.Itoa(bySeat[recipientSeat]))
|
||||||
|
}
|
||||||
|
for seat := 0; seat < n; seat++ {
|
||||||
|
if seat != recipientSeat {
|
||||||
|
parts = append(parts, strconv.Itoa(bySeat[seat]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ":")
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatResult reports the finished-game outcome from recipientSeat's perspective: "draw" when no
|
||||||
|
// seat is flagged the winner, "won" when recipientSeat is, otherwise "lost".
|
||||||
|
func seatResult(seats []Seat, recipientSeat int) string {
|
||||||
|
winner := false
|
||||||
|
for _, s := range seats {
|
||||||
|
if s.IsWinner {
|
||||||
|
winner = true
|
||||||
|
if s.Seat == recipientSeat {
|
||||||
|
return "won"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !winner {
|
||||||
|
return "draw"
|
||||||
|
}
|
||||||
|
return "lost"
|
||||||
|
}
|
||||||
|
|
||||||
// seatAccount returns the account seated at the given seat index, or false when
|
// seatAccount returns the account seated at the given seat index, or false when
|
||||||
// no seat matches (the slice is not assumed to be ordered by seat).
|
// no seat matches (the slice is not assumed to be ordered by seat).
|
||||||
func seatAccount(seats []Seat, seat int) (uuid.UUID, bool) {
|
func seatAccount(seats []Seat, seat int) (uuid.UUID, bool) {
|
||||||
@@ -694,6 +787,20 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitialState returns accountID's full initial view of game gameID as the notify
|
||||||
|
// PlayerState carried by the match_found / game_started events, so a client can
|
||||||
|
// render a freshly started game from the event without a follow-up fetch. The variant
|
||||||
|
// alphabet table is always embedded (the recipient may be seeing the variant for the
|
||||||
|
// first time). It satisfies lobby.GameCreator.
|
||||||
|
func (svc *Service) InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error) {
|
||||||
|
v, err := svc.GameState(ctx, gameID, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return notify.PlayerState{}, err
|
||||||
|
}
|
||||||
|
names := svc.seatNames(ctx, v.Game)
|
||||||
|
return playerState(v, names, true)
|
||||||
|
}
|
||||||
|
|
||||||
// Participants returns the seated account IDs in seat order, the seat index whose
|
// Participants returns the seated account IDs in seat order, the seat index whose
|
||||||
// turn it is, and the game status. It is a snapshot read (no engine, no lock) that
|
// turn it is, and the game status. It is a snapshot read (no engine, no lock) that
|
||||||
// lets the social package gate per-game chat and nudges without importing the
|
// lets the social package gate per-game chat and nudges without importing the
|
||||||
@@ -727,6 +834,30 @@ func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]
|
|||||||
return svc.store.ListGamesForAccount(ctx, accountID)
|
return svc.store.ListGamesForAccount(ctx, accountID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HideGame hides a finished game from accountID's own lobby (it stays visible to the other
|
||||||
|
// players); it is irreversible by design. Only a player of a finished game may hide it
|
||||||
|
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op.
|
||||||
|
func (svc *Service) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
|
||||||
|
g, err := svc.store.GetGame(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
seated := false
|
||||||
|
for _, s := range g.Seats {
|
||||||
|
if s.AccountID == accountID {
|
||||||
|
seated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !seated {
|
||||||
|
return ErrNotAPlayer
|
||||||
|
}
|
||||||
|
if g.Status != StatusFinished {
|
||||||
|
return ErrGameActive
|
||||||
|
}
|
||||||
|
return svc.store.HideGame(ctx, accountID, gameID)
|
||||||
|
}
|
||||||
|
|
||||||
// GameByID returns a game with its seats for the admin console detail view.
|
// GameByID returns a game with its seats for the admin console detail view.
|
||||||
func (svc *Service) GameByID(ctx context.Context, id uuid.UUID) (Game, error) {
|
func (svc *Service) GameByID(ctx context.Context, id uuid.UUID) (Game, error) {
|
||||||
return svc.store.GetGame(ctx, id)
|
return svc.store.GetGame(ctx, id)
|
||||||
@@ -894,6 +1025,9 @@ func (svc *Service) nonGuestSeats(ctx context.Context, seats []Seat) ([]Seat, er
|
|||||||
// seatNames resolves each seat's display name for GCG export.
|
// seatNames resolves each seat's display name for GCG export.
|
||||||
func (svc *Service) seatNames(ctx context.Context, g Game) []string {
|
func (svc *Service) seatNames(ctx context.Context, g Game) []string {
|
||||||
names := make([]string, g.Players)
|
names := make([]string, g.Players)
|
||||||
|
if svc.accounts == nil {
|
||||||
|
return names
|
||||||
|
}
|
||||||
for _, s := range g.Seats {
|
for _, s := range g.Seats {
|
||||||
if acc, err := svc.accounts.GetByID(ctx, s.AccountID); err == nil {
|
if acc, err := svc.accounts.GetByID(ctx, s.AccountID); err == nil {
|
||||||
names[s.Seat] = acc.DisplayName
|
names[s.Seat] = acc.DisplayName
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetGameVariant reads just a game's variant — a cheap single-column lookup the edge uses
|
// GetGameVariant reads just a game's variant — a cheap single-column lookup the edge uses
|
||||||
// to map wire alphabet indices to concrete letters (Stage 13) without loading the whole
|
// to map wire alphabet indices to concrete letters without loading the whole
|
||||||
// game and its seats.
|
// game and its seats.
|
||||||
func (s *Store) GetGameVariant(ctx context.Context, id uuid.UUID) (engine.Variant, error) {
|
func (s *Store) GetGameVariant(ctx context.Context, id uuid.UUID) (engine.Variant, error) {
|
||||||
stmt := postgres.SELECT(table.Games.Variant).
|
stmt := postgres.SELECT(table.Games.Variant).
|
||||||
@@ -186,6 +186,23 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
|
|||||||
if len(grows) == 0 {
|
if len(grows) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
// Drop games the account has hidden from its own lobby.
|
||||||
|
hidden, err := s.hiddenGameIDs(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(hidden) > 0 {
|
||||||
|
kept := grows[:0]
|
||||||
|
for _, g := range grows {
|
||||||
|
if !hidden[g.GameID] {
|
||||||
|
kept = append(kept, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
grows = kept
|
||||||
|
if len(grows) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ids := make([]postgres.Expression, len(grows))
|
ids := make([]postgres.Expression, len(grows))
|
||||||
for i, g := range grows {
|
for i, g := range grows {
|
||||||
@@ -215,6 +232,36 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HideGame hides a game from the account's own lobby list (idempotent). The caller validates the
|
||||||
|
// game is finished and the account is a player.
|
||||||
|
func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
|
||||||
|
_, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO backend.game_hidden (account_id, game_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||||
|
accountID, gameID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("game: hide game: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hiddenGameIDs returns the set of games the account has hidden from its lobby.
|
||||||
|
func (s *Store) hiddenGameIDs(ctx context.Context, accountID uuid.UUID) (map[uuid.UUID]bool, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT game_id FROM backend.game_hidden WHERE account_id = $1`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("game: hidden ids: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[uuid.UUID]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id uuid.UUID
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, fmt.Errorf("game: scan hidden id: %w", err)
|
||||||
|
}
|
||||||
|
out[id] = true
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// ListGames returns games for the admin games list, most-recently-updated first,
|
// ListGames returns games for the admin games list, most-recently-updated first,
|
||||||
// paginated. status filters by lifecycle ("active"/"finished") when non-empty.
|
// paginated. status filters by lifecycle ("active"/"finished") when non-empty.
|
||||||
// The seats are not loaded — the list shows summaries; the detail view uses
|
// The seats are not loaded — the list shows summaries; the detail view uses
|
||||||
@@ -653,7 +700,7 @@ func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) {
|
|||||||
|
|
||||||
// LastMoveAt returns the time of the account's most recent move in the game and true, or
|
// LastMoveAt returns the time of the account's most recent move in the game and true, or
|
||||||
// the zero time and false when it has not moved. The social service uses it to reset the
|
// the zero time and false when it has not moved. The social service uses it to reset the
|
||||||
// nudge cooldown once the player has taken a turn (Stage 17).
|
// nudge cooldown once the player has taken a turn.
|
||||||
func (s *Store) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
|
func (s *Store) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
|
||||||
var at sql.NullTime
|
var at sql.NullTime
|
||||||
err := s.db.QueryRowContext(ctx,
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ const (
|
|||||||
StatusFinished = "finished"
|
StatusFinished = "finished"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Complaint lifecycle values. A complaint is filed StatusComplaintOpen (Stage 3)
|
// Complaint lifecycle values. A complaint is filed StatusComplaintOpen
|
||||||
// and closed StatusComplaintResolved by the admin review queue (Stage 10) with a
|
// and closed StatusComplaintResolved by the admin review queue with a
|
||||||
// Disposition. The CHECK constraints live in migration 00008.
|
// Disposition. The CHECK constraints live in migration 00008.
|
||||||
const (
|
const (
|
||||||
StatusComplaintOpen = "open"
|
StatusComplaintOpen = "open"
|
||||||
@@ -124,10 +124,14 @@ func (g Game) seatOf(accountID uuid.UUID) (int, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MoveResult is the outcome of a committed transition: the decoded move and the
|
// MoveResult is the outcome of a committed transition: the decoded move and the
|
||||||
// post-move game.
|
// post-move game, plus the actor's own refilled rack and the bag size after the draw
|
||||||
|
// (Rack/BagLen), so the mover renders the next state from the response without a
|
||||||
|
// follow-up game.state.
|
||||||
type MoveResult struct {
|
type MoveResult struct {
|
||||||
Move engine.MoveRecord
|
Move engine.MoveRecord
|
||||||
Game Game
|
Game Game
|
||||||
|
Rack []string
|
||||||
|
BagLen int
|
||||||
}
|
}
|
||||||
|
|
||||||
// HintResult is a revealed hint and the requesting player's remaining hint
|
// HintResult is a revealed hint and the requesting player's remaining hint
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/game"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestAccountProvisionByIdentity covers find-or-create semantics, distinct
|
// TestAccountProvisionByIdentity covers find-or-create semantics, distinct
|
||||||
@@ -77,7 +80,7 @@ func TestAccountProvisionByIdentity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestGetStatsZeroForFreshAccount checks that an account with no finished games
|
// TestGetStatsZeroForFreshAccount checks that an account with no finished games
|
||||||
// reads back the zero statistics rather than an error (the Stage 8 stats screen).
|
// reads back the zero statistics rather than an error (the stats screen).
|
||||||
func TestGetStatsZeroForFreshAccount(t *testing.T) {
|
func TestGetStatsZeroForFreshAccount(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
store := account.NewStore(testDB)
|
store := account.NewStore(testDB)
|
||||||
@@ -108,7 +111,7 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool {
|
|||||||
// TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact
|
// TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact
|
||||||
// seeds the new account's language and display name from the launch fields,
|
// seeds the new account's language and display name from the launch fields,
|
||||||
// defaults the in-app-only flag on, and never overwrites an existing account on a
|
// defaults the in-app-only flag on, and never overwrites an existing account on a
|
||||||
// later login (Stage 9 language seeding).
|
// later login (language seeding).
|
||||||
func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
|
func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
store := account.NewStore(testDB)
|
store := account.NewStore(testDB)
|
||||||
@@ -195,6 +198,62 @@ func TestServiceLanguageRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHighRateFlagRoundTrip covers the soft high-rate marker: a fresh account
|
||||||
|
// is unflagged, FlagHighRate stamps it exactly once (a second sustained episode
|
||||||
|
// never moves the timestamp), ClearHighRateFlag reverses it, and a re-flag after
|
||||||
|
// the operator clear takes a fresh timestamp.
|
||||||
|
func TestHighRateFlagRoundTrip(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision telegram: %v", err)
|
||||||
|
}
|
||||||
|
if !acc.FlaggedHighRateAt.IsZero() {
|
||||||
|
t.Fatalf("fresh FlaggedHighRateAt = %v, want zero", acc.FlaggedHighRateAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
set, err := store.FlagHighRate(ctx, acc.ID, first)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("flag: %v", err)
|
||||||
|
}
|
||||||
|
if !set {
|
||||||
|
t.Fatal("first FlagHighRate reported not set")
|
||||||
|
}
|
||||||
|
if set, err = store.FlagHighRate(ctx, acc.ID, first.Add(time.Hour)); err != nil {
|
||||||
|
t.Fatalf("re-flag: %v", err)
|
||||||
|
} else if set {
|
||||||
|
t.Fatal("second FlagHighRate must not overwrite the marker")
|
||||||
|
}
|
||||||
|
got, err := store.GetByID(ctx, acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get by id: %v", err)
|
||||||
|
}
|
||||||
|
if !got.FlaggedHighRateAt.Equal(first) {
|
||||||
|
t.Errorf("FlaggedHighRateAt = %v, want %v", got.FlaggedHighRateAt, first)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.ClearHighRateFlag(ctx, acc.ID); err != nil {
|
||||||
|
t.Fatalf("clear: %v", err)
|
||||||
|
}
|
||||||
|
if got, err = store.GetByID(ctx, acc.ID); err != nil {
|
||||||
|
t.Fatalf("get by id: %v", err)
|
||||||
|
} else if !got.FlaggedHighRateAt.IsZero() {
|
||||||
|
t.Errorf("cleared FlaggedHighRateAt = %v, want zero", got.FlaggedHighRateAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
second := first.Add(24 * time.Hour)
|
||||||
|
if set, err = store.FlagHighRate(ctx, acc.ID, second); err != nil || !set {
|
||||||
|
t.Fatalf("re-flag after clear = (%v, %v), want (true, nil)", set, err)
|
||||||
|
}
|
||||||
|
if got, err = store.GetByID(ctx, acc.ID); err != nil {
|
||||||
|
t.Fatalf("get by id: %v", err)
|
||||||
|
} else if !got.FlaggedHighRateAt.Equal(second) {
|
||||||
|
t.Errorf("re-flagged FlaggedHighRateAt = %v, want %v", got.FlaggedHighRateAt, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestIdentityExternalID covers the reverse identity lookup the push-target route
|
// TestIdentityExternalID covers the reverse identity lookup the push-target route
|
||||||
// uses: it returns the external_id for the matching kind and ErrNotFound otherwise,
|
// uses: it returns the external_id for the matching kind and ErrNotFound otherwise,
|
||||||
// including for a guest that carries no identity.
|
// including for a guest that carries no identity.
|
||||||
@@ -222,7 +281,7 @@ func TestIdentityExternalID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNotificationsInAppOnlyRoundTrip checks the Stage 9 profile flag persists
|
// TestNotificationsInAppOnlyRoundTrip checks the profile flag persists
|
||||||
// through UpdateProfile and reads back through GetByID.
|
// through UpdateProfile and reads back through GetByID.
|
||||||
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
|
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -254,3 +313,59 @@ func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
|
|||||||
t.Error("GetByID still reports in-app-only after clearing")
|
t.Error("GetByID still reports in-app-only after clearing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGuestAutoMatchLeavesNoStats drives a guest through a full auto-match game
|
||||||
|
// against a robot to a natural end and checks the guest holds a seat (the
|
||||||
|
// game_players foreign key is satisfied) yet accrues no statistics, while the
|
||||||
|
// durable robot opponent does.
|
||||||
|
func TestGuestAutoMatchLeavesNoStats(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newGameService()
|
||||||
|
robots := newRobotService(t, svc)
|
||||||
|
if err := robots.EnsurePool(ctx); err != nil {
|
||||||
|
t.Fatalf("ensure pool: %v", err)
|
||||||
|
}
|
||||||
|
robotID, err := robots.Pick(engine.VariantEnglish)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pick: %v", err)
|
||||||
|
}
|
||||||
|
guest := provisionGuest(t)
|
||||||
|
seed := openingSeed(t)
|
||||||
|
|
||||||
|
g, err := svc.Create(ctx, game.CreateParams{
|
||||||
|
Variant: engine.VariantEnglish, Seats: []uuid.UUID{guest, robotID},
|
||||||
|
TurnTimeout: 24 * time.Hour, Seed: seed,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
const robotSeat = 1 // seats = [guest, robot]
|
||||||
|
|
||||||
|
finished := false
|
||||||
|
for i := 0; i < 400 && !finished; i++ {
|
||||||
|
_, toMove, status, err := svc.Participants(ctx, g.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("participants: %v", err)
|
||||||
|
}
|
||||||
|
if status != game.StatusActive {
|
||||||
|
finished = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if toMove == robotSeat {
|
||||||
|
setTurnStarted(t, g.ID, daytime.Add(-2*time.Hour))
|
||||||
|
robots.Drive(ctx, daytime)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
playHuman(t, ctx, svc, g.ID, guest)
|
||||||
|
}
|
||||||
|
if !finished {
|
||||||
|
t.Fatal("guest game did not finish within the move budget")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, _, _, _, ok := readStats(t, guest); ok {
|
||||||
|
t.Error("a guest must not accrue a statistics row")
|
||||||
|
}
|
||||||
|
if _, _, _, _, _, ok := readStats(t, robotID); !ok {
|
||||||
|
t.Error("the durable robot opponent should have a statistics row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/ratewatch"
|
||||||
"scrabble/backend/internal/server"
|
"scrabble/backend/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -168,7 +169,7 @@ func TestConsoleServesAndGuardsCSRF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestConsoleGameDetailRobotSchedule checks the admin game card surfaces a robot seat's
|
// TestConsoleGameDetailRobotSchedule checks the admin game card surfaces a robot seat's
|
||||||
// play-to-win intent and, while it is the robot's turn, its next-move ETA (Stage 17).
|
// play-to-win intent and, while it is the robot's turn, its next-move ETA.
|
||||||
func TestConsoleGameDetailRobotSchedule(t *testing.T) {
|
func TestConsoleGameDetailRobotSchedule(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
svc := newGameService()
|
svc := newGameService()
|
||||||
@@ -206,6 +207,72 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConsoleThrottledViewAndFlagClear drives the rate-limit surface end to
|
||||||
|
// end against real stores: a gateway report past the threshold auto-flags the
|
||||||
|
// account, the throttled view shows the episode and the flagged account, the
|
||||||
|
// user card carries the marker, and the operator clear (a same-origin POST)
|
||||||
|
// reverses it.
|
||||||
|
func TestConsoleThrottledViewAndFlagClear(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
accounts := account.NewStore(testDB)
|
||||||
|
acc, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch := ratewatch.New(ratewatch.Config{FlagThreshold: 100, FlagWindow: 10 * time.Minute}, accounts, zap.NewNop())
|
||||||
|
srv := server.New(":0", server.Deps{
|
||||||
|
Logger: zap.NewNop(),
|
||||||
|
Accounts: accounts,
|
||||||
|
Games: newGameService(),
|
||||||
|
Registry: testRegistry,
|
||||||
|
DictDir: dictDir(),
|
||||||
|
RateWatch: watch,
|
||||||
|
})
|
||||||
|
h := srv.Handler()
|
||||||
|
|
||||||
|
report := `{"window_seconds":30,"entries":[` +
|
||||||
|
`{"class":"user","key":"` + acc.ID.String() + `","rejected":150},` +
|
||||||
|
`{"class":"public","key":"10.1.2.3","rejected":7}]}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://admin.test/api/v1/internal/ratelimit/report", strings.NewReader(report))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("report = %d, want 204", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := accounts.GetByID(ctx, acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get by id: %v", err)
|
||||||
|
}
|
||||||
|
if got.FlaggedHighRateAt.IsZero() {
|
||||||
|
t.Fatal("account not auto-flagged past the threshold")
|
||||||
|
}
|
||||||
|
|
||||||
|
base := "http://admin.test/_gm"
|
||||||
|
code, body := consoleDo(h, http.MethodGet, base+"/throttled", "", "")
|
||||||
|
if code != http.StatusOK || !strings.Contains(body, acc.ID.String()) ||
|
||||||
|
!strings.Contains(body, "10.1.2.3") || !strings.Contains(body, "Throttled Player") {
|
||||||
|
t.Fatalf("throttled view = %d, episode/flag shown = %v/%v",
|
||||||
|
code, strings.Contains(body, "10.1.2.3"), strings.Contains(body, "Throttled Player"))
|
||||||
|
}
|
||||||
|
if code, body = consoleDo(h, http.MethodGet, base+"/users/"+acc.ID.String(), "", ""); code != http.StatusOK || !strings.Contains(body, "Clear high-rate flag") {
|
||||||
|
t.Fatalf("user card = %d, has clear action = %v", code, strings.Contains(body, "Clear high-rate flag"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clear POST is CSRF-guarded like every console action.
|
||||||
|
if code, _ = consoleDo(h, http.MethodPost, base+"/users/"+acc.ID.String()+"/clear-high-rate-flag", "", ""); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("clear without origin = %d, want 403", code)
|
||||||
|
}
|
||||||
|
if code, body = consoleDo(h, http.MethodPost, base+"/users/"+acc.ID.String()+"/clear-high-rate-flag", "x=1", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Cleared") {
|
||||||
|
t.Fatalf("clear with origin = %d, has Cleared = %v", code, strings.Contains(body, "Cleared"))
|
||||||
|
}
|
||||||
|
if got, err = accounts.GetByID(ctx, acc.ID); err != nil || !got.FlaggedHighRateAt.IsZero() {
|
||||||
|
t.Fatalf("flag survived the clear: %v (err %v)", got.FlaggedHighRateAt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// consoleDo issues a request to h, optionally with an Origin header, and returns
|
// consoleDo issues a request to h, optionally with an Origin header, and returns
|
||||||
// the status and body. Form bodies are sent as application/x-www-form-urlencoded.
|
// the status and body. Form bodies are sent as application/x-www-form-urlencoded.
|
||||||
func consoleDo(h http.Handler, method, target, body, origin string) (int, string) {
|
func consoleDo(h http.Handler, method, target, body, origin string) (int, string) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func TestMoveDurationAnalytics(t *testing.T) {
|
|||||||
t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||||
if _, err := testDB.ExecContext(ctx,
|
if _, err := testDB.ExecContext(ctx,
|
||||||
`INSERT INTO backend.games (game_id, variant, dict_version, seed, players, turn_timeout_secs, created_at)
|
`INSERT INTO backend.games (game_id, variant, dict_version, seed, players, turn_timeout_secs, created_at)
|
||||||
VALUES ($1,'english','v1',1,2,86400,$2)`, gid, t0); err != nil {
|
VALUES ($1,'scrabble_en','v1',1,2,86400,$2)`, gid, t0); err != nil {
|
||||||
t.Fatalf("insert game: %v", err)
|
t.Fatalf("insert game: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := testDB.ExecContext(ctx,
|
if _, err := testDB.ExecContext(ctx,
|
||||||
|
|||||||
@@ -5,36 +5,11 @@ package inttest
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"scrabble/backend/internal/engine"
|
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newDraftGame creates a started two-player English game on an opening seed and returns the
|
// TestDraftPersistAndConflictReset covers draft persistence: a round-trip of the
|
||||||
// service, game id, seats, and the opening play (from a mirror) used to drive a real commit.
|
|
||||||
func newDraftGame(t *testing.T) (*game.Service, uuid.UUID, []uuid.UUID, engine.MoveRecord) {
|
|
||||||
t.Helper()
|
|
||||||
ctx := context.Background()
|
|
||||||
svc := newGameService()
|
|
||||||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
|
||||||
seed := openingSeed(t)
|
|
||||||
g, err := svc.Create(ctx, game.CreateParams{
|
|
||||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create: %v", err)
|
|
||||||
}
|
|
||||||
hint, ok := newMirror(t, seed, 2).HintView()
|
|
||||||
if !ok || len(hint.Tiles) == 0 {
|
|
||||||
t.Fatal("no opening move")
|
|
||||||
}
|
|
||||||
return svc, g.ID, seats, hint
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDraftPersistAndConflictReset covers Stage 17 draft persistence: a round-trip of the
|
|
||||||
// rack order + board tiles, the actor's own draft cleared on their move, and an opponent's
|
// rack order + board tiles, the actor's own draft cleared on their move, and an opponent's
|
||||||
// board draft reset when a committed play overlaps one of its cells (the rack order kept).
|
// board draft reset when a committed play overlaps one of its cells (the rack order kept).
|
||||||
func TestDraftPersistAndConflictReset(t *testing.T) {
|
func TestDraftPersistAndConflictReset(t *testing.T) {
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ func TestUpdateProfilePersists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestUpdateProfileOffsetTimezone checks the Stage 8 UTC-offset timezone: it is
|
// TestUpdateProfileOffsetTimezone checks the UTC-offset timezone: it is
|
||||||
// accepted, persisted verbatim, and resolved to the right fixed offset.
|
// accepted, persisted verbatim, and resolved to the right fixed offset.
|
||||||
func TestUpdateProfileOffsetTimezone(t *testing.T) {
|
func TestUpdateProfileOffsetTimezone(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -181,3 +181,49 @@ func TestUpdateProfileOffsetTimezone(t *testing.T) {
|
|||||||
t.Fatalf("ResolveZone offset = %d, want 10800", off)
|
t.Fatalf("ResolveZone offset = %d, want 10800", off)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEmailLoginFlow covers the email-as-login path: a code is mailed to
|
||||||
|
// a new address, verifying it provisions and returns the owning account, and a
|
||||||
|
// second login for the same address resolves to that same account (a returning
|
||||||
|
// user), with the identity confirmed.
|
||||||
|
func TestEmailLoginFlow(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
mailer := &capturingMailer{}
|
||||||
|
svc := account.NewEmailService(account.NewStore(testDB), mailer)
|
||||||
|
email := "login-" + uuid.NewString() + "@example.com"
|
||||||
|
|
||||||
|
accountID, err := svc.RequestLoginCode(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request login code: %v", err)
|
||||||
|
}
|
||||||
|
code := sixDigit.FindString(mailer.lastBody)
|
||||||
|
if code == "" {
|
||||||
|
t.Fatalf("no code in mail body %q", mailer.lastBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
acc, err := svc.LoginWithCode(ctx, email, code)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login with code: %v", err)
|
||||||
|
}
|
||||||
|
if acc.ID != accountID {
|
||||||
|
t.Errorf("login account = %s, want %s", acc.ID, accountID)
|
||||||
|
}
|
||||||
|
if acc.IsGuest {
|
||||||
|
t.Error("an email account must be durable, not a guest")
|
||||||
|
}
|
||||||
|
if !identityConfirmed(t, account.KindEmail, email) {
|
||||||
|
t.Error("the email identity must be confirmed after login")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second login for the same email is the returning user: same account.
|
||||||
|
if _, err := svc.RequestLoginCode(ctx, email); err != nil {
|
||||||
|
t.Fatalf("second request: %v", err)
|
||||||
|
}
|
||||||
|
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second login: %v", err)
|
||||||
|
}
|
||||||
|
if acc2.ID != accountID {
|
||||||
|
t.Errorf("returning login account = %s, want %s", acc2.ID, accountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,88 +4,17 @@ package inttest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"errors"
|
"errors"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"go.uber.org/zap"
|
|
||||||
|
|
||||||
"scrabble/backend/internal/account"
|
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newGameService builds a game service over the shared pool and registry.
|
|
||||||
func newGameService() *game.Service {
|
|
||||||
return game.NewService(
|
|
||||||
game.NewStore(testDB),
|
|
||||||
account.NewStore(testDB),
|
|
||||||
testRegistry,
|
|
||||||
game.Config{
|
|
||||||
DictDir: dictDir(),
|
|
||||||
DictVersion: testDictVersion,
|
|
||||||
TimeoutSweepInterval: time.Minute,
|
|
||||||
CacheTTL: time.Hour,
|
|
||||||
},
|
|
||||||
zap.NewNop(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// provisionAccount creates a fresh durable account and returns its id.
|
|
||||||
func provisionAccount(t *testing.T) uuid.UUID {
|
|
||||||
t.Helper()
|
|
||||||
acc, err := account.NewStore(testDB).ProvisionByIdentity(context.Background(), account.KindTelegram, "tg-"+uuid.NewString())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("provision account: %v", err)
|
|
||||||
}
|
|
||||||
return acc.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// openingSeed returns a seed whose fresh two-player English opening rack has a
|
|
||||||
// legal move, so a greedy mirror can drive a game.
|
|
||||||
func openingSeed(t *testing.T) int64 {
|
|
||||||
t.Helper()
|
|
||||||
for seed := int64(1); seed <= 200; seed++ {
|
|
||||||
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("engine new: %v", err)
|
|
||||||
}
|
|
||||||
if _, ok := g.HintView(); ok {
|
|
||||||
return seed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Fatal("no opening seed found")
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// newMirror builds a parallel engine game with the same seed, used to compute
|
|
||||||
// legal moves to feed the service under test.
|
|
||||||
func newMirror(t *testing.T, seed int64, players int) *engine.Game {
|
|
||||||
t.Helper()
|
|
||||||
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: players, Seed: seed})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("mirror new: %v", err)
|
|
||||||
}
|
|
||||||
return g
|
|
||||||
}
|
|
||||||
|
|
||||||
// readStats reads an account's statistics row.
|
|
||||||
func readStats(t *testing.T, id uuid.UUID) (wins, losses, draws, maxGame, maxWord int, found bool) {
|
|
||||||
t.Helper()
|
|
||||||
row := testDB.QueryRowContext(context.Background(),
|
|
||||||
`SELECT wins, losses, draws, max_game_points, max_word_points FROM backend.account_stats WHERE account_id = $1`, id)
|
|
||||||
if err := row.Scan(&wins, &losses, &draws, &maxGame, &maxWord); err != nil {
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return 0, 0, 0, 0, 0, false
|
|
||||||
}
|
|
||||||
t.Fatalf("read stats: %v", err)
|
|
||||||
}
|
|
||||||
return wins, losses, draws, maxGame, maxWord, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestListForAccount checks the lobby "my games" query: it returns exactly the
|
// TestListForAccount checks the lobby "my games" query: it returns exactly the
|
||||||
// games the account is seated in (each with its seats), and nothing for an outsider.
|
// games the account is seated in (each with its seats), and nothing for an outsider.
|
||||||
func TestListForAccount(t *testing.T) {
|
func TestListForAccount(t *testing.T) {
|
||||||
@@ -299,7 +228,7 @@ func TestResignWinnerAndStats(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestResignOnOpponentTurn checks the Stage 17 fix: a player can forfeit on the
|
// TestResignOnOpponentTurn checks a player can forfeit on the
|
||||||
// opponent's turn. Seat 0 plays (so it is seat 1's turn), then seat 0 resigns its own
|
// opponent's turn. Seat 0 plays (so it is seat 1's turn), then seat 0 resigns its own
|
||||||
// seat while it is not its turn — no ErrNotYourTurn, the game ends, and seat 0 loses
|
// seat while it is not its turn — no ErrNotYourTurn, the game ends, and seat 0 loses
|
||||||
// despite leading on score.
|
// despite leading on score.
|
||||||
@@ -464,7 +393,7 @@ func TestHintPolicy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGameVariant covers the edge's lightweight variant lookup (Stage 13): it returns the
|
// TestGameVariant covers the edge's lightweight variant lookup: it returns the
|
||||||
// created game's variant and ErrNotFound for an unknown id.
|
// created game's variant and ErrNotFound for an unknown id.
|
||||||
func TestGameVariant(t *testing.T) {
|
func TestGameVariant(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -477,7 +406,7 @@ func TestGameVariant(t *testing.T) {
|
|||||||
t.Fatalf("create: %v", err)
|
t.Fatalf("create: %v", err)
|
||||||
}
|
}
|
||||||
if v, err := svc.GameVariant(ctx, g.ID); err != nil || v != engine.VariantEnglish {
|
if v, err := svc.GameVariant(ctx, g.ID); err != nil || v != engine.VariantEnglish {
|
||||||
t.Fatalf("GameVariant = %v, %v; want english, nil", v, err)
|
t.Fatalf("GameVariant = %v, %v; want scrabble_en, nil", v, err)
|
||||||
}
|
}
|
||||||
if _, err := svc.GameVariant(ctx, uuid.New()); !errors.Is(err, game.ErrNotFound) {
|
if _, err := svc.GameVariant(ctx, uuid.New()); !errors.Is(err, game.ErrNotFound) {
|
||||||
t.Errorf("GameVariant(unknown) = %v, want ErrNotFound", err)
|
t.Errorf("GameVariant(unknown) = %v, want ErrNotFound", err)
|
||||||
@@ -619,7 +548,7 @@ func equalStrings(a, b []string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestExportGCGRefusesActiveGame checks the Stage 8 finished-only gate: a GCG export
|
// TestExportGCGRefusesActiveGame checks the finished-only gate: a GCG export
|
||||||
// is allowed only once the game is over, so an active game leaks nothing mid-play.
|
// is allowed only once the game is over, so an active game leaks nothing mid-play.
|
||||||
func TestExportGCGRefusesActiveGame(t *testing.T) {
|
func TestExportGCGRefusesActiveGame(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.opentelemetry.io/otel/metric/noop"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/lobby"
|
||||||
|
"scrabble/backend/internal/robot"
|
||||||
|
"scrabble/backend/internal/social"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Shared fixtures for the Postgres-backed integration suite: the service
|
||||||
|
// constructors over the shared pool/registry, account provisioning, game
|
||||||
|
// assembly, and the stats reader. Helpers used by a single test file stay in
|
||||||
|
// that file; everything reused across files lives here.
|
||||||
|
|
||||||
|
// newGameService builds a game service over the shared pool and registry.
|
||||||
|
func newGameService() *game.Service {
|
||||||
|
return game.NewService(
|
||||||
|
game.NewStore(testDB),
|
||||||
|
account.NewStore(testDB),
|
||||||
|
testRegistry,
|
||||||
|
game.Config{
|
||||||
|
DictDir: dictDir(),
|
||||||
|
DictVersion: testDictVersion,
|
||||||
|
TimeoutSweepInterval: time.Minute,
|
||||||
|
CacheTTL: time.Hour,
|
||||||
|
},
|
||||||
|
zap.NewNop(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSocialService builds a social service over the shared pool, reading game
|
||||||
|
// state through a real game service.
|
||||||
|
func newSocialService() *social.Service {
|
||||||
|
return social.NewService(social.NewStore(testDB), account.NewStore(testDB), newGameService())
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRobotService builds a robot service over games (shared so its moves and the
|
||||||
|
// test's human moves use the same live-game cache and per-game locks), a fresh
|
||||||
|
// social service for nudges, and a no-op meter.
|
||||||
|
func newRobotService(t *testing.T, games *game.Service) *robot.Service {
|
||||||
|
t.Helper()
|
||||||
|
return robot.NewService(games, account.NewStore(testDB), newSocialService(), noop.NewMeterProvider().Meter("robot-test"), zap.NewNop())
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMatchmaker builds a matchmaker starting real games and substituting from
|
||||||
|
// robots after wait.
|
||||||
|
func newMatchmaker(t *testing.T, robots lobby.RobotProvider, wait time.Duration) *lobby.Matchmaker {
|
||||||
|
t.Helper()
|
||||||
|
return lobby.NewMatchmaker(newGameService(), robots, wait, zap.NewNop())
|
||||||
|
}
|
||||||
|
|
||||||
|
// provisionAccount creates a fresh durable account and returns its id.
|
||||||
|
func provisionAccount(t *testing.T) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
acc, err := account.NewStore(testDB).ProvisionByIdentity(context.Background(), account.KindTelegram, "tg-"+uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision account: %v", err)
|
||||||
|
}
|
||||||
|
return acc.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// provisionGuest creates a fresh ephemeral guest account and returns its id.
|
||||||
|
func provisionGuest(t *testing.T) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision guest: %v", err)
|
||||||
|
}
|
||||||
|
if !acc.IsGuest {
|
||||||
|
t.Fatalf("provisioned account %s is not flagged guest", acc.ID)
|
||||||
|
}
|
||||||
|
return acc.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// openingSeed returns a seed whose fresh two-player English opening rack has a
|
||||||
|
// legal move, so a greedy mirror can drive a game.
|
||||||
|
func openingSeed(t *testing.T) int64 {
|
||||||
|
t.Helper()
|
||||||
|
for seed := int64(1); seed <= 200; seed++ {
|
||||||
|
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("engine new: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := g.HintView(); ok {
|
||||||
|
return seed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("no opening seed found")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMirror builds a parallel engine game with the same seed, used to compute
|
||||||
|
// legal moves to feed the service under test.
|
||||||
|
func newMirror(t *testing.T, seed int64, players int) *engine.Game {
|
||||||
|
t.Helper()
|
||||||
|
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: players, Seed: seed})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mirror new: %v", err)
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
// newGameWithSeats creates a started game seating n fresh accounts and returns the
|
||||||
|
// game id and the seated account ids in seat order.
|
||||||
|
func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) {
|
||||||
|
t.Helper()
|
||||||
|
seats := make([]uuid.UUID, n)
|
||||||
|
for i := range seats {
|
||||||
|
seats[i] = provisionAccount(t)
|
||||||
|
}
|
||||||
|
g, err := newGameService().Create(context.Background(), game.CreateParams{
|
||||||
|
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create game: %v", err)
|
||||||
|
}
|
||||||
|
return g.ID, seats
|
||||||
|
}
|
||||||
|
|
||||||
|
// newDraftGame creates a started two-player English game on an opening seed and returns the
|
||||||
|
// service, game id, seats, and the opening play (from a mirror) used to drive a real commit.
|
||||||
|
func newDraftGame(t *testing.T) (*game.Service, uuid.UUID, []uuid.UUID, engine.MoveRecord) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newGameService()
|
||||||
|
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||||||
|
seed := openingSeed(t)
|
||||||
|
g, err := svc.Create(ctx, game.CreateParams{
|
||||||
|
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
hint, ok := newMirror(t, seed, 2).HintView()
|
||||||
|
if !ok || len(hint.Tiles) == 0 {
|
||||||
|
t.Fatal("no opening move")
|
||||||
|
}
|
||||||
|
return svc, g.ID, seats, hint
|
||||||
|
}
|
||||||
|
|
||||||
|
// readStats reads an account's statistics row.
|
||||||
|
func readStats(t *testing.T, id uuid.UUID) (wins, losses, draws, maxGame, maxWord int, found bool) {
|
||||||
|
t.Helper()
|
||||||
|
row := testDB.QueryRowContext(context.Background(),
|
||||||
|
`SELECT wins, losses, draws, max_game_points, max_word_points FROM backend.account_stats WHERE account_id = $1`, id)
|
||||||
|
if err := row.Scan(&wins, &losses, &draws, &maxGame, &maxWord); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, 0, 0, 0, 0, false
|
||||||
|
}
|
||||||
|
t.Fatalf("read stats: %v", err)
|
||||||
|
}
|
||||||
|
return wins, losses, draws, maxGame, maxWord, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHideFinishedGame covers per-account game hiding: an active game cannot be
|
||||||
|
// hidden, a finished game is removed from the hider's own list while staying visible to the
|
||||||
|
// other player, an outsider cannot hide it, and the action is idempotent.
|
||||||
|
func TestHideFinishedGame(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, gameID, seats, _ := newDraftGame(t)
|
||||||
|
|
||||||
|
// Hiding while the game is still active is refused.
|
||||||
|
if err := svc.HideGame(ctx, seats[0], gameID); !errors.Is(err, game.ErrGameActive) {
|
||||||
|
t.Fatalf("hide active = %v, want ErrGameActive", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish the game by seat 0 resigning.
|
||||||
|
if _, err := svc.Resign(ctx, gameID, seats[0]); err != nil {
|
||||||
|
t.Fatalf("resign: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-player cannot hide it.
|
||||||
|
if err := svc.HideGame(ctx, provisionAccount(t), gameID); !errors.Is(err, game.ErrNotAPlayer) {
|
||||||
|
t.Fatalf("hide by outsider = %v, want ErrNotAPlayer", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seat 0 hides the finished game; hiding again is a no-op success.
|
||||||
|
if err := svc.HideGame(ctx, seats[0], gameID); err != nil {
|
||||||
|
t.Fatalf("hide: %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.HideGame(ctx, seats[0], gameID); err != nil {
|
||||||
|
t.Fatalf("hide twice: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// It is gone from seat 0's list but still in seat 1's (hiding is per-account).
|
||||||
|
if containsGame(t, svc, seats[0], gameID) {
|
||||||
|
t.Error("hidden game still listed for the hider")
|
||||||
|
}
|
||||||
|
if !containsGame(t, svc, seats[1], gameID) {
|
||||||
|
t.Error("hidden game should remain listed for the other player")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsGame reports whether the account's lobby list includes gameID.
|
||||||
|
func containsGame(t *testing.T, svc *game.Service, accountID, gameID uuid.UUID) bool {
|
||||||
|
t.Helper()
|
||||||
|
games, err := svc.ListForAccount(context.Background(), accountID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list for account: %v", err)
|
||||||
|
}
|
||||||
|
for _, g := range games {
|
||||||
|
if g.ID == gameID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -8,31 +8,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"go.opentelemetry.io/otel/metric/noop"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
"scrabble/backend/internal/lobby"
|
|
||||||
"scrabble/backend/internal/robot"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// newRobotService builds a robot service over games (shared so its moves and the
|
|
||||||
// test's human moves use the same live-game cache and per-game locks), a fresh
|
|
||||||
// social service for nudges, and a no-op meter.
|
|
||||||
func newRobotService(t *testing.T, games *game.Service) *robot.Service {
|
|
||||||
t.Helper()
|
|
||||||
return robot.NewService(games, account.NewStore(testDB), newSocialService(), noop.NewMeterProvider().Meter("robot-test"), zap.NewNop())
|
|
||||||
}
|
|
||||||
|
|
||||||
// newMatchmaker builds a matchmaker starting real games and substituting from
|
|
||||||
// robots after wait.
|
|
||||||
func newMatchmaker(t *testing.T, robots lobby.RobotProvider, wait time.Duration) *lobby.Matchmaker {
|
|
||||||
t.Helper()
|
|
||||||
return lobby.NewMatchmaker(newGameService(), robots, wait, zap.NewNop())
|
|
||||||
}
|
|
||||||
|
|
||||||
// setTurnStarted rewrites a game's turn clock so a robot turn can be made due (or
|
// setTurnStarted rewrites a game's turn clock so a robot turn can be made due (or
|
||||||
// idle) at a chosen instant, independent of wall time.
|
// idle) at a chosen instant, independent of wall time.
|
||||||
func setTurnStarted(t *testing.T, id uuid.UUID, at time.Time) {
|
func setTurnStarted(t *testing.T, id uuid.UUID, at time.Time) {
|
||||||
@@ -90,14 +71,14 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) {
|
|||||||
t.Errorf("picked account %s is not a robot identity", id)
|
t.Errorf("picked account %s is not a robot identity", id)
|
||||||
}
|
}
|
||||||
if ru, err := r.Pick(engine.VariantRussianScrabble); err != nil || !isRobotAccount(t, ru) {
|
if ru, err := r.Pick(engine.VariantRussianScrabble); err != nil || !isRobotAccount(t, ru) {
|
||||||
t.Errorf("russian pick = (%s, %v), want a robot account", ru, err)
|
t.Errorf("scrabble_ru pick = (%s, %v), want a robot account", ru, err)
|
||||||
}
|
}
|
||||||
acc, err := account.NewStore(testDB).GetByID(ctx, id)
|
acc, err := account.NewStore(testDB).GetByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get robot account: %v", err)
|
t.Fatalf("get robot account: %v", err)
|
||||||
}
|
}
|
||||||
// A robot blocks chat but NOT friend requests: a request to a robot stays pending and
|
// A robot blocks chat but NOT friend requests: a request to a robot stays pending and
|
||||||
// expires, mirroring a human who ignores it (Stage 17).
|
// expires, mirroring a human who ignores it.
|
||||||
if acc.DisplayName == "" || !acc.BlockChat || acc.BlockFriendRequests {
|
if acc.DisplayName == "" || !acc.BlockChat || acc.BlockFriendRequests {
|
||||||
t.Errorf("robot profile wrong: name=%q chat-blocked=%v friends-blocked=%v (want chat blocked, friends open)",
|
t.Errorf("robot profile wrong: name=%q chat-blocked=%v friends-blocked=%v (want chat blocked, friends open)",
|
||||||
acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests)
|
acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests)
|
||||||
@@ -207,8 +188,8 @@ func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRobotProactiveNudge checks the robot nudges the human after the idle
|
// TestRobotProactiveNudge checks the robot's lengthening proactive-nudge schedule on the
|
||||||
// threshold on the human's turn.
|
// human's turn: nothing before the ~60-90 min first gap, exactly one once it has elapsed.
|
||||||
func TestRobotProactiveNudge(t *testing.T) {
|
func TestRobotProactiveNudge(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
svc := newGameService()
|
svc := newGameService()
|
||||||
@@ -232,14 +213,18 @@ func TestRobotProactiveNudge(t *testing.T) {
|
|||||||
t.Fatalf("create: %v", err)
|
t.Fatalf("create: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Midnight start, driven 13 hours later (>12h idle) at a daytime hour awake for
|
// A daytime turn start (the robot is awake for every ±3h drift between 07:30 and 12:00). No
|
||||||
// every drift.
|
// nudge before the 60-min floor of the first gap; exactly one once past its 90-min ceiling.
|
||||||
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||||
setTurnStarted(t, g.ID, start)
|
setTurnStarted(t, g.ID, start)
|
||||||
robots.Drive(ctx, start.Add(13*time.Hour))
|
|
||||||
|
|
||||||
|
robots.Drive(ctx, start.Add(30*time.Minute))
|
||||||
|
if n := countNudges(t, g.ID, robotID); n != 0 {
|
||||||
|
t.Errorf("robot nudges = %d at 30m idle, want 0 (before the first gap)", n)
|
||||||
|
}
|
||||||
|
robots.Drive(ctx, start.Add(2*time.Hour))
|
||||||
if n := countNudges(t, g.ID, robotID); n != 1 {
|
if n := countNudges(t, g.ID, robotID); n != 1 {
|
||||||
t.Errorf("robot nudges = %d, want 1 after 13h idle on the human's turn", n)
|
t.Errorf("robot nudges = %d at 2h idle, want 1 (after the first gap)", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,32 +45,9 @@ func (c *capturePublisher) notified(user uuid.UUID, sub string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSocialService builds a social service over the shared pool, reading game
|
|
||||||
// state through a real game service.
|
|
||||||
func newSocialService() *social.Service {
|
|
||||||
return social.NewService(social.NewStore(testDB), account.NewStore(testDB), newGameService())
|
|
||||||
}
|
|
||||||
|
|
||||||
// newGameWithSeats creates a started game seating n fresh accounts and returns the
|
|
||||||
// game id and the seated account ids in seat order.
|
|
||||||
func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) {
|
|
||||||
t.Helper()
|
|
||||||
seats := make([]uuid.UUID, n)
|
|
||||||
for i := range seats {
|
|
||||||
seats[i] = provisionAccount(t)
|
|
||||||
}
|
|
||||||
g, err := newGameService().Create(context.Background(), game.CreateParams{
|
|
||||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create game: %v", err)
|
|
||||||
}
|
|
||||||
return g.ID, seats
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as
|
// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as
|
||||||
// pending rather than blocked: robots no longer block friend requests, so the request
|
// pending rather than blocked: robots no longer block friend requests, so the request
|
||||||
// just sits unanswered and later expires — mirroring a human who ignores it (Stage 17).
|
// just sits unanswered and later expires — mirroring a human who ignores it.
|
||||||
func TestFriendRequestToRobotStaysPending(t *testing.T) {
|
func TestFriendRequestToRobotStaysPending(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
svc := newSocialService()
|
svc := newSocialService()
|
||||||
@@ -342,7 +319,7 @@ func TestChatRejectsBadContent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestChatOnlyOnYourTurn checks chat is allowed only on the sender's own turn (Stage 17):
|
// TestChatOnlyOnYourTurn checks chat is allowed only on the sender's own turn:
|
||||||
// the player to move can post, the waiting player gets ErrChatNotYourTurn.
|
// the player to move can post, the waiting player gets ErrChatNotYourTurn.
|
||||||
func TestChatOnlyOnYourTurn(t *testing.T) {
|
func TestChatOnlyOnYourTurn(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -383,7 +360,7 @@ func TestNudgeRulesAndRateLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has
|
// TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has
|
||||||
// acted (moved or chatted) since their last nudge, even within the hour (Stage 17).
|
// acted (moved or chatted) since their last nudge, even within the hour.
|
||||||
func TestNudgeCooldownResetsOnAction(t *testing.T) {
|
func TestNudgeCooldownResetsOnAction(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
svc := newSocialService()
|
svc := newSocialService()
|
||||||
@@ -413,7 +390,7 @@ func TestNudgeCooldownResetsOnAction(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestListOutgoingRequests checks the requester-side list that backs the in-game "add to
|
// TestListOutgoingRequests checks the requester-side list that backs the in-game "add to
|
||||||
// friends" item (Stage 17): a pending request shows for the requester only; an accepted one
|
// friends" item: a pending request shows for the requester only; an accepted one
|
||||||
// clears (it is a friendship now); a declined one stays (cannot be re-sent, so it reads as
|
// clears (it is a friendship now); a declined one stays (cannot be re-sent, so it reads as
|
||||||
// still "sent"); a lazily expired pending one drops (it may be re-sent).
|
// still "sent"); a lazily expired pending one drops (it may be re-sent).
|
||||||
func TestListOutgoingRequests(t *testing.T) {
|
func TestListOutgoingRequests(t *testing.T) {
|
||||||
@@ -469,7 +446,7 @@ func TestListOutgoingRequests(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestRespondPublishesToRequester checks that answering a request notifies the original
|
// TestRespondPublishesToRequester checks that answering a request notifies the original
|
||||||
// requester over the live channel (Stage 17): accept -> friend_added, decline ->
|
// requester over the live channel: accept -> friend_added, decline ->
|
||||||
// friend_declined, so a game screen watching that opponent re-derives its friend state.
|
// friend_declined, so a game screen watching that opponent re-derives its friend state.
|
||||||
func TestRespondPublishesToRequester(t *testing.T) {
|
func TestRespondPublishesToRequester(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -502,7 +479,33 @@ func TestRespondPublishesToRequester(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAdminListMessages checks the admin moderation list (Stage 17): real messages only
|
// TestNudgeRoutedByGameLanguage checks a nudge's out-of-app push carries the game's language, so
|
||||||
|
// it is delivered by the game's bot rather than the recipient's last-login bot.
|
||||||
|
func TestNudgeRoutedByGameLanguage(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newSocialService()
|
||||||
|
pub := &capturePublisher{}
|
||||||
|
svc.SetNotifier(pub)
|
||||||
|
|
||||||
|
gameID, seats := newGameWithSeats(t, 2) // an English game; seat 0 is to move
|
||||||
|
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
|
||||||
|
t.Fatalf("nudge: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, in := range pub.intents {
|
||||||
|
if in.Kind == notify.KindNudge {
|
||||||
|
found = true
|
||||||
|
if in.Language != "en" {
|
||||||
|
t.Errorf("nudge language = %q, want en (the game's language)", in.Language)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("no nudge intent published")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminListMessages checks the admin moderation list: real messages only
|
||||||
// (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
|
// (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
|
||||||
func TestAdminListMessages(t *testing.T) {
|
func TestAdminListMessages(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
//go:build integration
|
|
||||||
|
|
||||||
package inttest
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"scrabble/backend/internal/account"
|
|
||||||
"scrabble/backend/internal/engine"
|
|
||||||
"scrabble/backend/internal/game"
|
|
||||||
)
|
|
||||||
|
|
||||||
// provisionGuest creates a fresh ephemeral guest account and returns its id.
|
|
||||||
func provisionGuest(t *testing.T) uuid.UUID {
|
|
||||||
t.Helper()
|
|
||||||
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("provision guest: %v", err)
|
|
||||||
}
|
|
||||||
if !acc.IsGuest {
|
|
||||||
t.Fatalf("provisioned account %s is not flagged guest", acc.ID)
|
|
||||||
}
|
|
||||||
return acc.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGuestAutoMatchLeavesNoStats drives a guest through a full auto-match game
|
|
||||||
// against a robot to a natural end and checks the guest holds a seat (the
|
|
||||||
// game_players foreign key is satisfied) yet accrues no statistics, while the
|
|
||||||
// durable robot opponent does.
|
|
||||||
func TestGuestAutoMatchLeavesNoStats(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
svc := newGameService()
|
|
||||||
robots := newRobotService(t, svc)
|
|
||||||
if err := robots.EnsurePool(ctx); err != nil {
|
|
||||||
t.Fatalf("ensure pool: %v", err)
|
|
||||||
}
|
|
||||||
robotID, err := robots.Pick(engine.VariantEnglish)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("pick: %v", err)
|
|
||||||
}
|
|
||||||
guest := provisionGuest(t)
|
|
||||||
seed := openingSeed(t)
|
|
||||||
|
|
||||||
g, err := svc.Create(ctx, game.CreateParams{
|
|
||||||
Variant: engine.VariantEnglish, Seats: []uuid.UUID{guest, robotID},
|
|
||||||
TurnTimeout: 24 * time.Hour, Seed: seed,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create: %v", err)
|
|
||||||
}
|
|
||||||
const robotSeat = 1 // seats = [guest, robot]
|
|
||||||
|
|
||||||
finished := false
|
|
||||||
for i := 0; i < 400 && !finished; i++ {
|
|
||||||
_, toMove, status, err := svc.Participants(ctx, g.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("participants: %v", err)
|
|
||||||
}
|
|
||||||
if status != game.StatusActive {
|
|
||||||
finished = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if toMove == robotSeat {
|
|
||||||
setTurnStarted(t, g.ID, daytime.Add(-2*time.Hour))
|
|
||||||
robots.Drive(ctx, daytime)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
playHuman(t, ctx, svc, g.ID, guest)
|
|
||||||
}
|
|
||||||
if !finished {
|
|
||||||
t.Fatal("guest game did not finish within the move budget")
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, _, _, _, _, ok := readStats(t, guest); ok {
|
|
||||||
t.Error("a guest must not accrue a statistics row")
|
|
||||||
}
|
|
||||||
if _, _, _, _, _, ok := readStats(t, robotID); !ok {
|
|
||||||
t.Error("the durable robot opponent should have a statistics row")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEmailLoginFlow covers the Stage 6 email-as-login path: a code is mailed to
|
|
||||||
// a new address, verifying it provisions and returns the owning account, and a
|
|
||||||
// second login for the same address resolves to that same account (a returning
|
|
||||||
// user), with the identity confirmed.
|
|
||||||
func TestEmailLoginFlow(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
mailer := &capturingMailer{}
|
|
||||||
svc := account.NewEmailService(account.NewStore(testDB), mailer)
|
|
||||||
email := "login-" + uuid.NewString() + "@example.com"
|
|
||||||
|
|
||||||
accountID, err := svc.RequestLoginCode(ctx, email)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("request login code: %v", err)
|
|
||||||
}
|
|
||||||
code := sixDigit.FindString(mailer.lastBody)
|
|
||||||
if code == "" {
|
|
||||||
t.Fatalf("no code in mail body %q", mailer.lastBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
acc, err := svc.LoginWithCode(ctx, email, code)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("login with code: %v", err)
|
|
||||||
}
|
|
||||||
if acc.ID != accountID {
|
|
||||||
t.Errorf("login account = %s, want %s", acc.ID, accountID)
|
|
||||||
}
|
|
||||||
if acc.IsGuest {
|
|
||||||
t.Error("an email account must be durable, not a guest")
|
|
||||||
}
|
|
||||||
if !identityConfirmed(t, account.KindEmail, email) {
|
|
||||||
t.Error("the email identity must be confirmed after login")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A second login for the same email is the returning user: same account.
|
|
||||||
if _, err := svc.RequestLoginCode(ctx, email); err != nil {
|
|
||||||
t.Fatalf("second request: %v", err)
|
|
||||||
}
|
|
||||||
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("second login: %v", err)
|
|
||||||
}
|
|
||||||
if acc2.ID != accountID {
|
|
||||||
t.Errorf("returning login account = %s, want %s", acc2.ID, accountID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package link orchestrates account linking & merge (Stage 11, ARCHITECTURE.md §4).
|
// Package link orchestrates account linking & merge (ARCHITECTURE.md §4).
|
||||||
// It sits above the account, accountmerge and session layers: it verifies the
|
// It sits above the account, accountmerge and session layers: it verifies the
|
||||||
// caller's control of an identity (an email confirm-code or a gateway-validated
|
// caller's control of an identity (an email confirm-code or a gateway-validated
|
||||||
// platform identity), binds a free identity to the current account, and — when the
|
// platform identity), binds a free identity to the current account, and — when the
|
||||||
|
|||||||
@@ -105,18 +105,72 @@ func (svc *InvitationService) SetNotifier(p notify.Publisher) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// notify publishes a re-poll Notification of the given sub-kind to each user.
|
// emitInvitation publishes the invitation notification to each invitee, carrying the invitation
|
||||||
func (svc *InvitationService) notify(kind string, userIDs ...uuid.UUID) {
|
// itself so the client adds it to its lobby list without a refetch.
|
||||||
if len(userIDs) == 0 {
|
func (svc *InvitationService) emitInvitation(ctx context.Context, inv Invitation, inviteeIDs []uuid.UUID) {
|
||||||
|
if len(inviteeIDs) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
intents := make([]notify.Intent, 0, len(userIDs))
|
summary := svc.invitationSummary(ctx, inv)
|
||||||
for _, id := range userIDs {
|
intents := make([]notify.Intent, 0, len(inviteeIDs))
|
||||||
intents = append(intents, notify.Notification(id, kind))
|
for _, id := range inviteeIDs {
|
||||||
|
intents = append(intents, notify.NotificationInvitation(id, summary))
|
||||||
}
|
}
|
||||||
svc.pub.Publish(intents...)
|
svc.pub.Publish(intents...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emitGameStarted publishes the game_started notification to each seated player, carrying their
|
||||||
|
// initial view of the started game so the client seeds its game cache without a refetch. A
|
||||||
|
// seat whose state cannot be read is skipped (it still sees the game on the next lobby load).
|
||||||
|
func (svc *InvitationService) emitGameStarted(ctx context.Context, g game.Game, seats []uuid.UUID) {
|
||||||
|
intents := make([]notify.Intent, 0, len(seats))
|
||||||
|
for _, id := range seats {
|
||||||
|
state, err := svc.games.InitialState(ctx, g.ID, id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
intents = append(intents, notify.NotificationGameStarted(id, state))
|
||||||
|
}
|
||||||
|
svc.pub.Publish(intents...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// invitationSummary projects an Invitation into the notify.InvitationSummary the event carries,
|
||||||
|
// resolving the inviter's and invitees' display names from the account store.
|
||||||
|
func (svc *InvitationService) invitationSummary(ctx context.Context, inv Invitation) notify.InvitationSummary {
|
||||||
|
name := func(id uuid.UUID) string {
|
||||||
|
if acc, err := svc.accounts.GetByID(ctx, id); err == nil {
|
||||||
|
return acc.DisplayName
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
invitees := make([]notify.InvitationInvitee, 0, len(inv.Invitees))
|
||||||
|
for _, iv := range inv.Invitees {
|
||||||
|
invitees = append(invitees, notify.InvitationInvitee{
|
||||||
|
AccountID: iv.AccountID.String(),
|
||||||
|
DisplayName: name(iv.AccountID),
|
||||||
|
Seat: iv.Seat,
|
||||||
|
Response: iv.Response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
gameID := ""
|
||||||
|
if inv.GameID != nil {
|
||||||
|
gameID = inv.GameID.String()
|
||||||
|
}
|
||||||
|
return notify.InvitationSummary{
|
||||||
|
ID: inv.ID.String(),
|
||||||
|
Inviter: notify.AccountRef{AccountID: inv.InviterID.String(), DisplayName: name(inv.InviterID)},
|
||||||
|
Invitees: invitees,
|
||||||
|
Variant: inv.Settings.Variant.String(),
|
||||||
|
TurnTimeoutSecs: int(inv.Settings.TurnTimeout / time.Second),
|
||||||
|
HintsAllowed: inv.Settings.HintsAllowed,
|
||||||
|
HintsPerPlayer: inv.Settings.HintsPerPlayer,
|
||||||
|
DropoutTiles: inv.Settings.DropoutTiles.String(),
|
||||||
|
Status: inv.Status,
|
||||||
|
GameID: gameID,
|
||||||
|
ExpiresAtUnix: inv.ExpiresAt.Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CreateInvitation records a pending invitation from inviterID to inviteeIDs (in
|
// CreateInvitation records a pending invitation from inviterID to inviteeIDs (in
|
||||||
// seat order, 1..N) with the given settings. The total seat count must be 2-4,
|
// seat order, 1..N) with the given settings. The total seat count must be 2-4,
|
||||||
// invitees distinct and not the inviter, every invitee an existing account with no
|
// invitees distinct and not the inviter, every invitee an existing account with no
|
||||||
@@ -176,7 +230,7 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Invitation{}, err
|
return Invitation{}, err
|
||||||
}
|
}
|
||||||
svc.notify(notify.NotifyInvitation, inviteeIDs...)
|
svc.emitInvitation(ctx, inv, inviteeIDs)
|
||||||
return inv, nil
|
return inv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +278,7 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U
|
|||||||
if _, err := svc.store.markStarted(ctx, invitationID, g.ID, svc.now()); err != nil {
|
if _, err := svc.store.markStarted(ctx, invitationID, g.ID, svc.now()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
svc.notify(notify.NotifyGameStarted, seats...)
|
svc.emitGameStarted(ctx, g, seats)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,17 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GameCreator is the slice of the game domain the lobby needs: starting a seated
|
// GameCreator is the slice of the game domain the lobby needs: starting a seated
|
||||||
// game. game.Service satisfies it.
|
// game and reading a player's initial view of it. game.Service satisfies it.
|
||||||
type GameCreator interface {
|
type GameCreator interface {
|
||||||
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
|
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
|
||||||
|
// InitialState returns a seated player's full initial view of a started game, used
|
||||||
|
// to enrich the match_found / game_started events so the client renders the new game
|
||||||
|
// without a follow-up fetch.
|
||||||
|
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RobotProvider supplies a robot account to substitute for a missing human in
|
// RobotProvider supplies a robot account to substitute for a missing human in
|
||||||
|
|||||||
@@ -75,10 +75,21 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) {
|
|||||||
|
|
||||||
// emitMatchFound pushes match_found to every seat of a freshly started game.
|
// emitMatchFound pushes match_found to every seat of a freshly started game.
|
||||||
// Emitting to a robot seat is harmless (no client subscription exists for it).
|
// Emitting to a robot seat is harmless (no client subscription exists for it).
|
||||||
func (m *Matchmaker) emitMatchFound(g game.Game) {
|
func (m *Matchmaker) emitMatchFound(ctx context.Context, g game.Game) {
|
||||||
|
lang := g.Variant.Language() // route the push by the game's language, not the recipient's bot
|
||||||
intents := make([]notify.Intent, 0, len(g.Seats))
|
intents := make([]notify.Intent, 0, len(g.Seats))
|
||||||
for _, s := range g.Seats {
|
for _, s := range g.Seats {
|
||||||
intents = append(intents, notify.MatchFound(s.AccountID, g.ID))
|
state, err := m.games.InitialState(ctx, g.ID, s.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
// A waiter still discovers the game through Poll (the ws-down fallback), so skip the
|
||||||
|
// enriched push for this seat rather than failing the match.
|
||||||
|
m.log.Warn("match_found initial state",
|
||||||
|
zap.String("game", g.ID.String()), zap.String("account", s.AccountID.String()), zap.Error(err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mf := notify.MatchFound(s.AccountID, g.ID, state)
|
||||||
|
mf.Language = lang
|
||||||
|
intents = append(intents, mf)
|
||||||
}
|
}
|
||||||
m.pub.Publish(intents...)
|
m.pub.Publish(intents...)
|
||||||
}
|
}
|
||||||
@@ -125,7 +136,7 @@ func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant e
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.results[opponent] = g
|
m.results[opponent] = g
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
m.emitMatchFound(g)
|
m.emitMatchFound(ctx, g)
|
||||||
return EnqueueResult{Matched: true, Game: g}, nil
|
return EnqueueResult{Matched: true, Game: g}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +235,7 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.results[s.human] = g
|
m.results[s.human] = g
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
m.emitMatchFound(g)
|
m.emitMatchFound(ctx, g)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeCreator records the games a matchmaker asks it to start.
|
// fakeCreator records the games a matchmaker asks it to start.
|
||||||
@@ -27,6 +28,12 @@ func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game,
|
|||||||
return game.Game{ID: uuid.New(), Players: len(p.Seats)}, nil
|
return game.Game{ID: uuid.New(), Players: len(p.Seats)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitialState satisfies GameCreator; the matchmaker reads it to enrich match_found. The pairing
|
||||||
|
// tests assert on matching behaviour, not the payload, so an empty state is enough.
|
||||||
|
func (f *fakeCreator) InitialState(_ context.Context, _, _ uuid.UUID) (notify.PlayerState, error) {
|
||||||
|
return notify.PlayerState{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model
|
// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model
|
||||||
// an empty pool. It records the variant of the last substitution request.
|
// an empty pool. It records the variant of the last substitution request.
|
||||||
type fakeRobots struct {
|
type fakeRobots struct {
|
||||||
@@ -242,7 +249,7 @@ func TestMatchmakerReaperSkipsCancelled(t *testing.T) {
|
|||||||
|
|
||||||
// TestMatchmakerCancelClearsPendingResult covers the race where the reaper substitutes a
|
// TestMatchmakerCancelClearsPendingResult covers the race where the reaper substitutes a
|
||||||
// robot just before the player cancels: Cancel must drop the pending result so the
|
// robot just before the player cancels: Cancel must drop the pending result so the
|
||||||
// abandoned game never surfaces through Poll (Stage 17).
|
// abandoned game never surfaces through Poll.
|
||||||
func TestMatchmakerCancelClearsPendingResult(t *testing.T) {
|
func TestMatchmakerCancelClearsPendingResult(t *testing.T) {
|
||||||
creator := &fakeCreator{}
|
creator := &fakeCreator{}
|
||||||
mm := newTestMatchmaker(creator, uuid.New())
|
mm := newTestMatchmaker(creator, uuid.New())
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/pkg/wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The builders below encode the nested wire tables embedded in enriched event
|
||||||
|
// payloads. They map the domain's already-resolved values (notify.* payload structs
|
||||||
|
// and the decoded engine.MoveRecord) to the neutral scrabble/pkg/wire structs and
|
||||||
|
// delegate the FlatBuffers construction to package wire — the single definition of the
|
||||||
|
// nested-table layout shared with the gateway transcoder. Each returns the offset of
|
||||||
|
// the table it built; callers must build every nested table before opening the parent.
|
||||||
|
|
||||||
|
// toWireGame maps a GameSummary to the shared wire.GameView.
|
||||||
|
func toWireGame(g GameSummary) wire.GameView {
|
||||||
|
seats := make([]wire.SeatView, len(g.Seats))
|
||||||
|
for i, s := range g.Seats {
|
||||||
|
seats[i] = wire.SeatView{
|
||||||
|
Seat: s.Seat,
|
||||||
|
AccountID: s.AccountID,
|
||||||
|
Score: s.Score,
|
||||||
|
HintsUsed: s.HintsUsed,
|
||||||
|
IsWinner: s.IsWinner,
|
||||||
|
DisplayName: s.DisplayName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wire.GameView{
|
||||||
|
ID: g.ID,
|
||||||
|
Variant: g.Variant,
|
||||||
|
DictVersion: g.DictVersion,
|
||||||
|
Status: g.Status,
|
||||||
|
Players: g.Players,
|
||||||
|
ToMove: g.ToMove,
|
||||||
|
TurnTimeoutSecs: g.TurnTimeoutSecs,
|
||||||
|
MoveCount: g.MoveCount,
|
||||||
|
EndReason: g.EndReason,
|
||||||
|
Seats: seats,
|
||||||
|
LastActivityUnix: g.LastActivityUnix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildGameView builds a GameView table from a GameSummary and returns its offset.
|
||||||
|
func buildGameView(b *flatbuffers.Builder, g GameSummary) flatbuffers.UOffsetT {
|
||||||
|
return wire.BuildGameView(b, toWireGame(g))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMoveRecord builds a MoveRecord table from a decoded engine move and returns its
|
||||||
|
// offset (Count is the engine count: the number of tiles swapped on an exchange, zero
|
||||||
|
// otherwise).
|
||||||
|
func buildMoveRecord(b *flatbuffers.Builder, m engine.MoveRecord) flatbuffers.UOffsetT {
|
||||||
|
tiles := make([]wire.TileRecord, len(m.Tiles))
|
||||||
|
for i, t := range m.Tiles {
|
||||||
|
tiles[i] = wire.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank}
|
||||||
|
}
|
||||||
|
return wire.BuildMoveRecord(b, wire.MoveRecord{
|
||||||
|
Player: m.Player,
|
||||||
|
Action: m.Action.String(),
|
||||||
|
Dir: m.Dir.String(),
|
||||||
|
MainRow: m.MainRow,
|
||||||
|
MainCol: m.MainCol,
|
||||||
|
Tiles: tiles,
|
||||||
|
Words: m.Words,
|
||||||
|
Count: m.Count,
|
||||||
|
Score: m.Score,
|
||||||
|
Total: m.Total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildStateView builds a StateView table from a PlayerState and returns its offset.
|
||||||
|
func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT {
|
||||||
|
alphabet := make([]wire.AlphabetEntry, len(s.Alphabet))
|
||||||
|
for i, e := range s.Alphabet {
|
||||||
|
alphabet[i] = wire.AlphabetEntry{Index: e.Index, Letter: e.Letter, Value: e.Value}
|
||||||
|
}
|
||||||
|
return wire.BuildStateView(b, wire.StateView{
|
||||||
|
Game: toWireGame(s.Game),
|
||||||
|
Seat: s.Seat,
|
||||||
|
Rack: s.Rack,
|
||||||
|
BagLen: s.BagLen,
|
||||||
|
HintsRemaining: s.HintsRemaining,
|
||||||
|
Alphabet: alphabet,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAccountRef builds an AccountRef table and returns its offset.
|
||||||
|
func buildAccountRef(b *flatbuffers.Builder, a AccountRef) flatbuffers.UOffsetT {
|
||||||
|
return wire.BuildAccountRef(b, wire.AccountRef{AccountID: a.AccountID, DisplayName: a.DisplayName})
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildInvitation builds an Invitation table from an InvitationSummary and returns its offset.
|
||||||
|
func buildInvitation(b *flatbuffers.Builder, inv InvitationSummary) flatbuffers.UOffsetT {
|
||||||
|
invitees := make([]wire.InvitationInvitee, len(inv.Invitees))
|
||||||
|
for i, iv := range inv.Invitees {
|
||||||
|
invitees[i] = wire.InvitationInvitee{
|
||||||
|
AccountID: iv.AccountID,
|
||||||
|
DisplayName: iv.DisplayName,
|
||||||
|
Seat: iv.Seat,
|
||||||
|
Response: iv.Response,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wire.BuildInvitation(b, wire.Invitation{
|
||||||
|
ID: inv.ID,
|
||||||
|
Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName},
|
||||||
|
Invitees: invitees,
|
||||||
|
Variant: inv.Variant,
|
||||||
|
TurnTimeoutSecs: inv.TurnTimeoutSecs,
|
||||||
|
HintsAllowed: inv.HintsAllowed,
|
||||||
|
HintsPerPlayer: inv.HintsPerPlayer,
|
||||||
|
DropoutTiles: inv.DropoutTiles,
|
||||||
|
Status: inv.Status,
|
||||||
|
GameID: inv.GameID,
|
||||||
|
ExpiresAtUnix: inv.ExpiresAtUnix,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
flatbuffers "github.com/google/flatbuffers/go"
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
fb "scrabble/pkg/fbs/scrabblefb"
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,30 +14,65 @@ import (
|
|||||||
// the payload with the shared scrabblefb schema. Keeping the encoding here lets
|
// the payload with the shared scrabblefb schema. Keeping the encoding here lets
|
||||||
// the game/social/lobby services emit events without importing the wire schema.
|
// the game/social/lobby services emit events without importing the wire schema.
|
||||||
|
|
||||||
// YourTurn announces to userID that it is their turn in game gameID, with the
|
// YourTurn announces to userID that it is their turn in game gameID, with the turn's nominal
|
||||||
// turn's nominal deadline.
|
// deadline. opponentName, lastAction, lastWord and scoreLine enrich the out-of-app push:
|
||||||
func YourTurn(userID, gameID uuid.UUID, deadline time.Time) Intent {
|
// the player who just moved, their move kind, the main word of a scoring play (empty
|
||||||
b := flatbuffers.NewBuilder(64)
|
// otherwise) and the recipient-first running score line. Empty strings render the plain "your
|
||||||
|
// turn" text. moveCount is the post-move count, which the client compares against its cached
|
||||||
|
// game to detect a missed in-app move and fall back to a refetch.
|
||||||
|
func YourTurn(userID, gameID uuid.UUID, deadline time.Time, opponentName, lastAction, lastWord, scoreLine string, moveCount int) Intent {
|
||||||
|
b := flatbuffers.NewBuilder(128)
|
||||||
gid := b.CreateString(gameID.String())
|
gid := b.CreateString(gameID.String())
|
||||||
|
name := b.CreateString(opponentName)
|
||||||
|
action := b.CreateString(lastAction)
|
||||||
|
word := b.CreateString(lastWord)
|
||||||
|
score := b.CreateString(scoreLine)
|
||||||
fb.YourTurnEventStart(b)
|
fb.YourTurnEventStart(b)
|
||||||
fb.YourTurnEventAddGameId(b, gid)
|
fb.YourTurnEventAddGameId(b, gid)
|
||||||
fb.YourTurnEventAddDeadlineUnix(b, deadline.Unix())
|
fb.YourTurnEventAddDeadlineUnix(b, deadline.Unix())
|
||||||
|
fb.YourTurnEventAddOpponentName(b, name)
|
||||||
|
fb.YourTurnEventAddLastAction(b, action)
|
||||||
|
fb.YourTurnEventAddLastWord(b, word)
|
||||||
|
fb.YourTurnEventAddScoreLine(b, score)
|
||||||
|
fb.YourTurnEventAddMoveCount(b, int32(moveCount))
|
||||||
b.Finish(fb.YourTurnEventEnd(b))
|
b.Finish(fb.YourTurnEventEnd(b))
|
||||||
return Intent{UserID: userID, Kind: KindYourTurn, Payload: b.FinishedBytes(), EventID: eventID()}
|
return Intent{UserID: userID, Kind: KindYourTurn, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpponentMoved tells userID that seat just committed a move in game gameID,
|
// GameOver announces to userID that game gameID finished. result is the outcome from userID's
|
||||||
// summarising it (the client refetches the full state).
|
// own perspective ("won"/"lost"/"draw") and scoreLine is the recipient-first final score; both
|
||||||
func OpponentMoved(userID, gameID uuid.UUID, seat int, action string, score, total int) Intent {
|
// feed the out-of-app "game over" push. game is the final post-game summary (the
|
||||||
b := flatbuffers.NewBuilder(64)
|
// adjusted scores after rack penalties and the winner flag), so an in-app client settles the
|
||||||
|
// finished game from the event without a refetch.
|
||||||
|
func GameOver(userID, gameID uuid.UUID, result, scoreLine string, game GameSummary) Intent {
|
||||||
|
b := flatbuffers.NewBuilder(512)
|
||||||
gid := b.CreateString(gameID.String())
|
gid := b.CreateString(gameID.String())
|
||||||
act := b.CreateString(action)
|
res := b.CreateString(result)
|
||||||
|
score := b.CreateString(scoreLine)
|
||||||
|
gameOff := buildGameView(b, game)
|
||||||
|
fb.GameOverEventStart(b)
|
||||||
|
fb.GameOverEventAddGameId(b, gid)
|
||||||
|
fb.GameOverEventAddResult(b, res)
|
||||||
|
fb.GameOverEventAddScoreLine(b, score)
|
||||||
|
fb.GameOverEventAddGame(b, gameOff)
|
||||||
|
b.Finish(fb.GameOverEventEnd(b))
|
||||||
|
return Intent{UserID: userID, Kind: KindGameOver, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpponentMoved tells userID that move was just committed in game gameID, carrying it as a delta
|
||||||
|
// the client applies to its cached game without a refetch: move is the decoded play/pass/
|
||||||
|
// exchange, game is the post-move summary (per-seat scores, to_move, move_count, status) and
|
||||||
|
// bagLen is the bag size after the draw.
|
||||||
|
func OpponentMoved(userID, gameID uuid.UUID, move engine.MoveRecord, game GameSummary, bagLen int) Intent {
|
||||||
|
b := flatbuffers.NewBuilder(512)
|
||||||
|
gid := b.CreateString(gameID.String())
|
||||||
|
moveOff := buildMoveRecord(b, move)
|
||||||
|
gameOff := buildGameView(b, game)
|
||||||
fb.OpponentMovedEventStart(b)
|
fb.OpponentMovedEventStart(b)
|
||||||
fb.OpponentMovedEventAddGameId(b, gid)
|
fb.OpponentMovedEventAddGameId(b, gid)
|
||||||
fb.OpponentMovedEventAddSeat(b, int32(seat))
|
fb.OpponentMovedEventAddMove(b, moveOff)
|
||||||
fb.OpponentMovedEventAddAction(b, act)
|
fb.OpponentMovedEventAddGame(b, gameOff)
|
||||||
fb.OpponentMovedEventAddScore(b, int32(score))
|
fb.OpponentMovedEventAddBagLen(b, int32(bagLen))
|
||||||
fb.OpponentMovedEventAddTotal(b, int32(total))
|
|
||||||
b.Finish(fb.OpponentMovedEventEnd(b))
|
b.Finish(fb.OpponentMovedEventEnd(b))
|
||||||
return Intent{UserID: userID, Kind: KindOpponentMoved, Payload: b.FinishedBytes(), EventID: eventID()}
|
return Intent{UserID: userID, Kind: KindOpponentMoved, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||||
}
|
}
|
||||||
@@ -72,21 +108,24 @@ func Nudge(userID, gameID, fromUserID uuid.UUID) Intent {
|
|||||||
return Intent{UserID: userID, Kind: KindNudge, Payload: b.FinishedBytes(), EventID: eventID()}
|
return Intent{UserID: userID, Kind: KindNudge, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchFound tells userID that game gameID, which they are seated in, has
|
// MatchFound tells userID that game gameID, which they are seated in, has started (an auto-match
|
||||||
// started (an auto-match pairing or a robot substitution).
|
// pairing or a robot substitution). state is the recipient's full initial view of the new game,
|
||||||
func MatchFound(userID, gameID uuid.UUID) Intent {
|
// so the client navigates straight in from the event with no follow-up fetch.
|
||||||
b := flatbuffers.NewBuilder(64)
|
func MatchFound(userID, gameID uuid.UUID, state PlayerState) Intent {
|
||||||
|
b := flatbuffers.NewBuilder(512)
|
||||||
gid := b.CreateString(gameID.String())
|
gid := b.CreateString(gameID.String())
|
||||||
|
stateOff := buildStateView(b, state)
|
||||||
fb.MatchFoundEventStart(b)
|
fb.MatchFoundEventStart(b)
|
||||||
fb.MatchFoundEventAddGameId(b, gid)
|
fb.MatchFoundEventAddGameId(b, gid)
|
||||||
|
fb.MatchFoundEventAddState(b, stateOff)
|
||||||
b.Finish(fb.MatchFoundEventEnd(b))
|
b.Finish(fb.MatchFoundEventEnd(b))
|
||||||
return Intent{UserID: userID, Kind: KindMatchFound, Payload: b.FinishedBytes(), EventID: eventID()}
|
return Intent{UserID: userID, Kind: KindMatchFound, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notification is a lightweight "re-poll" signal to userID that a friend request or
|
// Notification is a lightweight "re-poll" signal to userID that something in their lobby
|
||||||
// invitation changed. kind is a sub-discriminator (NotifyFriendRequest,
|
// changed. kind is a sub-discriminator (NotifyFriendRequest, NotifyFriendAdded,
|
||||||
// NotifyFriendAdded, NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted) the
|
// NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted). It carries no payload; prefer the
|
||||||
// client may use to scope its refresh.
|
// enriched constructors below, which let the client update its lobby without a refetch.
|
||||||
func Notification(userID uuid.UUID, kind string) Intent {
|
func Notification(userID uuid.UUID, kind string) Intent {
|
||||||
b := flatbuffers.NewBuilder(32)
|
b := flatbuffers.NewBuilder(32)
|
||||||
k := b.CreateString(kind)
|
k := b.CreateString(kind)
|
||||||
@@ -96,6 +135,47 @@ func Notification(userID uuid.UUID, kind string) Intent {
|
|||||||
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
|
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NotificationAccount builds a lobby notification of one of the friend_* kinds carrying the
|
||||||
|
// account it concerns (the requester, the new friend or the decliner), so the client updates its
|
||||||
|
// requests/friends lists and the in-game "add friend" state without a refetch.
|
||||||
|
func NotificationAccount(userID uuid.UUID, kind string, acc AccountRef) Intent {
|
||||||
|
b := flatbuffers.NewBuilder(128)
|
||||||
|
k := b.CreateString(kind)
|
||||||
|
accOff := buildAccountRef(b, acc)
|
||||||
|
fb.NotificationEventStart(b)
|
||||||
|
fb.NotificationEventAddKind(b, k)
|
||||||
|
fb.NotificationEventAddAccount(b, accOff)
|
||||||
|
b.Finish(fb.NotificationEventEnd(b))
|
||||||
|
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationGameStarted builds the NotifyGameStarted notification carrying the recipient's
|
||||||
|
// initial view of the just-started invited game, so the client seeds its game cache and the
|
||||||
|
// lobby list without a refetch.
|
||||||
|
func NotificationGameStarted(userID uuid.UUID, state PlayerState) Intent {
|
||||||
|
b := flatbuffers.NewBuilder(512)
|
||||||
|
k := b.CreateString(NotifyGameStarted)
|
||||||
|
stateOff := buildStateView(b, state)
|
||||||
|
fb.NotificationEventStart(b)
|
||||||
|
fb.NotificationEventAddKind(b, k)
|
||||||
|
fb.NotificationEventAddState(b, stateOff)
|
||||||
|
b.Finish(fb.NotificationEventEnd(b))
|
||||||
|
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationInvitation builds the NotifyInvitation notification carrying the new invitation,
|
||||||
|
// so the client adds it to its lobby invitations list without a refetch.
|
||||||
|
func NotificationInvitation(userID uuid.UUID, inv InvitationSummary) Intent {
|
||||||
|
b := flatbuffers.NewBuilder(512)
|
||||||
|
k := b.CreateString(NotifyInvitation)
|
||||||
|
invOff := buildInvitation(b, inv)
|
||||||
|
fb.NotificationEventStart(b)
|
||||||
|
fb.NotificationEventAddKind(b, k)
|
||||||
|
fb.NotificationEventAddInvitation(b, invOff)
|
||||||
|
b.Finish(fb.NotificationEventEnd(b))
|
||||||
|
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||||
|
}
|
||||||
|
|
||||||
// eventID returns a best-effort correlation id for one emitted event.
|
// eventID returns a best-effort correlation id for one emitted event.
|
||||||
func eventID() string {
|
func eventID() string {
|
||||||
if id, err := uuid.NewV7(); err == nil {
|
if id, err := uuid.NewV7(); err == nil {
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ const (
|
|||||||
// KindNotification is a lightweight "re-poll your lobby counters" signal
|
// KindNotification is a lightweight "re-poll your lobby counters" signal
|
||||||
// (incoming friend requests, invitations) that drives the lobby badge.
|
// (incoming friend requests, invitations) that drives the lobby badge.
|
||||||
KindNotification = "notify"
|
KindNotification = "notify"
|
||||||
|
// KindGameOver announces a finished game to each seated player, driving the
|
||||||
|
// out-of-app "game over" push.
|
||||||
|
KindGameOver = "game_over"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Notification sub-kinds carried in a KindNotification event payload; the client
|
// Notification sub-kinds carried in a KindNotification event payload; the client
|
||||||
@@ -49,6 +52,11 @@ type Intent struct {
|
|||||||
Kind string
|
Kind string
|
||||||
Payload []byte
|
Payload []byte
|
||||||
EventID string
|
EventID string
|
||||||
|
// Language routes an out-of-app push to a specific per-language bot: for a
|
||||||
|
// game event it is the game's language ("en"/"ru"), so the notification comes from the
|
||||||
|
// game's bot rather than the recipient's last-login bot. Empty falls back to the
|
||||||
|
// recipient's service language at the gateway.
|
||||||
|
Language string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publisher accepts live-event intents. Implementations must be safe for
|
// Publisher accepts live-event intents. Implementations must be safe for
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
fb "scrabble/pkg/fbs/scrabblefb"
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
)
|
)
|
||||||
@@ -61,7 +62,7 @@ func TestNopPublisherDiscards(t *testing.T) {
|
|||||||
|
|
||||||
func TestYourTurnPayloadRoundTrips(t *testing.T) {
|
func TestYourTurnPayloadRoundTrips(t *testing.T) {
|
||||||
uid, gid := uuid.New(), uuid.New()
|
uid, gid := uuid.New(), uuid.New()
|
||||||
in := notify.YourTurn(uid, gid, time.Unix(1717000000, 0))
|
in := notify.YourTurn(uid, gid, time.Unix(1717000000, 0), "Ann", "play", "STOOL", "120:95", 7)
|
||||||
if in.UserID != uid || in.Kind != notify.KindYourTurn || in.EventID == "" {
|
if in.UserID != uid || in.Kind != notify.KindYourTurn || in.EventID == "" {
|
||||||
t.Fatalf("intent metadata wrong: %+v", in)
|
t.Fatalf("intent metadata wrong: %+v", in)
|
||||||
}
|
}
|
||||||
@@ -72,18 +73,124 @@ func TestYourTurnPayloadRoundTrips(t *testing.T) {
|
|||||||
if got := ev.DeadlineUnix(); got != 1717000000 {
|
if got := ev.DeadlineUnix(); got != 1717000000 {
|
||||||
t.Fatalf("deadline = %d, want 1717000000", got)
|
t.Fatalf("deadline = %d, want 1717000000", got)
|
||||||
}
|
}
|
||||||
|
if got := ev.MoveCount(); got != 7 {
|
||||||
|
t.Fatalf("move_count = %d, want 7", got)
|
||||||
|
}
|
||||||
|
if string(ev.OpponentName()) != "Ann" || string(ev.LastAction()) != "play" ||
|
||||||
|
string(ev.LastWord()) != "STOOL" || string(ev.ScoreLine()) != "120:95" {
|
||||||
|
t.Fatalf("enriched fields wrong: name=%q action=%q word=%q score=%q",
|
||||||
|
ev.OpponentName(), ev.LastAction(), ev.LastWord(), ev.ScoreLine())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameOverPayloadRoundTrips(t *testing.T) {
|
||||||
|
uid, gid := uuid.New(), uuid.New()
|
||||||
|
summary := notify.GameSummary{ID: gid.String(), Status: "finished", MoveCount: 18, Seats: []notify.SeatStanding{{Seat: 0, Score: 120, IsWinner: true}}}
|
||||||
|
in := notify.GameOver(uid, gid, "won", "120:95:80", summary)
|
||||||
|
if in.UserID != uid || in.Kind != notify.KindGameOver || in.EventID == "" {
|
||||||
|
t.Fatalf("intent metadata wrong: %+v", in)
|
||||||
|
}
|
||||||
|
ev := fb.GetRootAsGameOverEvent(in.Payload, 0)
|
||||||
|
if string(ev.GameId()) != gid.String() || string(ev.Result()) != "won" || string(ev.ScoreLine()) != "120:95:80" {
|
||||||
|
t.Fatalf("game_over fields wrong: game=%q result=%q score=%q", ev.GameId(), ev.Result(), ev.ScoreLine())
|
||||||
|
}
|
||||||
|
g := ev.Game(nil)
|
||||||
|
if g == nil || string(g.Id()) != gid.String() || g.MoveCount() != 18 || g.SeatsLength() != 1 {
|
||||||
|
t.Fatalf("final game summary wrong: %+v", g)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
|
func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
|
||||||
uid, gid := uuid.New(), uuid.New()
|
uid, gid := uuid.New(), uuid.New()
|
||||||
in := notify.OpponentMoved(uid, gid, 1, "play", 24, 130)
|
move := engine.MoveRecord{Player: 1, Action: engine.ActionPlay, Words: []string{"STOOL"}, Score: 24, Total: 130}
|
||||||
|
summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}}
|
||||||
|
in := notify.OpponentMoved(uid, gid, move, summary, 42)
|
||||||
if in.Kind != notify.KindOpponentMoved {
|
if in.Kind != notify.KindOpponentMoved {
|
||||||
t.Fatalf("kind = %q", in.Kind)
|
t.Fatalf("kind = %q", in.Kind)
|
||||||
}
|
}
|
||||||
ev := fb.GetRootAsOpponentMovedEvent(in.Payload, 0)
|
ev := fb.GetRootAsOpponentMovedEvent(in.Payload, 0)
|
||||||
if string(ev.GameId()) != gid.String() || ev.Seat() != 1 || string(ev.Action()) != "play" || ev.Score() != 24 || ev.Total() != 130 {
|
if string(ev.GameId()) != gid.String() {
|
||||||
t.Fatalf("decoded wrong: game=%q seat=%d action=%q score=%d total=%d",
|
t.Fatalf("game id = %q", ev.GameId())
|
||||||
ev.GameId(), ev.Seat(), ev.Action(), ev.Score(), ev.Total())
|
}
|
||||||
|
// The delta: the move, the post-move summary and the bag size.
|
||||||
|
if ev.BagLen() != 42 {
|
||||||
|
t.Fatalf("bag_len = %d, want 42", ev.BagLen())
|
||||||
|
}
|
||||||
|
m := ev.Move(nil)
|
||||||
|
if m == nil || m.Player() != 1 || string(m.Action()) != "play" || m.Total() != 130 {
|
||||||
|
t.Fatalf("move wrong: %+v", m)
|
||||||
|
}
|
||||||
|
if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 {
|
||||||
|
t.Fatalf("game summary wrong: %+v", ev.Game(nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchFoundCarriesInitialState(t *testing.T) {
|
||||||
|
uid, gid := uuid.New(), uuid.New()
|
||||||
|
state := notify.PlayerState{
|
||||||
|
Game: notify.GameSummary{ID: gid.String(), Variant: "scrabble_en", Seats: []notify.SeatStanding{{Seat: 0, DisplayName: "Ann"}}},
|
||||||
|
Seat: 0,
|
||||||
|
Rack: []int{0, 1, 2, 255},
|
||||||
|
BagLen: 86,
|
||||||
|
}
|
||||||
|
in := notify.MatchFound(uid, gid, state)
|
||||||
|
if in.UserID != uid || in.Kind != notify.KindMatchFound {
|
||||||
|
t.Fatalf("intent metadata wrong: %+v", in)
|
||||||
|
}
|
||||||
|
ev := fb.GetRootAsMatchFoundEvent(in.Payload, 0)
|
||||||
|
if string(ev.GameId()) != gid.String() {
|
||||||
|
t.Fatalf("game id = %q", ev.GameId())
|
||||||
|
}
|
||||||
|
st := ev.State(nil)
|
||||||
|
if st == nil || st.Seat() != 0 || st.BagLen() != 86 || st.RackLength() != 4 || st.Rack(3) != 255 {
|
||||||
|
t.Fatalf("initial state wrong: %+v", st)
|
||||||
|
}
|
||||||
|
if g := st.Game(nil); g == nil || string(g.Variant()) != "scrabble_en" {
|
||||||
|
t.Fatalf("state game wrong: %+v", st.Game(nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationInvitationCarriesInvitation(t *testing.T) {
|
||||||
|
uid := uuid.New()
|
||||||
|
inv := notify.InvitationSummary{
|
||||||
|
ID: "inv-1",
|
||||||
|
Inviter: notify.AccountRef{AccountID: "a-1", DisplayName: "Ann"},
|
||||||
|
Invitees: []notify.InvitationInvitee{{AccountID: "b-1", DisplayName: "Bob", Seat: 1, Response: "pending"}},
|
||||||
|
Variant: "erudit_ru",
|
||||||
|
TurnTimeoutSecs: 86400,
|
||||||
|
Status: "pending",
|
||||||
|
}
|
||||||
|
in := notify.NotificationInvitation(uid, inv)
|
||||||
|
if in.Kind != notify.KindNotification {
|
||||||
|
t.Fatalf("kind = %q", in.Kind)
|
||||||
|
}
|
||||||
|
ev := fb.GetRootAsNotificationEvent(in.Payload, 0)
|
||||||
|
if string(ev.Kind()) != notify.NotifyInvitation {
|
||||||
|
t.Fatalf("sub-kind = %q, want %q", ev.Kind(), notify.NotifyInvitation)
|
||||||
|
}
|
||||||
|
got := ev.Invitation(nil)
|
||||||
|
if got == nil || string(got.Id()) != "inv-1" || string(got.Variant()) != "erudit_ru" || got.InviteesLength() != 1 {
|
||||||
|
t.Fatalf("invitation wrong: %+v", got)
|
||||||
|
}
|
||||||
|
var iv fb.InvitationInvitee
|
||||||
|
if !got.Invitees(&iv, 0) || string(iv.DisplayName()) != "Bob" || iv.Seat() != 1 {
|
||||||
|
t.Fatalf("invitee wrong")
|
||||||
|
}
|
||||||
|
if inviter := got.Inviter(nil); inviter == nil || string(inviter.DisplayName()) != "Ann" {
|
||||||
|
t.Fatalf("inviter wrong")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationAccountCarriesAccount(t *testing.T) {
|
||||||
|
uid := uuid.New()
|
||||||
|
in := notify.NotificationAccount(uid, notify.NotifyFriendRequest, notify.AccountRef{AccountID: "a-1", DisplayName: "Ann"})
|
||||||
|
ev := fb.GetRootAsNotificationEvent(in.Payload, 0)
|
||||||
|
if string(ev.Kind()) != notify.NotifyFriendRequest {
|
||||||
|
t.Fatalf("sub-kind = %q", ev.Kind())
|
||||||
|
}
|
||||||
|
acc := ev.Account(nil)
|
||||||
|
if acc == nil || string(acc.AccountId()) != "a-1" || string(acc.DisplayName()) != "Ann" {
|
||||||
|
t.Fatalf("account wrong: %+v", acc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
// The structs below are the wire-agnostic inputs the domain services hand to the
|
||||||
|
// enriched event constructors. Keeping them here — rather than importing the wire
|
||||||
|
// schema into game/lobby/social — preserves the package boundary: notify owns the
|
||||||
|
// FlatBuffers encoding, while the domain only fills in already-resolved values (seat
|
||||||
|
// display names, alphabet-index racks). Each mirrors the matching scrabblefb table.
|
||||||
|
|
||||||
|
// SeatStanding is one seat's public standing inside a GameSummary (mirrors
|
||||||
|
// scrabblefb.SeatView).
|
||||||
|
type SeatStanding struct {
|
||||||
|
Seat int
|
||||||
|
AccountID string
|
||||||
|
DisplayName string
|
||||||
|
Score int
|
||||||
|
HintsUsed int
|
||||||
|
IsWinner bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameSummary is the shared, non-private game state embedded in enriched events
|
||||||
|
// (mirrors scrabblefb.GameView). LastActivityUnix is the lobby sort key: the current
|
||||||
|
// turn's start for an active game, the finish time once finished.
|
||||||
|
type GameSummary struct {
|
||||||
|
ID string
|
||||||
|
Variant string
|
||||||
|
DictVersion string
|
||||||
|
Status string
|
||||||
|
Players int
|
||||||
|
ToMove int
|
||||||
|
TurnTimeoutSecs int
|
||||||
|
MoveCount int
|
||||||
|
EndReason string
|
||||||
|
Seats []SeatStanding
|
||||||
|
LastActivityUnix int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an
|
||||||
|
// initial PlayerState so a client seeing a variant for the first time can render its
|
||||||
|
// rack (mirrors scrabblefb.AlphabetEntry).
|
||||||
|
type AlphabetLetter struct {
|
||||||
|
Index int
|
||||||
|
Letter string
|
||||||
|
Value int
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerState is a player's full initial view of a game — the shared summary plus
|
||||||
|
// their private rack and budgets (mirrors scrabblefb.StateView). Rack carries wire
|
||||||
|
// alphabet indices (a blank is the sentinel index 255). Alphabet is set only when the
|
||||||
|
// recipient may not have cached the variant yet (match_found / game_started).
|
||||||
|
type PlayerState struct {
|
||||||
|
Game GameSummary
|
||||||
|
Seat int
|
||||||
|
Rack []int
|
||||||
|
BagLen int
|
||||||
|
HintsRemaining int
|
||||||
|
Alphabet []AlphabetLetter
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountRef is a referenced account with its display name resolved (mirrors
|
||||||
|
// scrabblefb.AccountRef).
|
||||||
|
type AccountRef struct {
|
||||||
|
AccountID string
|
||||||
|
DisplayName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvitationInvitee is one invited player's seat and response inside an
|
||||||
|
// InvitationSummary (mirrors scrabblefb.InvitationInvitee).
|
||||||
|
type InvitationInvitee struct {
|
||||||
|
AccountID string
|
||||||
|
DisplayName string
|
||||||
|
Seat int
|
||||||
|
Response string
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvitationSummary is a friend-game invitation carried by the NotifyInvitation event so
|
||||||
|
// the client adds it to its lobby list without a refetch (mirrors scrabblefb.Invitation).
|
||||||
|
type InvitationSummary struct {
|
||||||
|
ID string
|
||||||
|
Inviter AccountRef
|
||||||
|
Invitees []InvitationInvitee
|
||||||
|
Variant string
|
||||||
|
TurnTimeoutSecs int
|
||||||
|
HintsAllowed bool
|
||||||
|
HintsPerPlayer int
|
||||||
|
DropoutTiles string
|
||||||
|
Status string
|
||||||
|
GameID string
|
||||||
|
ExpiresAtUnix int64
|
||||||
|
}
|
||||||
@@ -30,4 +30,5 @@ type Accounts struct {
|
|||||||
MergedInto *uuid.UUID
|
MergedInto *uuid.UUID
|
||||||
MergedAt *time.Time
|
MergedAt *time.Time
|
||||||
ServiceLanguage *string
|
ServiceLanguage *string
|
||||||
|
FlaggedHighRateAt *time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GameDrafts struct {
|
||||||
|
GameID uuid.UUID `sql:"primary_key"`
|
||||||
|
AccountID uuid.UUID `sql:"primary_key"`
|
||||||
|
RackOrder string
|
||||||
|
BoardTiles string
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GameHidden struct {
|
||||||
|
AccountID uuid.UUID `sql:"primary_key"`
|
||||||
|
GameID uuid.UUID `sql:"primary_key"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ type accountsTable struct {
|
|||||||
MergedInto postgres.ColumnString
|
MergedInto postgres.ColumnString
|
||||||
MergedAt postgres.ColumnTimestampz
|
MergedAt postgres.ColumnTimestampz
|
||||||
ServiceLanguage postgres.ColumnString
|
ServiceLanguage postgres.ColumnString
|
||||||
|
FlaggedHighRateAt postgres.ColumnTimestampz
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -92,8 +93,9 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
|
|||||||
MergedIntoColumn = postgres.StringColumn("merged_into")
|
MergedIntoColumn = postgres.StringColumn("merged_into")
|
||||||
MergedAtColumn = postgres.TimestampzColumn("merged_at")
|
MergedAtColumn = postgres.TimestampzColumn("merged_at")
|
||||||
ServiceLanguageColumn = postgres.StringColumn("service_language")
|
ServiceLanguageColumn = postgres.StringColumn("service_language")
|
||||||
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, ServiceLanguageColumn}
|
FlaggedHighRateAtColumn = postgres.TimestampzColumn("flagged_high_rate_at")
|
||||||
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, ServiceLanguageColumn}
|
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, ServiceLanguageColumn, FlaggedHighRateAtColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, ServiceLanguageColumn, FlaggedHighRateAtColumn}
|
||||||
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn}
|
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -118,6 +120,7 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
|
|||||||
MergedInto: MergedIntoColumn,
|
MergedInto: MergedIntoColumn,
|
||||||
MergedAt: MergedAtColumn,
|
MergedAt: MergedAtColumn,
|
||||||
ServiceLanguage: ServiceLanguageColumn,
|
ServiceLanguage: ServiceLanguageColumn,
|
||||||
|
FlaggedHighRateAt: FlaggedHighRateAtColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var GameDrafts = newGameDraftsTable("backend", "game_drafts", "")
|
||||||
|
|
||||||
|
type gameDraftsTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
GameID postgres.ColumnString
|
||||||
|
AccountID postgres.ColumnString
|
||||||
|
RackOrder postgres.ColumnString
|
||||||
|
BoardTiles postgres.ColumnString
|
||||||
|
UpdatedAt postgres.ColumnTimestampz
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameDraftsTable struct {
|
||||||
|
gameDraftsTable
|
||||||
|
|
||||||
|
EXCLUDED gameDraftsTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new GameDraftsTable with assigned alias
|
||||||
|
func (a GameDraftsTable) AS(alias string) *GameDraftsTable {
|
||||||
|
return newGameDraftsTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new GameDraftsTable with assigned schema name
|
||||||
|
func (a GameDraftsTable) FromSchema(schemaName string) *GameDraftsTable {
|
||||||
|
return newGameDraftsTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new GameDraftsTable with assigned table prefix
|
||||||
|
func (a GameDraftsTable) WithPrefix(prefix string) *GameDraftsTable {
|
||||||
|
return newGameDraftsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new GameDraftsTable with assigned table suffix
|
||||||
|
func (a GameDraftsTable) WithSuffix(suffix string) *GameDraftsTable {
|
||||||
|
return newGameDraftsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGameDraftsTable(schemaName, tableName, alias string) *GameDraftsTable {
|
||||||
|
return &GameDraftsTable{
|
||||||
|
gameDraftsTable: newGameDraftsTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newGameDraftsTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGameDraftsTableImpl(schemaName, tableName, alias string) gameDraftsTable {
|
||||||
|
var (
|
||||||
|
GameIDColumn = postgres.StringColumn("game_id")
|
||||||
|
AccountIDColumn = postgres.StringColumn("account_id")
|
||||||
|
RackOrderColumn = postgres.StringColumn("rack_order")
|
||||||
|
BoardTilesColumn = postgres.StringColumn("board_tiles")
|
||||||
|
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||||
|
allColumns = postgres.ColumnList{GameIDColumn, AccountIDColumn, RackOrderColumn, BoardTilesColumn, UpdatedAtColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{RackOrderColumn, BoardTilesColumn, UpdatedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{RackOrderColumn, BoardTilesColumn, UpdatedAtColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return gameDraftsTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
GameID: GameIDColumn,
|
||||||
|
AccountID: AccountIDColumn,
|
||||||
|
RackOrder: RackOrderColumn,
|
||||||
|
BoardTiles: BoardTilesColumn,
|
||||||
|
UpdatedAt: UpdatedAtColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var GameHidden = newGameHiddenTable("backend", "game_hidden", "")
|
||||||
|
|
||||||
|
type gameHiddenTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
AccountID postgres.ColumnString
|
||||||
|
GameID postgres.ColumnString
|
||||||
|
CreatedAt postgres.ColumnTimestampz
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameHiddenTable struct {
|
||||||
|
gameHiddenTable
|
||||||
|
|
||||||
|
EXCLUDED gameHiddenTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new GameHiddenTable with assigned alias
|
||||||
|
func (a GameHiddenTable) AS(alias string) *GameHiddenTable {
|
||||||
|
return newGameHiddenTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new GameHiddenTable with assigned schema name
|
||||||
|
func (a GameHiddenTable) FromSchema(schemaName string) *GameHiddenTable {
|
||||||
|
return newGameHiddenTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new GameHiddenTable with assigned table prefix
|
||||||
|
func (a GameHiddenTable) WithPrefix(prefix string) *GameHiddenTable {
|
||||||
|
return newGameHiddenTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new GameHiddenTable with assigned table suffix
|
||||||
|
func (a GameHiddenTable) WithSuffix(suffix string) *GameHiddenTable {
|
||||||
|
return newGameHiddenTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGameHiddenTable(schemaName, tableName, alias string) *GameHiddenTable {
|
||||||
|
return &GameHiddenTable{
|
||||||
|
gameHiddenTable: newGameHiddenTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newGameHiddenTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGameHiddenTableImpl(schemaName, tableName, alias string) gameHiddenTable {
|
||||||
|
var (
|
||||||
|
AccountIDColumn = postgres.StringColumn("account_id")
|
||||||
|
GameIDColumn = postgres.StringColumn("game_id")
|
||||||
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
|
allColumns = postgres.ColumnList{AccountIDColumn, GameIDColumn, CreatedAtColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{CreatedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return gameHiddenTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
AccountID: AccountIDColumn,
|
||||||
|
GameID: GameIDColumn,
|
||||||
|
CreatedAt: CreatedAtColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ func UseSchema(schema string) {
|
|||||||
EmailConfirmations = EmailConfirmations.FromSchema(schema)
|
EmailConfirmations = EmailConfirmations.FromSchema(schema)
|
||||||
FriendCodes = FriendCodes.FromSchema(schema)
|
FriendCodes = FriendCodes.FromSchema(schema)
|
||||||
Friendships = Friendships.FromSchema(schema)
|
Friendships = Friendships.FromSchema(schema)
|
||||||
|
GameDrafts = GameDrafts.FromSchema(schema)
|
||||||
|
GameHidden = GameHidden.FromSchema(schema)
|
||||||
GameInvitationInvitees = GameInvitationInvitees.FromSchema(schema)
|
GameInvitationInvitees = GameInvitationInvitees.FromSchema(schema)
|
||||||
GameInvitations = GameInvitations.FromSchema(schema)
|
GameInvitations = GameInvitations.FromSchema(schema)
|
||||||
GameMoves = GameMoves.FromSchema(schema)
|
GameMoves = GameMoves.FromSchema(schema)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ var gooseMu sync.Mutex
|
|||||||
// ApplyMigrations runs every pending Up migration embedded in the backend
|
// ApplyMigrations runs every pending Up migration embedded in the backend
|
||||||
// binary against db. The schema is created upfront so goose's bookkeeping table
|
// binary against db. The schema is created upfront so goose's bookkeeping table
|
||||||
// (`goose_db_version`, scoped to the DSN search_path) has somewhere to land
|
// (`goose_db_version`, scoped to the DSN search_path) has somewhere to land
|
||||||
// before the first migration runs; migration 00001_init.sql re-asserts the
|
// before the first migration runs; the baseline migration re-asserts the
|
||||||
// schema with IF NOT EXISTS, so the double-create is idempotent.
|
// schema with IF NOT EXISTS, so the double-create is idempotent.
|
||||||
//
|
//
|
||||||
// The apply is retried on transient connection errors. Both steps are
|
// The apply is retried on transient connection errors. Both steps are
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Baseline schema for the Scrabble backend service, consolidating the incremental
|
||||||
|
-- migration history into a single starting point (there is no production data yet,
|
||||||
|
-- so the squash carries no data migration). Every backend object lives in the
|
||||||
|
-- `backend` schema; it is created here so a fresh database can apply this migration,
|
||||||
|
-- and search_path is pinned for the rest of the file so unqualified CREATE
|
||||||
|
-- statements land in `backend`. Production also pins search_path via
|
||||||
|
-- BACKEND_POSTGRES_DSN.
|
||||||
|
CREATE SCHEMA IF NOT EXISTS backend;
|
||||||
|
SET search_path = backend, pg_catalog;
|
||||||
|
|
||||||
|
-- Durable internal accounts. A guest is a durable row with is_guest set and no
|
||||||
|
-- identity, excluded from profile/friends/stats/history. The away window (one
|
||||||
|
-- interval per day, in the account's time_zone) is honoured by the turn-timeout
|
||||||
|
-- sweeper and the robot's sleep; hint_balance is the purchasable-hint wallet.
|
||||||
|
-- service_language records the language tag of the bot a Telegram user last
|
||||||
|
-- authenticated through (out-of-app push routing), distinct from preferred_language
|
||||||
|
-- (the interface language). merged_into/merged_at turn a merged-away secondary into
|
||||||
|
-- an audit tombstone; paid_account is a forward-looking one-time-payment marker.
|
||||||
|
CREATE TABLE accounts (
|
||||||
|
account_id uuid PRIMARY KEY,
|
||||||
|
display_name text NOT NULL DEFAULT '',
|
||||||
|
preferred_language text NOT NULL DEFAULT 'en',
|
||||||
|
time_zone text NOT NULL DEFAULT 'UTC',
|
||||||
|
block_chat boolean NOT NULL DEFAULT false,
|
||||||
|
block_friend_requests boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
away_start time NOT NULL DEFAULT '00:00',
|
||||||
|
away_end time NOT NULL DEFAULT '07:00',
|
||||||
|
hint_balance integer NOT NULL DEFAULT 0,
|
||||||
|
is_guest boolean NOT NULL DEFAULT false,
|
||||||
|
notifications_in_app_only boolean NOT NULL DEFAULT true,
|
||||||
|
paid_account boolean NOT NULL DEFAULT false,
|
||||||
|
merged_into uuid REFERENCES accounts (account_id) ON DELETE SET NULL,
|
||||||
|
merged_at timestamptz,
|
||||||
|
service_language text CHECK (service_language IN ('en', 'ru')),
|
||||||
|
-- Soft, reversible "suspected high-rate" marker: set once when the gateway
|
||||||
|
-- reports sustained rate-limiter rejections past the threshold; an operator
|
||||||
|
-- clears it in the admin console. Never an automatic ban.
|
||||||
|
flagged_high_rate_at timestamptz,
|
||||||
|
CONSTRAINT accounts_preferred_language_chk CHECK (preferred_language IN ('en', 'ru')),
|
||||||
|
CONSTRAINT accounts_hint_balance_chk CHECK (hint_balance >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Platform and email identities attached to an account. external_id is the platform
|
||||||
|
-- user id (kind='telegram'), the email address (kind='email') or the robot name
|
||||||
|
-- (kind='robot'); confirmed flips true once an email confirm-code is verified.
|
||||||
|
CREATE TABLE identities (
|
||||||
|
identity_id uuid PRIMARY KEY,
|
||||||
|
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||||
|
kind text NOT NULL,
|
||||||
|
external_id text NOT NULL,
|
||||||
|
confirmed boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email', 'robot')),
|
||||||
|
CONSTRAINT identities_kind_external_id_key UNIQUE (kind, external_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX identities_account_idx ON identities (account_id);
|
||||||
|
|
||||||
|
-- Opaque server sessions. token_hash is the hex-encoded SHA-256 of the bearer token;
|
||||||
|
-- the plaintext token is never stored. Sessions are revoke-only (no TTL): status
|
||||||
|
-- moves active -> revoked and revoked_at is stamped.
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
session_id uuid PRIMARY KEY,
|
||||||
|
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||||
|
token_hash text NOT NULL,
|
||||||
|
status text NOT NULL DEFAULT 'active',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
last_seen_at timestamptz,
|
||||||
|
revoked_at timestamptz,
|
||||||
|
CONSTRAINT sessions_status_chk CHECK (status IN ('active', 'revoked')),
|
||||||
|
CONSTRAINT sessions_token_hash_key UNIQUE (token_hash)
|
||||||
|
);
|
||||||
|
CREATE INDEX sessions_account_idx ON sessions (account_id);
|
||||||
|
|
||||||
|
-- One match. The live position is event-sourced: this row carries the pinned
|
||||||
|
-- dictionary, the bag seed and the denormalised turn cursor the sweeper needs, while
|
||||||
|
-- game_moves is the append-only journal the in-memory engine.Game is replayed from
|
||||||
|
-- (docs/ARCHITECTURE.md §9). turn_timeout_secs is the per-game move clock; its allowed
|
||||||
|
-- values are enforced in Go. variant uses engine.Variant's stable labels.
|
||||||
|
CREATE TABLE games (
|
||||||
|
game_id uuid PRIMARY KEY,
|
||||||
|
variant text NOT NULL,
|
||||||
|
dict_version text NOT NULL,
|
||||||
|
seed bigint NOT NULL,
|
||||||
|
status text NOT NULL DEFAULT 'active',
|
||||||
|
players smallint NOT NULL,
|
||||||
|
to_move smallint NOT NULL DEFAULT 0,
|
||||||
|
turn_started_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
turn_timeout_secs integer NOT NULL,
|
||||||
|
hints_allowed boolean NOT NULL DEFAULT true,
|
||||||
|
hints_per_player smallint NOT NULL DEFAULT 1,
|
||||||
|
move_count integer NOT NULL DEFAULT 0,
|
||||||
|
end_reason text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
finished_at timestamptz,
|
||||||
|
dropout_tiles text NOT NULL DEFAULT 'remove',
|
||||||
|
CONSTRAINT games_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')),
|
||||||
|
CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')),
|
||||||
|
CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4),
|
||||||
|
CONSTRAINT games_to_move_chk CHECK (to_move >= 0 AND to_move < players),
|
||||||
|
CONSTRAINT games_turn_timeout_chk CHECK (turn_timeout_secs > 0),
|
||||||
|
CONSTRAINT games_hints_per_player_chk CHECK (hints_per_player >= 0),
|
||||||
|
CONSTRAINT games_end_reason_chk CHECK (
|
||||||
|
end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout')
|
||||||
|
),
|
||||||
|
CONSTRAINT games_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return'))
|
||||||
|
);
|
||||||
|
-- The sweeper scans active games oldest-turn-first; a partial index keeps it off the
|
||||||
|
-- finished archive.
|
||||||
|
CREATE INDEX games_active_idx ON games (turn_started_at) WHERE status = 'active';
|
||||||
|
|
||||||
|
-- Seats in turn order (seat 0 moves first), one row per player. account_id is a
|
||||||
|
-- durable account. score is the running/final score, is_winner is stamped on finish
|
||||||
|
-- (false for every seat on a draw), hints_used counts the per-game allowance consumed
|
||||||
|
-- before the profile wallet.
|
||||||
|
CREATE TABLE game_players (
|
||||||
|
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||||
|
seat smallint NOT NULL,
|
||||||
|
account_id uuid NOT NULL REFERENCES accounts (account_id),
|
||||||
|
score integer NOT NULL DEFAULT 0,
|
||||||
|
hints_used smallint NOT NULL DEFAULT 0,
|
||||||
|
is_winner boolean NOT NULL DEFAULT false,
|
||||||
|
PRIMARY KEY (game_id, seat),
|
||||||
|
CONSTRAINT game_players_account_key UNIQUE (game_id, account_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX game_players_account_idx ON game_players (account_id);
|
||||||
|
|
||||||
|
-- The append-only, dictionary-independent move journal (docs/ARCHITECTURE.md §9.1).
|
||||||
|
-- seq orders the moves from 0. payload holds the decoded values needed to both replay
|
||||||
|
-- the game through the engine and emit GCG without a dictionary. score / running_total
|
||||||
|
-- / exchanged_count are lifted out for cheap history rendering.
|
||||||
|
CREATE TABLE game_moves (
|
||||||
|
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||||
|
seq integer NOT NULL,
|
||||||
|
seat smallint NOT NULL,
|
||||||
|
action text NOT NULL,
|
||||||
|
score integer NOT NULL DEFAULT 0,
|
||||||
|
running_total integer NOT NULL DEFAULT 0,
|
||||||
|
exchanged_count smallint NOT NULL DEFAULT 0,
|
||||||
|
payload text NOT NULL DEFAULT '{}',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (game_id, seq),
|
||||||
|
CONSTRAINT game_moves_action_chk CHECK (action IN ('play', 'pass', 'exchange', 'resign', 'timeout'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Word-check complaints captured in the context of a game's pinned dictionary. The
|
||||||
|
-- admin review queue resolves them with a disposition that also feeds the offline
|
||||||
|
-- dictionary-rebuild pipeline: an accepted complaint records whether the word is to be
|
||||||
|
-- added or removed, and is marked applied once a rebuilt version is hot-reloaded.
|
||||||
|
CREATE TABLE complaints (
|
||||||
|
complaint_id uuid PRIMARY KEY,
|
||||||
|
complainant_id uuid NOT NULL REFERENCES accounts (account_id),
|
||||||
|
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||||
|
variant text NOT NULL,
|
||||||
|
dict_version text NOT NULL,
|
||||||
|
word text NOT NULL,
|
||||||
|
was_valid boolean NOT NULL,
|
||||||
|
note text NOT NULL DEFAULT '',
|
||||||
|
status text NOT NULL DEFAULT 'open',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
disposition text NOT NULL DEFAULT '',
|
||||||
|
resolution_note text NOT NULL DEFAULT '',
|
||||||
|
resolved_at timestamptz,
|
||||||
|
applied_in_version text NOT NULL DEFAULT '',
|
||||||
|
CONSTRAINT complaints_status_chk CHECK (status IN ('open', 'resolved')),
|
||||||
|
CONSTRAINT complaints_disposition_chk
|
||||||
|
CHECK (disposition IN ('', 'reject', 'accept_add', 'accept_remove'))
|
||||||
|
);
|
||||||
|
CREATE INDEX complaints_status_idx ON complaints (status);
|
||||||
|
|
||||||
|
-- Per-account lifetime statistics, recomputed incrementally on each game finish.
|
||||||
|
-- Guests have no durable stats. A draw increments draws only. max_word_points is the
|
||||||
|
-- best single move score (folding in every word the move formed and the all-tiles bonus).
|
||||||
|
CREATE TABLE account_stats (
|
||||||
|
account_id uuid PRIMARY KEY REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||||
|
wins integer NOT NULL DEFAULT 0,
|
||||||
|
losses integer NOT NULL DEFAULT 0,
|
||||||
|
draws integer NOT NULL DEFAULT 0,
|
||||||
|
max_game_points integer NOT NULL DEFAULT 0,
|
||||||
|
max_word_points integer NOT NULL DEFAULT 0,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The friend graph. A row is created by the requester as 'pending' and flipped to
|
||||||
|
-- 'accepted' by the addressee; an explicit 'declined' is remembered (anti-spam),
|
||||||
|
-- while cancelling or unfriending deletes the row. Friendship is symmetric.
|
||||||
|
CREATE TABLE friendships (
|
||||||
|
requester_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||||
|
addressee_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||||
|
status text NOT NULL DEFAULT 'pending',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
responded_at timestamptz,
|
||||||
|
PRIMARY KEY (requester_id, addressee_id),
|
||||||
|
CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted', 'declined')),
|
||||||
|
CONSTRAINT friendships_distinct_chk CHECK (requester_id <> addressee_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX friendships_addressee_idx ON friendships (addressee_id);
|
||||||
|
|
||||||
|
-- Per-user blocks. The effect is applied mutually by the social checks (a block in
|
||||||
|
-- either direction suppresses chat visibility and prevents requests/invitations).
|
||||||
|
CREATE TABLE blocks (
|
||||||
|
blocker_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||||
|
blocked_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (blocker_id, blocked_id),
|
||||||
|
CONSTRAINT blocks_distinct_chk CHECK (blocker_id <> blocked_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX blocks_blocked_idx ON blocks (blocked_id);
|
||||||
|
|
||||||
|
-- Per-game chat. A nudge ("it's your move") is a kind='nudge' row with an empty body,
|
||||||
|
-- so one journal carries both chatter and nudges. body is capped at 60 runes (enforced
|
||||||
|
-- again in Go, where the content filter also rejects links/emails/phone numbers).
|
||||||
|
-- sender_ip holds the gateway-forwarded client IP as a validated string. Chat is part
|
||||||
|
-- of the game archive and cascades away only with its game.
|
||||||
|
CREATE TABLE chat_messages (
|
||||||
|
message_id uuid PRIMARY KEY,
|
||||||
|
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||||
|
sender_id uuid NOT NULL REFERENCES accounts (account_id),
|
||||||
|
kind text NOT NULL DEFAULT 'message',
|
||||||
|
body text NOT NULL DEFAULT '',
|
||||||
|
sender_ip text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT chat_messages_kind_chk CHECK (kind IN ('message', 'nudge')),
|
||||||
|
CONSTRAINT chat_messages_body_len_chk CHECK (char_length(body) <= 60),
|
||||||
|
CONSTRAINT chat_messages_nudge_empty_chk CHECK (kind <> 'nudge' OR body = '')
|
||||||
|
);
|
||||||
|
CREATE INDEX chat_messages_game_idx ON chat_messages (game_id, created_at);
|
||||||
|
-- Backs the once-per-hour nudge rate-limit lookup (latest nudge by a sender).
|
||||||
|
CREATE INDEX chat_messages_nudge_idx ON chat_messages (game_id, sender_id, created_at)
|
||||||
|
WHERE kind = 'nudge';
|
||||||
|
|
||||||
|
-- Pending email confirm-codes. code_hash is the hex-encoded SHA-256 of the 6-digit code
|
||||||
|
-- (the plaintext is never stored); expires_at bounds the TTL and attempts caps brute
|
||||||
|
-- force. A row is consumed (consumed_at stamped) on success.
|
||||||
|
CREATE TABLE email_confirmations (
|
||||||
|
confirmation_id uuid PRIMARY KEY,
|
||||||
|
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||||
|
email text NOT NULL,
|
||||||
|
code_hash text NOT NULL,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
attempts smallint NOT NULL DEFAULT 0,
|
||||||
|
consumed_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT email_confirmations_attempts_chk CHECK (attempts >= 0)
|
||||||
|
);
|
||||||
|
CREATE INDEX email_confirmations_account_idx ON email_confirmations (account_id);
|
||||||
|
|
||||||
|
-- A friend-game invitation. The inviter (seat 0) proposes the game settings to 1..3
|
||||||
|
-- invitees; the game starts only when every invitee has accepted, and any decline
|
||||||
|
-- cancels the whole invitation. Lazily expired after expires_at (no background sweep).
|
||||||
|
-- game_id is set when the game is started.
|
||||||
|
CREATE TABLE game_invitations (
|
||||||
|
invitation_id uuid PRIMARY KEY,
|
||||||
|
inviter_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||||
|
variant text NOT NULL,
|
||||||
|
turn_timeout_secs integer NOT NULL,
|
||||||
|
hints_allowed boolean NOT NULL DEFAULT true,
|
||||||
|
hints_per_player smallint NOT NULL DEFAULT 1,
|
||||||
|
dropout_tiles text NOT NULL DEFAULT 'remove',
|
||||||
|
status text NOT NULL DEFAULT 'pending',
|
||||||
|
game_id uuid REFERENCES games (game_id) ON DELETE SET NULL,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT game_invitations_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')),
|
||||||
|
CONSTRAINT game_invitations_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return')),
|
||||||
|
CONSTRAINT game_invitations_status_chk CHECK (status IN ('pending', 'declined', 'cancelled', 'expired', 'started')),
|
||||||
|
CONSTRAINT game_invitations_turn_timeout_chk CHECK (turn_timeout_secs > 0),
|
||||||
|
CONSTRAINT game_invitations_hints_per_player_chk CHECK (hints_per_player >= 0)
|
||||||
|
);
|
||||||
|
CREATE INDEX game_invitations_inviter_idx ON game_invitations (inviter_id);
|
||||||
|
|
||||||
|
-- One row per invitee (the inviter is implicit seat 0). seat is the invitee's seat in
|
||||||
|
-- the started game (1..3, in invitation order). response tracks each invitee's decision.
|
||||||
|
CREATE TABLE game_invitation_invitees (
|
||||||
|
invitation_id uuid NOT NULL REFERENCES game_invitations (invitation_id) ON DELETE CASCADE,
|
||||||
|
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||||
|
seat smallint NOT NULL,
|
||||||
|
response text NOT NULL DEFAULT 'pending',
|
||||||
|
responded_at timestamptz,
|
||||||
|
PRIMARY KEY (invitation_id, account_id),
|
||||||
|
CONSTRAINT game_invitation_invitees_response_chk CHECK (response IN ('pending', 'accepted', 'declined')),
|
||||||
|
CONSTRAINT game_invitation_invitees_seat_chk CHECK (seat BETWEEN 1 AND 3)
|
||||||
|
);
|
||||||
|
CREATE INDEX game_invitation_invitees_account_idx ON game_invitation_invitees (account_id);
|
||||||
|
|
||||||
|
-- One-time friend codes. The player who wants to be added issues a 6-digit code;
|
||||||
|
-- whoever enters it becomes their friend. Only the SHA-256 hash is stored; expires_at
|
||||||
|
-- bounds the 12h TTL and consumed_at marks single use. At most one live code per issuer.
|
||||||
|
CREATE TABLE friend_codes (
|
||||||
|
code_id uuid PRIMARY KEY,
|
||||||
|
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||||
|
code_hash text NOT NULL,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
consumed_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX friend_codes_account_idx ON friend_codes (account_id);
|
||||||
|
CREATE INDEX friend_codes_code_hash_idx ON friend_codes (code_hash);
|
||||||
|
|
||||||
|
-- Per-(game, account) draft the server persists across reloads and devices: the
|
||||||
|
-- player's preferred rack tile order and the tiles laid on the board but not yet
|
||||||
|
-- submitted. board_tiles is reset when an opponent's committed move overlaps a cell.
|
||||||
|
CREATE TABLE game_drafts (
|
||||||
|
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||||
|
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||||
|
rack_order text NOT NULL DEFAULT '',
|
||||||
|
board_tiles jsonb NOT NULL DEFAULT '[]',
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (game_id, account_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Per-account hidden games. A row hides game_id from account_id's own "my games" list,
|
||||||
|
-- leaving it visible to the other players. Only finished games are hidden, and the
|
||||||
|
-- action is irreversible by design (there is no un-hide).
|
||||||
|
CREATE TABLE game_hidden (
|
||||||
|
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||||
|
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (account_id, game_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP SCHEMA IF EXISTS backend CASCADE;
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- Initial schema for the Scrabble backend service: durable accounts, their
|
|
||||||
-- platform/email identities, and opaque server sessions.
|
|
||||||
--
|
|
||||||
-- Every backend table lives in the `backend` schema. The schema is created here
|
|
||||||
-- so a fresh database can apply this migration, and search_path is pinned for
|
|
||||||
-- the rest of the migration so the CREATE statements land in `backend` without
|
|
||||||
-- qualifying every object. Production also pins search_path via
|
|
||||||
-- BACKEND_POSTGRES_DSN.
|
|
||||||
CREATE SCHEMA IF NOT EXISTS backend;
|
|
||||||
SET search_path = backend, pg_catalog;
|
|
||||||
|
|
||||||
-- Durable internal accounts. Guests are session-only and never reach this table.
|
|
||||||
CREATE TABLE accounts (
|
|
||||||
account_id uuid PRIMARY KEY,
|
|
||||||
display_name text NOT NULL DEFAULT '',
|
|
||||||
preferred_language text NOT NULL DEFAULT 'en',
|
|
||||||
time_zone text NOT NULL DEFAULT 'UTC',
|
|
||||||
block_chat boolean NOT NULL DEFAULT false,
|
|
||||||
block_friend_requests boolean NOT NULL DEFAULT false,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT accounts_preferred_language_chk CHECK (preferred_language IN ('en', 'ru'))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Platform and email identities attached to an account. external_id is the
|
|
||||||
-- platform user id (kind='telegram') or the email address (kind='email');
|
|
||||||
-- confirmed flips true once an email confirm-code is verified (later stages).
|
|
||||||
CREATE TABLE identities (
|
|
||||||
identity_id uuid PRIMARY KEY,
|
|
||||||
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
||||||
kind text NOT NULL,
|
|
||||||
external_id text NOT NULL,
|
|
||||||
confirmed boolean NOT NULL DEFAULT false,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email')),
|
|
||||||
CONSTRAINT identities_kind_external_id_key UNIQUE (kind, external_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX identities_account_idx ON identities (account_id);
|
|
||||||
|
|
||||||
-- Opaque server sessions. token_hash is the hex-encoded SHA-256 of the bearer
|
|
||||||
-- token; the plaintext token is never stored. Sessions are revoke-only (no
|
|
||||||
-- TTL): status moves active -> revoked and revoked_at is stamped.
|
|
||||||
CREATE TABLE sessions (
|
|
||||||
session_id uuid PRIMARY KEY,
|
|
||||||
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
||||||
token_hash text NOT NULL,
|
|
||||||
status text NOT NULL DEFAULT 'active',
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
last_seen_at timestamptz,
|
|
||||||
revoked_at timestamptz,
|
|
||||||
CONSTRAINT sessions_status_chk CHECK (status IN ('active', 'revoked')),
|
|
||||||
CONSTRAINT sessions_token_hash_key UNIQUE (token_hash)
|
|
||||||
);
|
|
||||||
CREATE INDEX sessions_account_idx ON sessions (account_id);
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
DROP TABLE sessions;
|
|
||||||
DROP TABLE identities;
|
|
||||||
DROP TABLE accounts;
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- Stage 3 game domain: the per-game lifecycle, the dictionary-independent move
|
|
||||||
-- journal, word-check complaints and per-account statistics, plus two account
|
|
||||||
-- columns the game domain needs.
|
|
||||||
SET search_path = backend, pg_catalog;
|
|
||||||
|
|
||||||
-- Extend accounts with the per-user away window (one interval per day, in the
|
|
||||||
-- account's local time_zone) honoured by the turn-timeout sweeper -- and, in a
|
|
||||||
-- later stage, by the robot's sleep -- and a hint wallet (purchasable hints; the
|
|
||||||
-- purchase flow lands later, so the balance defaults to empty). Profile editing
|
|
||||||
-- of the away window arrives with the profile surface (Stage 4).
|
|
||||||
ALTER TABLE accounts
|
|
||||||
ADD COLUMN away_start time NOT NULL DEFAULT '00:00',
|
|
||||||
ADD COLUMN away_end time NOT NULL DEFAULT '07:00',
|
|
||||||
ADD COLUMN hint_balance integer NOT NULL DEFAULT 0,
|
|
||||||
ADD CONSTRAINT accounts_hint_balance_chk CHECK (hint_balance >= 0);
|
|
||||||
|
|
||||||
-- One match. The live position is event-sourced: this row carries the pinned
|
|
||||||
-- dictionary, the bag seed and the denormalised turn cursor the sweeper needs,
|
|
||||||
-- while game_moves is the append-only journal the in-memory engine.Game is
|
|
||||||
-- replayed from (docs/ARCHITECTURE.md §9). turn_timeout_secs is the per-game move
|
|
||||||
-- clock; its allowed values are enforced in Go. variant uses engine.Variant's
|
|
||||||
-- stable labels.
|
|
||||||
CREATE TABLE games (
|
|
||||||
game_id uuid PRIMARY KEY,
|
|
||||||
variant text NOT NULL,
|
|
||||||
dict_version text NOT NULL,
|
|
||||||
seed bigint NOT NULL,
|
|
||||||
status text NOT NULL DEFAULT 'active',
|
|
||||||
players smallint NOT NULL,
|
|
||||||
to_move smallint NOT NULL DEFAULT 0,
|
|
||||||
turn_started_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
turn_timeout_secs integer NOT NULL,
|
|
||||||
hints_allowed boolean NOT NULL DEFAULT true,
|
|
||||||
hints_per_player smallint NOT NULL DEFAULT 1,
|
|
||||||
move_count integer NOT NULL DEFAULT 0,
|
|
||||||
end_reason text,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
finished_at timestamptz,
|
|
||||||
CONSTRAINT games_variant_chk CHECK (variant IN ('english', 'russian_scrabble', 'erudit')),
|
|
||||||
CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')),
|
|
||||||
CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4),
|
|
||||||
CONSTRAINT games_to_move_chk CHECK (to_move >= 0 AND to_move < players),
|
|
||||||
CONSTRAINT games_turn_timeout_chk CHECK (turn_timeout_secs > 0),
|
|
||||||
CONSTRAINT games_hints_per_player_chk CHECK (hints_per_player >= 0),
|
|
||||||
CONSTRAINT games_end_reason_chk CHECK (
|
|
||||||
end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
-- The sweeper scans active games oldest-turn-first; a partial index keeps it
|
|
||||||
-- off the finished archive.
|
|
||||||
CREATE INDEX games_active_idx ON games (turn_started_at) WHERE status = 'active';
|
|
||||||
|
|
||||||
-- Seats in turn order (seat 0 moves first), one row per player. account_id is a
|
|
||||||
-- durable account (guests and robots are revisited when they arrive). score is
|
|
||||||
-- the running/final score, is_winner is stamped on finish (false for every seat
|
|
||||||
-- on a draw), hints_used counts the per-game allowance consumed before the
|
|
||||||
-- profile wallet.
|
|
||||||
CREATE TABLE game_players (
|
|
||||||
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
|
||||||
seat smallint NOT NULL,
|
|
||||||
account_id uuid NOT NULL REFERENCES accounts (account_id),
|
|
||||||
score integer NOT NULL DEFAULT 0,
|
|
||||||
hints_used smallint NOT NULL DEFAULT 0,
|
|
||||||
is_winner boolean NOT NULL DEFAULT false,
|
|
||||||
PRIMARY KEY (game_id, seat),
|
|
||||||
CONSTRAINT game_players_account_key UNIQUE (game_id, account_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX game_players_account_idx ON game_players (account_id);
|
|
||||||
|
|
||||||
-- The append-only, dictionary-independent move journal (docs/ARCHITECTURE.md
|
|
||||||
-- §9.1). seq orders the moves from 0. payload holds the decoded values needed to
|
|
||||||
-- both replay the game through the engine and emit GCG without a dictionary: the
|
|
||||||
-- acting rack, and for a play its direction, placed tiles and formed words; for
|
|
||||||
-- an exchange the swapped tiles. score / running_total / exchanged_count are
|
|
||||||
-- lifted out for cheap history rendering.
|
|
||||||
CREATE TABLE game_moves (
|
|
||||||
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
|
||||||
seq integer NOT NULL,
|
|
||||||
seat smallint NOT NULL,
|
|
||||||
action text NOT NULL,
|
|
||||||
score integer NOT NULL DEFAULT 0,
|
|
||||||
running_total integer NOT NULL DEFAULT 0,
|
|
||||||
exchanged_count smallint NOT NULL DEFAULT 0,
|
|
||||||
payload text NOT NULL DEFAULT '{}',
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
PRIMARY KEY (game_id, seq),
|
|
||||||
CONSTRAINT game_moves_action_chk CHECK (action IN ('play', 'pass', 'exchange', 'resign', 'timeout'))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Word-check complaints captured in the context of a game's pinned dictionary.
|
|
||||||
-- The admin review queue and the resolution lifecycle land in Stage 9, which
|
|
||||||
-- owns the status state machine; Stage 3 only ever writes 'open'.
|
|
||||||
CREATE TABLE complaints (
|
|
||||||
complaint_id uuid PRIMARY KEY,
|
|
||||||
complainant_id uuid NOT NULL REFERENCES accounts (account_id),
|
|
||||||
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
|
||||||
variant text NOT NULL,
|
|
||||||
dict_version text NOT NULL,
|
|
||||||
word text NOT NULL,
|
|
||||||
was_valid boolean NOT NULL,
|
|
||||||
note text NOT NULL DEFAULT '',
|
|
||||||
status text NOT NULL DEFAULT 'open',
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE INDEX complaints_status_idx ON complaints (status);
|
|
||||||
|
|
||||||
-- Per-account lifetime statistics, recomputed incrementally on each game finish.
|
|
||||||
-- Guests have no durable account and never appear here. A draw increments draws
|
|
||||||
-- only (neither wins nor losses). max_word_points is the best single move score
|
|
||||||
-- (which already folds in every word the move formed and the all-tiles bonus).
|
|
||||||
CREATE TABLE account_stats (
|
|
||||||
account_id uuid PRIMARY KEY REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
||||||
wins integer NOT NULL DEFAULT 0,
|
|
||||||
losses integer NOT NULL DEFAULT 0,
|
|
||||||
draws integer NOT NULL DEFAULT 0,
|
|
||||||
max_game_points integer NOT NULL DEFAULT 0,
|
|
||||||
max_word_points integer NOT NULL DEFAULT 0,
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
DROP TABLE account_stats;
|
|
||||||
DROP TABLE complaints;
|
|
||||||
DROP TABLE game_moves;
|
|
||||||
DROP TABLE game_players;
|
|
||||||
DROP TABLE games;
|
|
||||||
ALTER TABLE accounts
|
|
||||||
DROP CONSTRAINT accounts_hint_balance_chk,
|
|
||||||
DROP COLUMN hint_balance,
|
|
||||||
DROP COLUMN away_end,
|
|
||||||
DROP COLUMN away_start;
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- Stage 4 lobby & social: the friend graph, per-user blocks, per-game chat (with
|
|
||||||
-- nudge folded in as a message kind), email confirm-codes, and friend-game
|
|
||||||
-- invitations -- plus the per-game drop-out tile disposition the multi-player
|
|
||||||
-- engine needs. Matchmaking is an in-memory pool and persists nothing.
|
|
||||||
SET search_path = backend, pg_catalog;
|
|
||||||
|
|
||||||
-- The disposition of a dropped-out player's tiles in a game with three or more
|
|
||||||
-- seats (docs/ARCHITECTURE.md §6), chosen at creation: 'remove' burns them
|
|
||||||
-- (default), 'return' puts them back in the bag. Moot for a two-player game,
|
|
||||||
-- which ends on the first drop-out. engine.DropoutTiles owns the stable labels.
|
|
||||||
ALTER TABLE games
|
|
||||||
ADD COLUMN dropout_tiles text NOT NULL DEFAULT 'remove',
|
|
||||||
ADD CONSTRAINT games_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return'));
|
|
||||||
|
|
||||||
-- The friend graph. A row is created by the requester as 'pending' and flipped to
|
|
||||||
-- 'accepted' by the addressee; declining, cancelling or unfriending deletes the
|
|
||||||
-- row. Friendship is symmetric: a player's friends are the accepted rows in
|
|
||||||
-- either direction. A pair has at most one row (guarded in Go against either
|
|
||||||
-- direction existing).
|
|
||||||
CREATE TABLE friendships (
|
|
||||||
requester_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
||||||
addressee_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
||||||
status text NOT NULL DEFAULT 'pending',
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
responded_at timestamptz,
|
|
||||||
PRIMARY KEY (requester_id, addressee_id),
|
|
||||||
CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted')),
|
|
||||||
CONSTRAINT friendships_distinct_chk CHECK (requester_id <> addressee_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX friendships_addressee_idx ON friendships (addressee_id);
|
|
||||||
|
|
||||||
-- Per-user blocks. blocker_id has blocked blocked_id; the effect is applied
|
|
||||||
-- mutually by the social checks (a block in either direction suppresses chat
|
|
||||||
-- visibility and prevents requests/invitations between the pair).
|
|
||||||
CREATE TABLE blocks (
|
|
||||||
blocker_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
||||||
blocked_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
PRIMARY KEY (blocker_id, blocked_id),
|
|
||||||
CONSTRAINT blocks_distinct_chk CHECK (blocker_id <> blocked_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX blocks_blocked_idx ON blocks (blocked_id);
|
|
||||||
|
|
||||||
-- Per-game chat. A nudge ("it's your move") is a kind='nudge' row with an empty
|
|
||||||
-- body, so one journal carries both chatter and nudges. body is capped at 60
|
|
||||||
-- runes (enforced again in Go on input, where the content filter also rejects
|
|
||||||
-- links/emails/phone numbers). sender_ip holds the gateway-forwarded client IP as
|
|
||||||
-- a validated string (text, not inet, to avoid go-jet literal friction; the
|
|
||||||
-- gateway populates it in Stage 6). Chat is part of the game archive and is never
|
|
||||||
-- purged; it cascades away only with its game.
|
|
||||||
CREATE TABLE chat_messages (
|
|
||||||
message_id uuid PRIMARY KEY,
|
|
||||||
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
|
||||||
sender_id uuid NOT NULL REFERENCES accounts (account_id),
|
|
||||||
kind text NOT NULL DEFAULT 'message',
|
|
||||||
body text NOT NULL DEFAULT '',
|
|
||||||
sender_ip text,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT chat_messages_kind_chk CHECK (kind IN ('message', 'nudge')),
|
|
||||||
CONSTRAINT chat_messages_body_len_chk CHECK (char_length(body) <= 60),
|
|
||||||
CONSTRAINT chat_messages_nudge_empty_chk CHECK (kind <> 'nudge' OR body = '')
|
|
||||||
);
|
|
||||||
CREATE INDEX chat_messages_game_idx ON chat_messages (game_id, created_at);
|
|
||||||
-- Backs the once-per-hour nudge rate-limit lookup (latest nudge by a sender).
|
|
||||||
CREATE INDEX chat_messages_nudge_idx ON chat_messages (game_id, sender_id, created_at)
|
|
||||||
WHERE kind = 'nudge';
|
|
||||||
|
|
||||||
-- Pending email confirm-codes. code_hash is the hex-encoded SHA-256 of the
|
|
||||||
-- 6-digit code (the plaintext is never stored, matching the session model);
|
|
||||||
-- expires_at bounds the TTL and attempts caps brute force. A row is consumed
|
|
||||||
-- (consumed_at stamped) on success. A re-request deletes the prior pending row
|
|
||||||
-- for the same (account, lowercased email) and inserts a fresh one.
|
|
||||||
CREATE TABLE email_confirmations (
|
|
||||||
confirmation_id uuid PRIMARY KEY,
|
|
||||||
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
||||||
email text NOT NULL,
|
|
||||||
code_hash text NOT NULL,
|
|
||||||
expires_at timestamptz NOT NULL,
|
|
||||||
attempts smallint NOT NULL DEFAULT 0,
|
|
||||||
consumed_at timestamptz,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT email_confirmations_attempts_chk CHECK (attempts >= 0)
|
|
||||||
);
|
|
||||||
CREATE INDEX email_confirmations_account_idx ON email_confirmations (account_id);
|
|
||||||
|
|
||||||
-- A friend-game invitation. The inviter (seat 0) proposes the game settings to
|
|
||||||
-- 1..3 invitees; the game starts only when every invitee has accepted, and any
|
|
||||||
-- decline cancels the whole invitation. Lazily expired after expires_at (no
|
|
||||||
-- background sweep). game_id is set when the game is started.
|
|
||||||
CREATE TABLE game_invitations (
|
|
||||||
invitation_id uuid PRIMARY KEY,
|
|
||||||
inviter_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
||||||
variant text NOT NULL,
|
|
||||||
turn_timeout_secs integer NOT NULL,
|
|
||||||
hints_allowed boolean NOT NULL DEFAULT true,
|
|
||||||
hints_per_player smallint NOT NULL DEFAULT 1,
|
|
||||||
dropout_tiles text NOT NULL DEFAULT 'remove',
|
|
||||||
status text NOT NULL DEFAULT 'pending',
|
|
||||||
game_id uuid REFERENCES games (game_id) ON DELETE SET NULL,
|
|
||||||
expires_at timestamptz NOT NULL,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT game_invitations_variant_chk CHECK (variant IN ('english', 'russian_scrabble', 'erudit')),
|
|
||||||
CONSTRAINT game_invitations_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return')),
|
|
||||||
CONSTRAINT game_invitations_status_chk CHECK (status IN ('pending', 'declined', 'cancelled', 'expired', 'started')),
|
|
||||||
CONSTRAINT game_invitations_turn_timeout_chk CHECK (turn_timeout_secs > 0),
|
|
||||||
CONSTRAINT game_invitations_hints_per_player_chk CHECK (hints_per_player >= 0)
|
|
||||||
);
|
|
||||||
CREATE INDEX game_invitations_inviter_idx ON game_invitations (inviter_id);
|
|
||||||
|
|
||||||
-- One row per invitee (the inviter is implicit seat 0). seat is the invitee's
|
|
||||||
-- seat in the started game (1..3, in invitation order). response tracks each
|
|
||||||
-- invitee's pending/accepted/declined decision.
|
|
||||||
CREATE TABLE game_invitation_invitees (
|
|
||||||
invitation_id uuid NOT NULL REFERENCES game_invitations (invitation_id) ON DELETE CASCADE,
|
|
||||||
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
||||||
seat smallint NOT NULL,
|
|
||||||
response text NOT NULL DEFAULT 'pending',
|
|
||||||
responded_at timestamptz,
|
|
||||||
PRIMARY KEY (invitation_id, account_id),
|
|
||||||
CONSTRAINT game_invitation_invitees_response_chk CHECK (response IN ('pending', 'accepted', 'declined')),
|
|
||||||
CONSTRAINT game_invitation_invitees_seat_chk CHECK (seat BETWEEN 1 AND 3)
|
|
||||||
);
|
|
||||||
CREATE INDEX game_invitation_invitees_account_idx ON game_invitation_invitees (account_id);
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
DROP TABLE game_invitation_invitees;
|
|
||||||
DROP TABLE game_invitations;
|
|
||||||
DROP TABLE email_confirmations;
|
|
||||||
DROP TABLE chat_messages;
|
|
||||||
DROP TABLE blocks;
|
|
||||||
DROP TABLE friendships;
|
|
||||||
ALTER TABLE games
|
|
||||||
DROP CONSTRAINT games_dropout_tiles_chk,
|
|
||||||
DROP COLUMN dropout_tiles;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- Stage 5 robot opponent: admit a 'robot' identity kind so the robot pool can be
|
|
||||||
-- provisioned as durable accounts (one identity row per named robot). This widens
|
|
||||||
-- the identities kind CHECK only; no table or column changes, so the generated
|
|
||||||
-- jet code is unaffected.
|
|
||||||
SET search_path = backend, pg_catalog;
|
|
||||||
|
|
||||||
ALTER TABLE identities DROP CONSTRAINT identities_kind_chk;
|
|
||||||
ALTER TABLE identities ADD CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email', 'robot'));
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
SET search_path = backend, pg_catalog;
|
|
||||||
|
|
||||||
ALTER TABLE identities DROP CONSTRAINT identities_kind_chk;
|
|
||||||
ALTER TABLE identities ADD CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email'));
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- Stage 6 gateway edge: mark ephemeral guest accounts. A guest is a durable
|
|
||||||
-- account row -- the sessions and game_players foreign keys both require one --
|
|
||||||
-- that carries no identity and no profile, friends, stats or history; is_guest
|
|
||||||
-- gates that exclusion (statistics recompute skips guest seats). This adds a
|
|
||||||
-- column, so the generated jet code is regenerated (cmd/jetgen).
|
|
||||||
SET search_path = backend, pg_catalog;
|
|
||||||
|
|
||||||
ALTER TABLE accounts ADD COLUMN is_guest boolean NOT NULL DEFAULT false;
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
SET search_path = backend, pg_catalog;
|
|
||||||
|
|
||||||
ALTER TABLE accounts DROP COLUMN is_guest;
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- Stage 8 social UI: two changes to the friend graph.
|
|
||||||
--
|
|
||||||
-- 1. A declined friend request is now remembered permanently (status 'declined')
|
|
||||||
-- instead of deleting the row, so a recipient's explicit "no" blocks the same
|
|
||||||
-- requester from re-sending (anti-spam). An ignored request still lazily
|
|
||||||
-- expires (30 days, computed from created_at in Go) and can then be re-sent; a
|
|
||||||
-- one-time friend code from the same person bypasses a prior decline. This
|
|
||||||
-- widens friendships_status_chk; the Stage 4 "declining deletes the row" rule
|
|
||||||
-- is superseded (cancelling by the requester still deletes).
|
|
||||||
--
|
|
||||||
-- 2. friend_codes backs the code-redeem add-a-friend path: the player who wants to
|
|
||||||
-- be added issues a one-time 6-digit numeric code; whoever enters it becomes
|
|
||||||
-- their friend immediately. Only the hex-encoded SHA-256 of the code is stored
|
|
||||||
-- (the plaintext is never persisted, matching the session and email-code
|
|
||||||
-- models); expires_at bounds the 12h TTL and consumed_at marks single use. At
|
|
||||||
-- most one live code exists per issuer (issuing a new one clears the prior
|
|
||||||
-- unconsumed code, enforced in Go). This adds a table, so the generated jet code
|
|
||||||
-- is regenerated (cmd/jetgen).
|
|
||||||
SET search_path = backend, pg_catalog;
|
|
||||||
|
|
||||||
ALTER TABLE friendships
|
|
||||||
DROP CONSTRAINT friendships_status_chk,
|
|
||||||
ADD CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted', 'declined'));
|
|
||||||
|
|
||||||
CREATE TABLE friend_codes (
|
|
||||||
code_id uuid PRIMARY KEY,
|
|
||||||
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
||||||
code_hash text NOT NULL,
|
|
||||||
expires_at timestamptz NOT NULL,
|
|
||||||
consumed_at timestamptz,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
-- Backs "clear the issuer's prior live code" on issue.
|
|
||||||
CREATE INDEX friend_codes_account_idx ON friend_codes (account_id);
|
|
||||||
-- Backs the redeem lookup by code hash.
|
|
||||||
CREATE INDEX friend_codes_code_hash_idx ON friend_codes (code_hash);
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
SET search_path = backend, pg_catalog;
|
|
||||||
|
|
||||||
DROP TABLE friend_codes;
|
|
||||||
ALTER TABLE friendships
|
|
||||||
DROP CONSTRAINT friendships_status_chk,
|
|
||||||
ADD CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted'));
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- Stage 9 Telegram integration: a per-account toggle that confines notifications
|
|
||||||
-- to the in-app live stream. When notifications_in_app_only is true (the default),
|
|
||||||
-- the platform side-service (Telegram) sends no out-of-app push; turning it off
|
|
||||||
-- opts into out-of-app push, which the gateway delivers only while the account has
|
|
||||||
-- no live in-app stream, so the in-app and platform channels never duplicate. Adds
|
|
||||||
-- a column, so the generated jet code is regenerated (cmd/jetgen).
|
|
||||||
SET search_path = backend, pg_catalog;
|
|
||||||
|
|
||||||
ALTER TABLE accounts
|
|
||||||
ADD COLUMN notifications_in_app_only boolean NOT NULL DEFAULT true;
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
SET search_path = backend, pg_catalog;
|
|
||||||
|
|
||||||
ALTER TABLE accounts
|
|
||||||
DROP COLUMN notifications_in_app_only;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- Stage 10 admin & dictionary ops: the word-check complaint resolution lifecycle.
|
|
||||||
-- Stage 3 created complaints with a free-form status (only ever 'open'); the admin
|
|
||||||
-- review queue (this stage) resolves them with a disposition that also feeds the
|
|
||||||
-- offline dictionary-rebuild pipeline: an accepted complaint records whether the
|
|
||||||
-- word should be added or removed, and is marked applied once a rebuilt dictionary
|
|
||||||
-- version is hot-reloaded. No operator identity is recorded (the gateway gates the
|
|
||||||
-- console behind Basic-Auth; the backend keeps no admin principal). Adds columns, so
|
|
||||||
-- the generated jet code is regenerated (cmd/jetgen).
|
|
||||||
SET search_path = backend, pg_catalog;
|
|
||||||
|
|
||||||
ALTER TABLE complaints
|
|
||||||
ADD COLUMN disposition text NOT NULL DEFAULT '',
|
|
||||||
ADD COLUMN resolution_note text NOT NULL DEFAULT '',
|
|
||||||
ADD COLUMN resolved_at timestamptz,
|
|
||||||
ADD COLUMN applied_in_version text NOT NULL DEFAULT '',
|
|
||||||
ADD CONSTRAINT complaints_status_chk CHECK (status IN ('open', 'resolved')),
|
|
||||||
ADD CONSTRAINT complaints_disposition_chk
|
|
||||||
CHECK (disposition IN ('', 'reject', 'accept_add', 'accept_remove'));
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
SET search_path = backend, pg_catalog;
|
|
||||||
|
|
||||||
ALTER TABLE complaints
|
|
||||||
DROP CONSTRAINT complaints_disposition_chk,
|
|
||||||
DROP CONSTRAINT complaints_status_chk,
|
|
||||||
DROP COLUMN applied_in_version,
|
|
||||||
DROP COLUMN resolved_at,
|
|
||||||
DROP COLUMN resolution_note,
|
|
||||||
DROP COLUMN disposition;
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- Stage 11 account linking & merge: retire a secondary account into a primary one.
|
|
||||||
-- merged_into/merged_at turn the secondary into an audit tombstone (its identities
|
|
||||||
-- are repointed and its non-shared rows transferred to the primary, but the row is
|
|
||||||
-- kept so the no-cascade game_players/chat/complaints foreign keys of any shared
|
|
||||||
-- finished game stay valid). merged_into self-references accounts and is SET NULL on
|
|
||||||
-- delete so a future guest reaper (PLAN.md TODO-3) can still remove a primary.
|
|
||||||
-- paid_account is a forward-looking lifetime one-time-payment marker (no purchase
|
|
||||||
-- flow yet); the merge ORs it so a paid status is never lost. Adds columns, so the
|
|
||||||
-- generated jet code is regenerated (cmd/jetgen).
|
|
||||||
SET search_path = backend, pg_catalog;
|
|
||||||
|
|
||||||
ALTER TABLE accounts
|
|
||||||
ADD COLUMN paid_account boolean NOT NULL DEFAULT false,
|
|
||||||
ADD COLUMN merged_into uuid REFERENCES accounts (account_id) ON DELETE SET NULL,
|
|
||||||
ADD COLUMN merged_at timestamptz;
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
SET search_path = backend, pg_catalog;
|
|
||||||
|
|
||||||
ALTER TABLE accounts
|
|
||||||
DROP COLUMN merged_at,
|
|
||||||
DROP COLUMN merged_into,
|
|
||||||
DROP COLUMN paid_account;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- Stage 15 dual Telegram bots: service_language records the language tag of the bot
|
|
||||||
-- a Telegram user last authenticated through (their last ValidateInitData). It is
|
|
||||||
-- updated on every Telegram login — new and existing accounts — and routes the
|
|
||||||
-- user's out-of-app push back through the right bot. It is distinct from
|
|
||||||
-- preferred_language (the interface language) and from a game's variant language.
|
|
||||||
-- Nullable: an account that has never signed in through a tagged bot (legacy,
|
|
||||||
-- email-only or guest) has no value, and push routing falls back to
|
|
||||||
-- preferred_language. Adds a column, so the generated jet code is regenerated
|
|
||||||
-- (cmd/jetgen).
|
|
||||||
SET search_path = backend, pg_catalog;
|
|
||||||
|
|
||||||
ALTER TABLE accounts
|
|
||||||
ADD COLUMN service_language text
|
|
||||||
CHECK (service_language IN ('en', 'ru'));
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
SET search_path = backend, pg_catalog;
|
|
||||||
|
|
||||||
ALTER TABLE accounts
|
|
||||||
DROP COLUMN service_language;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- Stage 17: a per-(game, account) draft the server persists across reloads and devices —
|
|
||||||
-- the player's preferred rack tile order (#4) and the tiles they have laid on the board but
|
|
||||||
-- not yet submitted (#5/#6). board_tiles is reset when an opponent's committed move overlaps
|
|
||||||
-- one of its cells (the draft can no longer be placed). Queried with raw SQL, so no
|
|
||||||
-- generated jet code is needed.
|
|
||||||
SET search_path = backend, pg_catalog;
|
|
||||||
|
|
||||||
CREATE TABLE game_drafts (
|
|
||||||
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
|
||||||
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
|
||||||
rack_order text NOT NULL DEFAULT '',
|
|
||||||
board_tiles jsonb NOT NULL DEFAULT '[]',
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
PRIMARY KEY (game_id, account_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
SET search_path = backend, pg_catalog;
|
|
||||||
|
|
||||||
DROP TABLE game_drafts;
|
|
||||||
@@ -58,6 +58,7 @@ func (s *Service) Subscribe(req *pushv1.SubscribeRequest, stream grpc.ServerStre
|
|||||||
Kind: in.Kind,
|
Kind: in.Kind,
|
||||||
Payload: in.Payload,
|
Payload: in.Payload,
|
||||||
EventId: in.EventID,
|
EventId: in.EventID,
|
||||||
|
Language: in.Language,
|
||||||
}
|
}
|
||||||
if err := stream.Send(ev); err != nil {
|
if err := stream.Send(ev); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// Package ratewatch ingests the gateway's periodic rate-limiter rejection
|
||||||
|
// reports. It keeps an in-memory window of recent throttle episodes for
|
||||||
|
// the admin console's view and applies the conservative high-rate auto-flag:
|
||||||
|
// when one account's rejections within the rolling window cross the threshold,
|
||||||
|
// the account store stamps the soft, reversible flagged_high_rate_at marker
|
||||||
|
// (set-once; an operator clears it; never an automatic ban). Like the gateway's
|
||||||
|
// active_users gauge it is single-instance and resets on restart by design —
|
||||||
|
// the durable part is the account flag, not the episode window.
|
||||||
|
package ratewatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClassUser is the limiter class whose keys are account ids — the only class
|
||||||
|
// the auto-flag applies to (the others are keyed by client IP).
|
||||||
|
const ClassUser = "user"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// maxSeries bounds the distinct (class, key) series kept for the console
|
||||||
|
// view, so a key-spraying client cannot grow the map: past the bound the
|
||||||
|
// least-recently-throttled series is evicted.
|
||||||
|
maxSeries = 200
|
||||||
|
// minRetention keeps an episode visible in the console for at least an hour
|
||||||
|
// after its last rejection (longer when the flag window is longer).
|
||||||
|
minRetention = time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config tunes the conservative high-rate auto-flag.
|
||||||
|
type Config struct {
|
||||||
|
// FlagThreshold is the rejected-call count within FlagWindow past which a
|
||||||
|
// user account is flagged.
|
||||||
|
FlagThreshold int
|
||||||
|
// FlagWindow is the rolling window the rejections accumulate over.
|
||||||
|
FlagWindow time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns the agreed conservative defaults — 1000 rejected calls
|
||||||
|
// within a rolling 10 minutes (~1.7/s sustained, far above the client's
|
||||||
|
// capped-backoff retry noise yet a fraction of an abusive loop).
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
return Config{FlagThreshold: 1000, FlagWindow: 10 * time.Minute}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reports whether the configuration values are acceptable.
|
||||||
|
func (c Config) Validate() error {
|
||||||
|
if c.FlagThreshold <= 0 {
|
||||||
|
return fmt.Errorf("ratewatch: flag threshold must be positive")
|
||||||
|
}
|
||||||
|
if c.FlagWindow <= 0 {
|
||||||
|
return fmt.Errorf("ratewatch: flag window must be positive")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flagger stamps the account-level high-rate marker; account.Store satisfies it.
|
||||||
|
type Flagger interface {
|
||||||
|
FlagHighRate(ctx context.Context, id uuid.UUID, at time.Time) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry is one reported aggregate: the rejections of one limiter key within one
|
||||||
|
// gateway report window (the wire mirror of the gateway's rejection summary).
|
||||||
|
type Entry struct {
|
||||||
|
Class string
|
||||||
|
Key string
|
||||||
|
Rejected int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Episode is one key's recent-throttle aggregate for the admin view.
|
||||||
|
type Episode struct {
|
||||||
|
Class string
|
||||||
|
Key string
|
||||||
|
Rejected int
|
||||||
|
FirstSeen time.Time
|
||||||
|
LastSeen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch accumulates reports and applies the auto-flag rule.
|
||||||
|
type Watch struct {
|
||||||
|
cfg Config
|
||||||
|
flagger Flagger
|
||||||
|
log *zap.Logger
|
||||||
|
now func() time.Time
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
series map[seriesKey]*series
|
||||||
|
}
|
||||||
|
|
||||||
|
type seriesKey struct{ class, key string }
|
||||||
|
|
||||||
|
type series struct {
|
||||||
|
points []point // ascending by time
|
||||||
|
}
|
||||||
|
|
||||||
|
type point struct {
|
||||||
|
at time.Time
|
||||||
|
n int
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Watch over flagger with cfg. A nil logger is replaced by a
|
||||||
|
// no-op one; a nil flagger disables the auto-flag (the view still works).
|
||||||
|
func New(cfg Config, flagger Flagger, log *zap.Logger) *Watch {
|
||||||
|
if log == nil {
|
||||||
|
log = zap.NewNop()
|
||||||
|
}
|
||||||
|
return &Watch{
|
||||||
|
cfg: cfg,
|
||||||
|
flagger: flagger,
|
||||||
|
log: log,
|
||||||
|
now: time.Now,
|
||||||
|
series: make(map[seriesKey]*series),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingest records one gateway report. Entries with an empty class or key or a
|
||||||
|
// non-positive count are skipped. When a user-class series crosses the flag
|
||||||
|
// threshold within the flag window, the account is flagged (the store keeps it
|
||||||
|
// set-once, so a sustained episode costs one no-op UPDATE per report).
|
||||||
|
func (w *Watch) Ingest(ctx context.Context, entries []Entry) {
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := w.now()
|
||||||
|
var flag []uuid.UUID
|
||||||
|
w.mu.Lock()
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Class == "" || e.Key == "" || e.Rejected <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k := seriesKey{class: e.Class, key: e.Key}
|
||||||
|
s := w.series[k]
|
||||||
|
if s == nil {
|
||||||
|
s = &series{}
|
||||||
|
w.series[k] = s
|
||||||
|
}
|
||||||
|
s.points = append(s.points, point{at: now, n: e.Rejected})
|
||||||
|
if e.Class == ClassUser && s.sumSince(now.Add(-w.cfg.FlagWindow)) >= w.cfg.FlagThreshold {
|
||||||
|
if id, err := uuid.Parse(e.Key); err == nil {
|
||||||
|
flag = append(flag, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.pruneLocked(now)
|
||||||
|
w.mu.Unlock()
|
||||||
|
|
||||||
|
if w.flagger == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range flag {
|
||||||
|
set, err := w.flagger.FlagHighRate(ctx, id, now)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
w.log.Warn("high-rate flag failed", zap.String("account_id", id.String()), zap.Error(err))
|
||||||
|
case set:
|
||||||
|
w.log.Info("account flagged high-rate",
|
||||||
|
zap.String("account_id", id.String()),
|
||||||
|
zap.Int("threshold", w.cfg.FlagThreshold),
|
||||||
|
zap.Duration("window", w.cfg.FlagWindow))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config returns the active auto-flag tuning (the admin console captions it).
|
||||||
|
func (w *Watch) Config() Config { return w.cfg }
|
||||||
|
|
||||||
|
// Recent returns the retained throttle episodes, most recently throttled first.
|
||||||
|
func (w *Watch) Recent() []Episode {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
out := make([]Episode, 0, len(w.series))
|
||||||
|
for k, s := range w.series {
|
||||||
|
if len(s.points) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ep := Episode{
|
||||||
|
Class: k.class,
|
||||||
|
Key: k.key,
|
||||||
|
FirstSeen: s.points[0].at,
|
||||||
|
LastSeen: s.points[len(s.points)-1].at,
|
||||||
|
}
|
||||||
|
for _, p := range s.points {
|
||||||
|
ep.Rejected += p.n
|
||||||
|
}
|
||||||
|
out = append(out, ep)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].LastSeen.After(out[j].LastSeen) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// sumSince totals the points at or after cutoff.
|
||||||
|
func (s *series) sumSince(cutoff time.Time) int {
|
||||||
|
sum := 0
|
||||||
|
for i := len(s.points) - 1; i >= 0; i-- {
|
||||||
|
if s.points[i].at.Before(cutoff) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sum += s.points[i].n
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneLocked drops points past retention, empty series, and — past maxSeries —
|
||||||
|
// the least-recently-throttled series. The caller holds w.mu.
|
||||||
|
func (w *Watch) pruneLocked(now time.Time) {
|
||||||
|
cutoff := now.Add(-max(minRetention, w.cfg.FlagWindow))
|
||||||
|
for k, s := range w.series {
|
||||||
|
i := 0
|
||||||
|
for i < len(s.points) && s.points[i].at.Before(cutoff) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
s.points = s.points[i:]
|
||||||
|
if len(s.points) == 0 {
|
||||||
|
delete(w.series, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for len(w.series) > maxSeries {
|
||||||
|
var oldest seriesKey
|
||||||
|
var oldestAt time.Time
|
||||||
|
first := true
|
||||||
|
for k, s := range w.series {
|
||||||
|
last := s.points[len(s.points)-1].at
|
||||||
|
if first || last.Before(oldestAt) {
|
||||||
|
oldest, oldestAt, first = k, last, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(w.series, oldest)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package ratewatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeFlagger records flag calls and reports them as newly set.
|
||||||
|
type fakeFlagger struct {
|
||||||
|
calls []uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeFlagger) FlagHighRate(_ context.Context, id uuid.UUID, _ time.Time) (bool, error) {
|
||||||
|
f.calls = append(f.calls, id)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchAt returns a Watch with a controllable clock.
|
||||||
|
func watchAt(cfg Config, flagger Flagger, at *time.Time) *Watch {
|
||||||
|
w := New(cfg, flagger, nil)
|
||||||
|
w.now = func() time.Time { return *at }
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIngestAggregatesAndRecent verifies episodes accumulate per (class, key),
|
||||||
|
// invalid entries are skipped, and Recent orders by last rejection.
|
||||||
|
func TestIngestAggregatesAndRecent(t *testing.T) {
|
||||||
|
now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC)
|
||||||
|
w := watchAt(DefaultConfig(), nil, &now)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
w.Ingest(ctx, []Entry{
|
||||||
|
{Class: "public", Key: "10.0.0.1", Rejected: 3},
|
||||||
|
{Class: "user", Key: "u-1", Rejected: 5},
|
||||||
|
{Class: "", Key: "x", Rejected: 1},
|
||||||
|
{Class: "user", Key: "", Rejected: 1},
|
||||||
|
{Class: "user", Key: "u-1", Rejected: 0},
|
||||||
|
})
|
||||||
|
now = now.Add(30 * time.Second)
|
||||||
|
w.Ingest(ctx, []Entry{{Class: "public", Key: "10.0.0.1", Rejected: 4}})
|
||||||
|
|
||||||
|
got := w.Recent()
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("Recent returned %d episodes, want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Class != "public" || got[0].Key != "10.0.0.1" || got[0].Rejected != 7 {
|
||||||
|
t.Errorf("first episode = %+v, want public/10.0.0.1 rejected=7", got[0])
|
||||||
|
}
|
||||||
|
if !got[0].LastSeen.After(got[0].FirstSeen) {
|
||||||
|
t.Errorf("episode span = [%v, %v], want a positive span", got[0].FirstSeen, got[0].LastSeen)
|
||||||
|
}
|
||||||
|
if got[1].Class != "user" || got[1].Rejected != 5 {
|
||||||
|
t.Errorf("second episode = %+v, want user rejected=5", got[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAutoFlagThreshold verifies the flag fires only for a user-class series
|
||||||
|
// crossing the threshold within the window, with a parseable account id.
|
||||||
|
func TestAutoFlagThreshold(t *testing.T) {
|
||||||
|
now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC)
|
||||||
|
flagged := &fakeFlagger{}
|
||||||
|
id := uuid.New()
|
||||||
|
w := watchAt(Config{FlagThreshold: 100, FlagWindow: 10 * time.Minute}, flagged, &now)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
w.Ingest(ctx, []Entry{
|
||||||
|
{Class: "user", Key: id.String(), Rejected: 99},
|
||||||
|
{Class: "public", Key: "10.0.0.1", Rejected: 1000},
|
||||||
|
{Class: "user", Key: "not-a-uuid", Rejected: 1000},
|
||||||
|
})
|
||||||
|
if len(flagged.calls) != 0 {
|
||||||
|
t.Fatalf("flagged %v below the threshold", flagged.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
now = now.Add(30 * time.Second)
|
||||||
|
w.Ingest(ctx, []Entry{{Class: "user", Key: id.String(), Rejected: 1}})
|
||||||
|
if len(flagged.calls) != 1 || flagged.calls[0] != id {
|
||||||
|
t.Fatalf("flag calls = %v, want exactly [%s]", flagged.calls, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAutoFlagWindowExpiry verifies rejections age out of the rolling window.
|
||||||
|
func TestAutoFlagWindowExpiry(t *testing.T) {
|
||||||
|
now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC)
|
||||||
|
flagged := &fakeFlagger{}
|
||||||
|
id := uuid.New()
|
||||||
|
w := watchAt(Config{FlagThreshold: 100, FlagWindow: 10 * time.Minute}, flagged, &now)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
w.Ingest(ctx, []Entry{{Class: "user", Key: id.String(), Rejected: 60}})
|
||||||
|
now = now.Add(11 * time.Minute)
|
||||||
|
w.Ingest(ctx, []Entry{{Class: "user", Key: id.String(), Rejected: 60}})
|
||||||
|
if len(flagged.calls) != 0 {
|
||||||
|
t.Fatalf("flagged %v across an expired window", flagged.calls)
|
||||||
|
}
|
||||||
|
now = now.Add(time.Minute)
|
||||||
|
w.Ingest(ctx, []Entry{{Class: "user", Key: id.String(), Rejected: 50}})
|
||||||
|
if len(flagged.calls) != 1 {
|
||||||
|
t.Fatalf("flag calls = %v, want one in-window crossing", flagged.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeriesBound verifies the episode map stays bounded by evicting the
|
||||||
|
// least-recently-throttled series.
|
||||||
|
func TestSeriesBound(t *testing.T) {
|
||||||
|
now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC)
|
||||||
|
w := watchAt(DefaultConfig(), nil, &now)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := range maxSeries + 10 {
|
||||||
|
now = now.Add(time.Second)
|
||||||
|
w.Ingest(ctx, []Entry{{Class: "public", Key: fmt.Sprintf("10.0.%d.%d", i/256, i%256), Rejected: 1}})
|
||||||
|
}
|
||||||
|
got := w.Recent()
|
||||||
|
if len(got) != maxSeries {
|
||||||
|
t.Fatalf("retained %d series, want %d", len(got), maxSeries)
|
||||||
|
}
|
||||||
|
for _, ep := range got {
|
||||||
|
if ep.Key == "10.0.0.0" {
|
||||||
|
t.Fatal("the least-recently-throttled series survived the bound")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigValidate covers the tuning guards.
|
||||||
|
func TestConfigValidate(t *testing.T) {
|
||||||
|
if err := DefaultConfig().Validate(); err != nil {
|
||||||
|
t.Errorf("default config invalid: %v", err)
|
||||||
|
}
|
||||||
|
if err := (Config{FlagThreshold: 0, FlagWindow: time.Minute}).Validate(); err == nil {
|
||||||
|
t.Error("zero threshold passed validation")
|
||||||
|
}
|
||||||
|
if err := (Config{FlagThreshold: 1, FlagWindow: 0}).Validate(); err == nil {
|
||||||
|
t.Error("zero window passed validation")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,11 +96,20 @@ func (s *Service) maybeMove(ctx context.Context, rt game.RobotTurn, oppID uuid.U
|
|||||||
return s.act(ctx, rt, now)
|
return s.act(ctx, rt, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeNudge sends a proactive nudge once the human has been idle past the
|
// maybeNudge sends a proactive nudge on a lengthening, randomized schedule (proactiveNudgeGap):
|
||||||
// threshold. The social service enforces the once-per-hour-per-game limit and
|
// the first lands ~60-90 min into the human's turn, and each one waits longer than the last, so a
|
||||||
// rejects a nudge on the robot's own turn, so any such rejection is benign.
|
// long idle turn gets a handful of increasingly-spaced reminders rather than an hourly stream. The
|
||||||
|
// gap is measured from the previous nudge (or the turn start for the first). The social service
|
||||||
|
// still enforces the once-per-game floor and rejects a nudge on the robot's own turn, so any such
|
||||||
|
// rejection is benign.
|
||||||
func (s *Service) maybeNudge(ctx context.Context, rt game.RobotTurn, now time.Time) error {
|
func (s *Service) maybeNudge(ctx context.Context, rt game.RobotTurn, now time.Time) error {
|
||||||
if now.Sub(rt.TurnStartedAt) < proactiveNudgeIdle {
|
ref := rt.TurnStartedAt
|
||||||
|
if last, ok, err := s.social.LastNudgeAt(ctx, rt.GameID, rt.RobotID); err != nil {
|
||||||
|
return err
|
||||||
|
} else if ok && last.After(rt.TurnStartedAt) {
|
||||||
|
ref = last
|
||||||
|
}
|
||||||
|
if now.Sub(ref) < proactiveNudgeGap(ref.Sub(rt.TurnStartedAt), rt.Seed) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if _, err := s.social.Nudge(ctx, rt.GameID, rt.RobotID); err != nil {
|
if _, err := s.social.Nudge(ctx, rt.GameID, rt.RobotID); err != nil {
|
||||||
|
|||||||
@@ -75,14 +75,14 @@ func TestPickVariantRouting(t *testing.T) {
|
|||||||
s := &Service{poolEN: []uuid.UUID{enID}, poolRU: []uuid.UUID{ruID}}
|
s := &Service{poolEN: []uuid.UUID{enID}, poolRU: []uuid.UUID{ruID}}
|
||||||
for i := 0; i < 200; i++ {
|
for i := 0; i < 200; i++ {
|
||||||
if got, err := s.Pick(engine.VariantEnglish); err != nil || got != enID {
|
if got, err := s.Pick(engine.VariantEnglish); err != nil || got != enID {
|
||||||
t.Fatalf("english Pick = (%v, %v), want (%v, nil)", got, err, enID)
|
t.Fatalf("scrabble_en Pick = (%v, %v), want (%v, nil)", got, err, enID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var en, ru int
|
var en, ru int
|
||||||
for i := 0; i < 4000; i++ {
|
for i := 0; i < 4000; i++ {
|
||||||
got, err := s.Pick(engine.VariantRussianScrabble)
|
got, err := s.Pick(engine.VariantRussianScrabble)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("russian Pick: %v", err)
|
t.Fatalf("scrabble_ru Pick: %v", err)
|
||||||
}
|
}
|
||||||
switch got {
|
switch got {
|
||||||
case enID:
|
case enID:
|
||||||
@@ -92,14 +92,14 @@ func TestPickVariantRouting(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ru <= en {
|
if ru <= en {
|
||||||
t.Errorf("russian names should dominate a Russian game: ru=%d en=%d", ru, en)
|
t.Errorf("scrabble_ru names should dominate a Russian game: ru=%d en=%d", ru, en)
|
||||||
}
|
}
|
||||||
if en == 0 {
|
if en == 0 {
|
||||||
t.Errorf("some Latin names should appear in Russian games (got 0 of 4000)")
|
t.Errorf("some Latin names should appear in Russian games (got 0 of 4000)")
|
||||||
}
|
}
|
||||||
// Эрудит routes like Russian Scrabble.
|
// Эрудит routes like Russian Scrabble.
|
||||||
if _, err := s.Pick(engine.VariantErudit); err != nil {
|
if _, err := s.Pick(engine.VariantErudit); err != nil {
|
||||||
t.Errorf("erudit Pick: %v", err)
|
t.Errorf("erudit_ru Pick: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,10 +108,10 @@ func TestPickVariantRouting(t *testing.T) {
|
|||||||
func TestPickFallback(t *testing.T) {
|
func TestPickFallback(t *testing.T) {
|
||||||
id := uuid.New()
|
id := uuid.New()
|
||||||
if got, err := (&Service{poolEN: []uuid.UUID{id}}).Pick(engine.VariantRussianScrabble); err != nil || got != id {
|
if got, err := (&Service{poolEN: []uuid.UUID{id}}).Pick(engine.VariantRussianScrabble); err != nil || got != id {
|
||||||
t.Errorf("russian fallback to EN = (%v, %v), want (%v, nil)", got, err, id)
|
t.Errorf("scrabble_ru fallback to EN = (%v, %v), want (%v, nil)", got, err, id)
|
||||||
}
|
}
|
||||||
if got, err := (&Service{poolRU: []uuid.UUID{id}}).Pick(engine.VariantEnglish); err != nil || got != id {
|
if got, err := (&Service{poolRU: []uuid.UUID{id}}).Pick(engine.VariantEnglish); err != nil || got != id {
|
||||||
t.Errorf("english fallback to RU = (%v, %v), want (%v, nil)", got, err, id)
|
t.Errorf("scrabble_en fallback to RU = (%v, %v), want (%v, nil)", got, err, id)
|
||||||
}
|
}
|
||||||
if _, err := (&Service{}).Pick(engine.VariantEnglish); !errors.Is(err, ErrNoRobotAvailable) {
|
if _, err := (&Service{}).Pick(engine.VariantEnglish); !errors.Is(err, ErrNoRobotAvailable) {
|
||||||
t.Errorf("empty pool err = %v, want ErrNoRobotAvailable", err)
|
t.Errorf("empty pool err = %v, want ErrNoRobotAvailable", err)
|
||||||
|
|||||||
@@ -55,9 +55,16 @@ const (
|
|||||||
// sleep window relative to the opponent's timezone, in hours.
|
// sleep window relative to the opponent's timezone, in hours.
|
||||||
sleepDriftHours = 3
|
sleepDriftHours = 3
|
||||||
|
|
||||||
// proactiveNudgeIdle is how long the robot waits on the human's turn before it
|
// The robot proactively nudges the idle human on a lengthening, randomized schedule rather
|
||||||
// proactively nudges (subject to the social once-per-hour-per-game limit).
|
// than an hourly stream: the first nudge lands ~60-90 min into the turn, and each subsequent
|
||||||
proactiveNudgeIdle = 12 * time.Hour
|
// gap grows toward 1-6 h the longer the wait drags on, so a long idle turn gets only a handful
|
||||||
|
// of increasingly-spaced reminders. The gap is a uniform sample in [nudgeGapFloorMinutes,
|
||||||
|
// ceil] minutes, where ceil ramps from nudgeGapFirstCeilMinutes to nudgeGapCeilMinutes over
|
||||||
|
// nudgeGapRamp of idle.
|
||||||
|
nudgeGapFloorMinutes = 60.0
|
||||||
|
nudgeGapFirstCeilMinutes = 90.0
|
||||||
|
nudgeGapCeilMinutes = 360.0
|
||||||
|
nudgeGapRamp = 12 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultBand is the target resulting score margin after the robot's move: when
|
// defaultBand is the target resulting score margin after the robot's move: when
|
||||||
@@ -181,6 +188,23 @@ func nudgeReplyDelay(seed int64, moveCount int) time.Duration {
|
|||||||
return clampMinutes(lo + nudgeReplySpreadMinutes*u)
|
return clampMinutes(lo + nudgeReplySpreadMinutes*u)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// proactiveNudgeGap is the randomized wait before the next proactive nudge, given how long the
|
||||||
|
// human had already been idle at the previous nudge (refIdle; 0 for the first nudge of the turn).
|
||||||
|
// It is a uniform sample in [nudgeGapFloorMinutes, ceil] minutes, where ceil ramps from
|
||||||
|
// nudgeGapFirstCeilMinutes (a ~60-90 min first gap) up to nudgeGapCeilMinutes (a 1-6 h gap) as
|
||||||
|
// refIdle reaches nudgeGapRamp — so the reminders space out the longer the turn is neglected. It
|
||||||
|
// is deterministic per (seed, refIdle), so the driver computes the same due time on every scan.
|
||||||
|
func proactiveNudgeGap(refIdle time.Duration, seed int64) time.Duration {
|
||||||
|
f := float64(refIdle) / float64(nudgeGapRamp)
|
||||||
|
if f > 1 {
|
||||||
|
f = 1
|
||||||
|
}
|
||||||
|
ceil := nudgeGapFirstCeilMinutes + (nudgeGapCeilMinutes-nudgeGapFirstCeilMinutes)*f
|
||||||
|
u := unitFloat(mix(seed, "pnudge", int(refIdle/(30*time.Minute))))
|
||||||
|
mins := nudgeGapFloorMinutes + (ceil-nudgeGapFloorMinutes)*u
|
||||||
|
return time.Duration(mins * float64(time.Minute))
|
||||||
|
}
|
||||||
|
|
||||||
// clampMinutes converts a minute count to a duration, clamping it to the hard delay
|
// clampMinutes converts a minute count to a duration, clamping it to the hard delay
|
||||||
// bounds so an out-of-range band can never produce an absurd think time.
|
// bounds so an out-of-range band can never produce an absurd think time.
|
||||||
func clampMinutes(mins float64) time.Duration {
|
func clampMinutes(mins float64) time.Duration {
|
||||||
|
|||||||
@@ -238,6 +238,38 @@ func TestPlayToWinExport(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProactiveNudgeGap checks the proactive-nudge schedule: the first gap (refIdle 0) is
|
||||||
|
// ~60-90 min, every gap stays within [60 min, 6 h] and is deterministic, and the gap lengthens
|
||||||
|
// as the idle grows (the median at 12 h idle exceeds the median at the start).
|
||||||
|
func TestProactiveNudgeGap(t *testing.T) {
|
||||||
|
for seed := int64(1); seed <= 1000; seed++ {
|
||||||
|
if first := proactiveNudgeGap(0, seed); first < 60*time.Minute || first > 90*time.Minute {
|
||||||
|
t.Fatalf("first gap %s out of [60m,90m] for seed %d", first, seed)
|
||||||
|
}
|
||||||
|
for _, idle := range []time.Duration{0, time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour, 24 * time.Hour} {
|
||||||
|
g := proactiveNudgeGap(idle, seed)
|
||||||
|
if g < 60*time.Minute || g > 6*time.Hour {
|
||||||
|
t.Fatalf("gap %s out of [60m,6h] for seed %d idle %s", g, seed, idle)
|
||||||
|
}
|
||||||
|
if proactiveNudgeGap(idle, seed) != g {
|
||||||
|
t.Fatalf("gap not deterministic for seed %d idle %s", seed, idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
median := func(idle time.Duration) float64 {
|
||||||
|
const n = 4000
|
||||||
|
xs := make([]float64, n)
|
||||||
|
for s := 0; s < n; s++ {
|
||||||
|
xs[s] = proactiveNudgeGap(idle, int64(s+1)).Minutes()
|
||||||
|
}
|
||||||
|
sort.Float64s(xs)
|
||||||
|
return xs[n/2]
|
||||||
|
}
|
||||||
|
if early, late := median(0), median(12*time.Hour); early >= late {
|
||||||
|
t.Errorf("median gap should grow with idle: idle0=%.0f idle12h=%.0f", early, late)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// plays builds candidate plays carrying only the given scores (ranked as passed).
|
// plays builds candidate plays carrying only the given scores (ranked as passed).
|
||||||
func plays(scores ...int) []engine.MoveRecord {
|
func plays(scores ...int) []engine.MoveRecord {
|
||||||
out := make([]engine.MoveRecord, len(scores))
|
out := make([]engine.MoveRecord, len(scores))
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestCSVSafe checks the CSV/spreadsheet formula-injection guard used by the admin Messages
|
||||||
|
// export: a leading formula trigger is quoted, everything else is left intact.
|
||||||
|
func TestCSVSafe(t *testing.T) {
|
||||||
|
tests := []struct{ in, want string }{
|
||||||
|
{"", ""},
|
||||||
|
{"hello", "hello"},
|
||||||
|
{"=1+1", "'=1+1"},
|
||||||
|
{"+cmd", "'+cmd"},
|
||||||
|
{"-2", "'-2"},
|
||||||
|
{"@SUM(A1)", "'@SUM(A1)"},
|
||||||
|
{"\tx", "'\tx"},
|
||||||
|
{"\rx", "'\rx"},
|
||||||
|
{"good luck", "good luck"},
|
||||||
|
{"a=b", "a=b"}, // a formula char that is not leading must be left untouched
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := csvSafe(tc.in); got != tc.want {
|
||||||
|
t.Errorf("csvSafe(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,27 +93,30 @@ type gameDTO struct {
|
|||||||
MoveCount int `json:"move_count"`
|
MoveCount int `json:"move_count"`
|
||||||
EndReason string `json:"end_reason"`
|
EndReason string `json:"end_reason"`
|
||||||
// LastActivityUnix is the lobby sort key: the current turn's start for an active
|
// LastActivityUnix is the lobby sort key: the current turn's start for an active
|
||||||
// game, the finish time once finished (Stage 17).
|
// game, the finish time once finished.
|
||||||
LastActivityUnix int64 `json:"last_activity_unix"`
|
LastActivityUnix int64 `json:"last_activity_unix"`
|
||||||
Seats []seatDTO `json:"seats"`
|
Seats []seatDTO `json:"seats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// moveResultDTO is the outcome of a committed move.
|
// moveResultDTO is the outcome of a committed move. Rack carries the actor's refilled rack as
|
||||||
|
// wire alphabet indices and BagLen the bag size after the draw, so the mover renders the
|
||||||
|
// next state from the response without a follow-up state fetch.
|
||||||
type moveResultDTO struct {
|
type moveResultDTO struct {
|
||||||
Move moveRecordDTO `json:"move"`
|
Move moveRecordDTO `json:"move"`
|
||||||
Game gameDTO `json:"game"`
|
Game gameDTO `json:"game"`
|
||||||
|
Rack []int `json:"rack"`
|
||||||
|
BagLen int `json:"bag_len"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// alphabetEntryDTO is one letter of a variant's alphabet (its index, concrete letter and
|
// alphabetEntryDTO is one letter of a variant's alphabet (its index, concrete letter and
|
||||||
// tile value), embedded in the state view for display only when the client requests it
|
// tile value), embedded in the state view for display only when the client requests it.
|
||||||
// (Stage 13).
|
|
||||||
type alphabetEntryDTO struct {
|
type alphabetEntryDTO struct {
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
Letter string `json:"letter"`
|
Letter string `json:"letter"`
|
||||||
Value int `json:"value"`
|
Value int `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// stateDTO is a player's view of a game. Rack carries wire alphabet indices (Stage 13; a
|
// stateDTO is a player's view of a game. Rack carries wire alphabet indices (a
|
||||||
// blank is engine.BlankIndex). Alphabet is present only when the request asked for it.
|
// blank is engine.BlankIndex). Alphabet is present only when the request asked for it.
|
||||||
type stateDTO struct {
|
type stateDTO struct {
|
||||||
Game gameDTO `json:"game"`
|
Game gameDTO `json:"game"`
|
||||||
@@ -231,13 +234,23 @@ func moveRecordDTOFrom(m engine.MoveRecord) moveRecordDTO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// moveResultDTOFrom projects a committed move result into its DTO.
|
// moveResultDTOFrom projects a committed move result into its DTO, encoding the actor's rack as
|
||||||
func moveResultDTOFrom(r game.MoveResult) moveResultDTO {
|
// wire alphabet indices.
|
||||||
return moveResultDTO{Move: moveRecordDTOFrom(r.Move), Game: gameDTOFromGame(r.Game)}
|
func moveResultDTOFrom(r game.MoveResult) (moveResultDTO, error) {
|
||||||
|
rack, err := engine.EncodeRack(r.Game.Variant, r.Rack)
|
||||||
|
if err != nil {
|
||||||
|
return moveResultDTO{}, err
|
||||||
|
}
|
||||||
|
return moveResultDTO{
|
||||||
|
Move: moveRecordDTOFrom(r.Move),
|
||||||
|
Game: gameDTOFromGame(r.Game),
|
||||||
|
Rack: rack,
|
||||||
|
BagLen: r.BagLen,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// stateDTOFrom projects a player's state view into its DTO, encoding the rack as wire
|
// stateDTOFrom projects a player's state view into its DTO, encoding the rack as wire
|
||||||
// alphabet indices (Stage 13). When includeAlphabet is set it also embeds the variant's
|
// alphabet indices. When includeAlphabet is set it also embeds the variant's
|
||||||
// display table, which the client caches per variant and renders the rack with.
|
// display table, which the client caches per variant and renders the rack with.
|
||||||
func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
|
func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
|
||||||
rack, err := engine.EncodeRack(v.Game.Variant, v.Rack)
|
rack, err := engine.EncodeRack(v.Game.Variant, v.Rack)
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ func TestGameDTOFromGame(t *testing.T) {
|
|||||||
Seats: []game.Seat{{Seat: 0, AccountID: aid, Score: 12}},
|
Seats: []game.Seat{{Seat: 0, AccountID: aid, Score: 12}},
|
||||||
}
|
}
|
||||||
dto := gameDTOFromGame(g)
|
dto := gameDTOFromGame(g)
|
||||||
if dto.ID != gid.String() || dto.Variant != "english" || dto.ToMove != 1 || dto.TurnTimeoutSecs != 86400 {
|
if dto.ID != gid.String() || dto.Variant != "scrabble_en" || dto.ToMove != 1 || dto.TurnTimeoutSecs != 86400 {
|
||||||
t.Fatalf("game dto mismatch: %+v", dto)
|
t.Fatalf("game dto mismatch: %+v", dto)
|
||||||
}
|
}
|
||||||
if len(dto.Seats) != 1 || dto.Seats[0].AccountID != aid.String() || dto.Seats[0].Score != 12 {
|
if len(dto.Seats) != 1 || dto.Seats[0].AccountID != aid.String() || dto.Seats[0].Score != 12 {
|
||||||
|
|||||||
@@ -18,11 +18,10 @@ import (
|
|||||||
"scrabble/backend/internal/social"
|
"scrabble/backend/internal/social"
|
||||||
)
|
)
|
||||||
|
|
||||||
// registerRoutes wires the Stage 6 REST handlers onto the /api/v1 groups. The
|
// registerRoutes wires the REST handlers onto the /api/v1 groups. The
|
||||||
// internal group is gateway-only (the gateway authenticates and forwards); the
|
// internal group is gateway-only (the gateway authenticates and forwards); the
|
||||||
// user group requires X-User-ID; the admin group is reached through the gateway's
|
// user group requires X-User-ID; the admin group is reached through the gateway's
|
||||||
// Basic-Auth proxy. This is the representative vertical slice — further domain
|
// Basic-Auth proxy.
|
||||||
// operations follow the same pattern (PLAN.md Stage 6).
|
|
||||||
func (s *Server) registerRoutes() {
|
func (s *Server) registerRoutes() {
|
||||||
if s.sessions != nil && s.accounts != nil {
|
if s.sessions != nil && s.accounts != nil {
|
||||||
in := s.internal
|
in := s.internal
|
||||||
@@ -32,11 +31,16 @@ func (s *Server) registerRoutes() {
|
|||||||
in.POST("/sessions/email/login", s.handleEmailLogin)
|
in.POST("/sessions/email/login", s.handleEmailLogin)
|
||||||
in.POST("/sessions/resolve", s.handleResolveSession)
|
in.POST("/sessions/resolve", s.handleResolveSession)
|
||||||
in.POST("/sessions/revoke", s.handleRevokeSession)
|
in.POST("/sessions/revoke", s.handleRevokeSession)
|
||||||
// Out-of-app push routing for the platform side-service (Stage 9): the
|
// Out-of-app push routing for the platform side-service: the
|
||||||
// gateway resolves a recipient's Telegram chat + language + in-app-only flag
|
// gateway resolves a recipient's Telegram chat + language + in-app-only flag
|
||||||
// before delivering an out-of-app notification.
|
// before delivering an out-of-app notification.
|
||||||
in.POST("/push-target", s.handlePushTarget)
|
in.POST("/push-target", s.handlePushTarget)
|
||||||
}
|
}
|
||||||
|
if s.ratewatch != nil {
|
||||||
|
// The gateway's periodic rate-limiter rejection summary: feeds the
|
||||||
|
// admin console's throttled view and the high-rate auto-flag.
|
||||||
|
s.internal.POST("/ratelimit/report", s.handleRateLimitReport)
|
||||||
|
}
|
||||||
u := s.user
|
u := s.user
|
||||||
if s.accounts != nil {
|
if s.accounts != nil {
|
||||||
u.GET("/profile", s.handleProfile)
|
u.GET("/profile", s.handleProfile)
|
||||||
@@ -44,7 +48,7 @@ func (s *Server) registerRoutes() {
|
|||||||
u.GET("/stats", s.handleStats)
|
u.GET("/stats", s.handleStats)
|
||||||
}
|
}
|
||||||
if s.links != nil {
|
if s.links != nil {
|
||||||
// Account linking & merge (Stage 11). The request step always mails a code;
|
// Account linking & merge. The request step always mails a code;
|
||||||
// a required merge is revealed only after the code is verified, and the
|
// a required merge is revealed only after the code is verified, and the
|
||||||
// irreversible merge is an explicit second step.
|
// irreversible merge is an explicit second step.
|
||||||
u.POST("/link/email/request", s.handleLinkEmailRequest)
|
u.POST("/link/email/request", s.handleLinkEmailRequest)
|
||||||
@@ -68,6 +72,7 @@ func (s *Server) registerRoutes() {
|
|||||||
u.GET("/games/:id/gcg", s.handleExportGCG)
|
u.GET("/games/:id/gcg", s.handleExportGCG)
|
||||||
u.GET("/games/:id/draft", s.handleGetDraft)
|
u.GET("/games/:id/draft", s.handleGetDraft)
|
||||||
u.PUT("/games/:id/draft", s.handleSaveDraft)
|
u.PUT("/games/:id/draft", s.handleSaveDraft)
|
||||||
|
u.POST("/games/:id/hide", s.handleHideGame)
|
||||||
}
|
}
|
||||||
if s.matchmaker != nil {
|
if s.matchmaker != nil {
|
||||||
u.POST("/lobby/enqueue", s.handleEnqueue)
|
u.POST("/lobby/enqueue", s.handleEnqueue)
|
||||||
@@ -119,10 +124,8 @@ func gameIDParam(c *gin.Context) (uuid.UUID, bool) {
|
|||||||
// X-Forwarded-For (the first hop), falling back to the direct peer.
|
// X-Forwarded-For (the first hop), falling back to the direct peer.
|
||||||
func clientIP(c *gin.Context) string {
|
func clientIP(c *gin.Context) string {
|
||||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||||
if i := strings.IndexByte(xff, ','); i >= 0 {
|
first, _, _ := strings.Cut(xff, ",")
|
||||||
return strings.TrimSpace(xff[:i])
|
return strings.TrimSpace(first)
|
||||||
}
|
|
||||||
return strings.TrimSpace(xff)
|
|
||||||
}
|
}
|
||||||
return c.ClientIP()
|
return c.ClientIP()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// The /api/v1/user account handlers wire profile editing, email binding and the
|
// The /api/v1/user account handlers wire profile editing, email binding and the
|
||||||
// statistics read (Stage 8). They follow handlers_user.go: X-User-ID identity, a
|
// statistics read. They follow handlers_user.go: X-User-ID identity, a
|
||||||
// domain call, a JSON DTO. Profile editing overwrites the full editable set, so the
|
// domain call, a JSON DTO. Profile editing overwrites the full editable set, so the
|
||||||
// client sends the complete desired profile.
|
// client sends the complete desired profile.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
"scrabble/backend/internal/adminconsole"
|
"scrabble/backend/internal/adminconsole"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/ratewatch"
|
||||||
"scrabble/backend/internal/robot"
|
"scrabble/backend/internal/robot"
|
||||||
"scrabble/backend/internal/social"
|
"scrabble/backend/internal/social"
|
||||||
)
|
)
|
||||||
@@ -47,12 +49,15 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.GET("/users", s.consoleUsers)
|
gm.GET("/users", s.consoleUsers)
|
||||||
gm.GET("/users/:id", s.consoleUserDetail)
|
gm.GET("/users/:id", s.consoleUserDetail)
|
||||||
gm.POST("/users/:id/message", s.consoleUserMessage)
|
gm.POST("/users/:id/message", s.consoleUserMessage)
|
||||||
|
gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag)
|
||||||
|
gm.GET("/throttled", s.consoleThrottled)
|
||||||
gm.GET("/games", s.consoleGames)
|
gm.GET("/games", s.consoleGames)
|
||||||
gm.GET("/games/:id", s.consoleGameDetail)
|
gm.GET("/games/:id", s.consoleGameDetail)
|
||||||
gm.GET("/complaints", s.consoleComplaints)
|
gm.GET("/complaints", s.consoleComplaints)
|
||||||
gm.GET("/complaints/:id", s.consoleComplaintDetail)
|
gm.GET("/complaints/:id", s.consoleComplaintDetail)
|
||||||
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
|
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
|
||||||
gm.GET("/messages", s.consoleMessages)
|
gm.GET("/messages", s.consoleMessages)
|
||||||
|
gm.GET("/messages.csv", s.consoleMessagesCSV)
|
||||||
gm.GET("/dictionary", s.consoleDictionary)
|
gm.GET("/dictionary", s.consoleDictionary)
|
||||||
gm.POST("/dictionary/reload", s.consoleReloadDictionary)
|
gm.POST("/dictionary/reload", s.consoleReloadDictionary)
|
||||||
gm.POST("/dictionary/changes/apply", s.consoleApplyChanges)
|
gm.POST("/dictionary/changes/apply", s.consoleApplyChanges)
|
||||||
@@ -115,7 +120,8 @@ func (s *Server) consoleUsers(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
view.Items = append(view.Items, adminconsole.UserRow{
|
view.Items = append(view.Items, adminconsole.UserRow{
|
||||||
ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind,
|
ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind,
|
||||||
Language: it.PreferredLanguage, Guest: it.IsGuest, CreatedAt: fmtTime(it.CreatedAt),
|
Language: it.PreferredLanguage, Guest: it.IsGuest,
|
||||||
|
FlaggedHighRate: !it.FlaggedHighRateAt.IsZero(), CreatedAt: fmtTime(it.CreatedAt),
|
||||||
})
|
})
|
||||||
ids = append(ids, it.ID)
|
ids = append(ids, it.ID)
|
||||||
}
|
}
|
||||||
@@ -186,6 +192,54 @@ func (s *Server) consoleMessages(c *gin.Context) {
|
|||||||
s.renderConsole(c, "messages", "messages", "Messages", view)
|
s.renderConsole(c, "messages", "messages", "Messages", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adminMessagesExportCap bounds the CSV export row count (the moderated chat volume is small).
|
||||||
|
const adminMessagesExportCap = 100000
|
||||||
|
|
||||||
|
// consoleMessagesCSV exports the whole filtered chat-message list (ignoring pagination) as a
|
||||||
|
// CSV download, for offline moderation review.
|
||||||
|
func (s *Server) consoleMessagesCSV(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
|
||||||
|
userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
|
||||||
|
filter := social.AdminMessageFilter{
|
||||||
|
GameID: gameID,
|
||||||
|
SenderID: userID,
|
||||||
|
NameMask: c.Query("name"),
|
||||||
|
ExtMask: c.Query("ext"),
|
||||||
|
}
|
||||||
|
items, err := s.social.AdminListMessages(ctx, filter, adminMessagesExportCap, 0)
|
||||||
|
if err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
c.Header("Content-Disposition", `attachment; filename="messages.csv"`)
|
||||||
|
w := csv.NewWriter(c.Writer)
|
||||||
|
_ = w.Write([]string{"time", "source", "sender_id", "sender", "ip", "message", "game_id"})
|
||||||
|
for _, m := range items {
|
||||||
|
// The sender name and message body are user-controlled; defuse spreadsheet formula
|
||||||
|
// injection so a moderator opening the export can't trigger a formula.
|
||||||
|
_ = w.Write([]string{
|
||||||
|
fmtTime(m.CreatedAt), m.Source, m.SenderID.String(), csvSafe(m.SenderName), csvSafe(m.SenderIP), csvSafe(m.Body), m.GameID.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// csvSafe defuses CSV/spreadsheet formula injection: a value a spreadsheet would treat as a
|
||||||
|
// formula (a leading =, +, -, @, tab or CR) is prefixed with a single quote so it renders as
|
||||||
|
// plain text on open.
|
||||||
|
func csvSafe(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
switch s[0] {
|
||||||
|
case '=', '+', '-', '@', '\t', '\r':
|
||||||
|
return "'" + s
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// consoleUserDetail renders one account with its stats, identities and games.
|
// consoleUserDetail renders one account with its stats, identities and games.
|
||||||
func (s *Server) consoleUserDetail(c *gin.Context) {
|
func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
@@ -207,6 +261,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
|||||||
if acc.MergedInto != uuid.Nil {
|
if acc.MergedInto != uuid.Nil {
|
||||||
view.MergedInto = acc.MergedInto.String()
|
view.MergedInto = acc.MergedInto.String()
|
||||||
}
|
}
|
||||||
|
if !acc.FlaggedHighRateAt.IsZero() {
|
||||||
|
view.FlaggedHighRateAt = fmtTime(acc.FlaggedHighRateAt)
|
||||||
|
}
|
||||||
if view.HasStats {
|
if view.HasStats {
|
||||||
if st, err := s.accounts.GetStats(ctx, id); err == nil {
|
if st, err := s.accounts.GetStats(ctx, id); err == nil {
|
||||||
view.Stats = adminconsole.StatsRow{Wins: st.Wins, Losses: st.Losses, Draws: st.Draws, MaxGamePoints: st.MaxGamePoints, MaxWordPoints: st.MaxWordPoints}
|
view.Stats = adminconsole.StatsRow{Wins: st.Wins, Losses: st.Losses, Draws: st.Draws, MaxGamePoints: st.MaxGamePoints, MaxWordPoints: st.MaxWordPoints}
|
||||||
@@ -501,6 +558,56 @@ func (s *Server) consolePostBroadcast(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// consoleThrottled renders the rate-limit observability page: the recent
|
||||||
|
// gateway-reported throttle episodes (in-memory, reset on a backend restart)
|
||||||
|
// and the accounts currently carrying the soft high-rate flag.
|
||||||
|
func (s *Server) consoleThrottled(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
var view adminconsole.ThrottledView
|
||||||
|
if s.ratewatch != nil {
|
||||||
|
cfg := s.ratewatch.Config()
|
||||||
|
view.FlagThreshold = cfg.FlagThreshold
|
||||||
|
view.FlagWindow = cfg.FlagWindow.String()
|
||||||
|
for _, ep := range s.ratewatch.Recent() {
|
||||||
|
row := adminconsole.ThrottleEpisodeRow{
|
||||||
|
Class: ep.Class, Key: ep.Key, Rejected: ep.Rejected,
|
||||||
|
FirstSeen: fmtTime(ep.FirstSeen), LastSeen: fmtTime(ep.LastSeen),
|
||||||
|
}
|
||||||
|
if ep.Class == ratewatch.ClassUser {
|
||||||
|
if id, err := uuid.Parse(ep.Key); err == nil {
|
||||||
|
row.UserID = id.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.Episodes = append(view.Episodes, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flagged, err := s.accounts.ListFlaggedHighRate(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, fa := range flagged {
|
||||||
|
view.Flagged = append(view.Flagged, adminconsole.FlaggedAccountRow{
|
||||||
|
ID: fa.ID.String(), DisplayName: fa.DisplayName, FlaggedAt: fmtTime(fa.FlaggedHighRateAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
s.renderConsole(c, "throttled", "throttled", "Throttled", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleClearHighRateFlag clears the soft high-rate marker — the operator's
|
||||||
|
// reversible review action.
|
||||||
|
func (s *Server) consoleClearHighRateFlag(c *gin.Context) {
|
||||||
|
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.accounts.ClearHighRateFlag(c.Request.Context(), id); err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String())
|
||||||
|
}
|
||||||
|
|
||||||
// variantVersions builds the per-variant resident-version summary from the registry.
|
// variantVersions builds the per-variant resident-version summary from the registry.
|
||||||
func (s *Server) variantVersions() []adminconsole.VariantVersions {
|
func (s *Server) variantVersions() []adminconsole.VariantVersions {
|
||||||
out := make([]adminconsole.VariantVersions, 0, len(engine.Variants()))
|
out := make([]adminconsole.VariantVersions, 0, len(engine.Variants()))
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The /api/v1/user/blocks/* handlers wire the per-user block list (Stage 8). A block
|
// The /api/v1/user/blocks/* handlers wire the per-user block list. A block
|
||||||
// is mutual in effect (the social checks apply it both ways) and severs any
|
// is mutual in effect (the social checks apply it both ways) and severs any
|
||||||
// friendship between the pair. They reuse the friend handlers' targetIDRequest and
|
// friendship between the pair. They reuse the friend handlers' targetIDRequest and
|
||||||
// account-ref resolution.
|
// account-ref resolution.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user