117 Commits

Author SHA1 Message Date
developer f8b6b7f2e3 Merge pull request 'R7: final stress run + tuning' (#38) from feature/r7-final-stress-tuning into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 37s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 59s
2026-06-11 09:35:15 +00:00
Ilia Denisov 225188e4b5 R7: add a VPS/VDS sizing table (min/avg/max) to the trip report
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 36s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
A practical single-host ordering guide — CPU cores, RAM, disk at three tiers —
grounded in the R7 profile (~5.5 cores / ~2.5 GiB peak at 500 players) and the
measured on-disk footprint (images ~2.4 GB; Tempo 3.1 GB at 72 h; the game DB
23 MiB and growing). Notes which knobs move disk (Tempo/Prometheus retention,
Postgres growth) and that the gateway scales horizontally past one host.
2026-06-11 11:32:09 +02:00
Ilia Denisov 2a48df9b83 R7: trip report + docs/tracker bake-back; mark R7 done
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
- loadtest/REPORT-R7.md: the final stress-run report — method, the 500-player resource
  profile, the agreed tuning, the validation (transport_error 2.49% -> 0.72% at 3 gateway
  cores; the burst run showing connection-bound behavior), and the prod-sizing
  recommendation for Stage 18.
- loadtest/README.md: per-player transports, --cpus capping, docker_stats (was cAdvisor),
  the absolute BACKEND_DICT_DIR for ./loadtest/... , and report links.
- docs/TESTING.md + docs/ARCHITECTURE.md: observability now uses the otelcol docker_stats
  receiver (cAdvisor removed); links to both trip reports.
- CLAUDE.md: repo-layout line reflects docker_stats + per-service limits.
- PRERELEASE.md: R7 marked done in the tracker + heading; a Refinements entry recording
  the decisions, findings, applied tuning and validation.

This is the final pre-release hardening phase; Stage 18 (prod cutover) is next.
2026-06-11 11:18:57 +02:00
Ilia Denisov f23da88028 R7: apply the agreed tuning from the final stress run
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 36s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m23s
Round-2 tuning, decided from the 500-player resource profile:
- gateway: 2 -> 3 cores + GOMAXPROCS=3. It holds one h2c connection per player, so
  at 500 players it burst into the 2-core cap (~2.49% transport_error on game.state);
  3 cores absorbs the bursts. The per-connection cost is the realistic prod load.
- tempo: memory 1G -> 2G. It reached the 1 GiB cap during the run (OOM risk).
- backend Postgres pool: MAX_OPEN_CONNS 25 -> 40. The pool sat at its 25-conn cap
  (28 backends) at peak; headroom trims the p99 tail. Postgres (2c/512M) handles it.
- docker log volume: a json-file rotation default (10m x 3 = 30 MiB/container) applied
  contour-wide via a YAML anchor; the backend logs ~14 MiB / 30 min at info under load
  and was previously unbounded. Log level stays info.

backend/postgres stay at 2 cores / 512 MiB (peak ~0.85 / ~1.4 cores — headroom is cheap
on the shared host). A validation re-run confirms the gateway fix before merge.
2026-06-11 10:33:58 +02:00
Ilia Denisov 8eee018728 R7: pin docker_stats api_version to 1.44
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 36s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
The receiver defaults to Docker API 1.25, but the contour daemon's minimum is
1.40 (it speaks up to 1.54), so otelcol crash-looped on start with "client
version 1.25 is too old". Pinning api_version to 1.44 (accepted by both the
receiver's bundled client and the daemon) starts the receiver cleanly —
verified by running the image against the host socket ("Everything is ready",
no start error).
2026-06-10 18:58:55 +02:00
Ilia Denisov c16f27475f R7: contour docker_stats observability + container limits/GOMAXPROCS
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
Observability: replace cAdvisor (which resolves only the root cgroup on the
contour host — separate-XFS /var/lib/docker) with the otelcol docker_stats
receiver, which reads per-container CPU/memory/network straight from the Docker
API and works the same in prod. The collector joins the host docker group
(DOCKER_GID, default 989) and mounts the socket read-only; its metrics flow out
through the existing prometheus exporter, so the cAdvisor scrape job and the
privileged cAdvisor service are removed. The Resources dashboard panels are
retargeted to the docker_stats metric names (container_name label;
container.cpu.utilization/100 == cores).

Container limits: apply deploy.resources.limits (honoured by Compose v2) across
the contour and pin GOMAXPROCS to the CPU limit on the Go services so the runtime
matches the cgroup quota. Starting values are generous over the R2 peak (~1 core /
<=100 MiB per app service) to avoid skewing or OOM-killing the measurement run;
they are tightened to the agreed prod sizing after the final stress run (R7
Round 2). The privileged VPN sidecar is left unconstrained.
2026-06-10 18:53:19 +02:00
Ilia Denisov 04263a17ca R7: per-player transports + drop finished games in the load harness
Each virtual player now builds its own edge.Client (its own h2c connection
carrying both the Subscribe stream and the Execute calls), instead of every
player multiplexing over a single shared http2.Transport. The R2 trip report
traced the ~14% transport_error on game.state at 500 players to that single
shared transport; per-player connections mirror real clients and isolate the
artifact. The assembly burst and the gateway-hammer each get their own client.

playTurn now reports when a game has finished so playerLoop drops it from the
rotation (slices.DeleteFunc); once no active game remains the player idles while
still holding its stream. This stops secondary ops from hammering game_finished
on already-ended games (the other R2 harness finding).
2026-06-10 18:53:07 +02:00
developer 7210bed560 Merge pull request 'R6: refactor + docs reconciliation + de-staging' (#37) from feature/r6-refactor-destage into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 12s
CI / ui (push) Successful in 36s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m1s
2026-06-10 16:03:18 +00:00
Ilia Denisov 40ccfb9514 R6: mark phase done in PRERELEASE.md + log refinements
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
2026-06-10 17:32:30 +02:00
Ilia Denisov c6e0dac940 R6(c): centralize shared integration-test fixtures in helpers.go
Move the cross-file integration fixtures — the service constructors
(newGameService/newSocialService/newRobotService/newMatchmaker), the game-assembly
helpers (newMirror/newGameWithSeats/newDraftGame), account provisioning
(provisionAccount/provisionGuest) and the stats reader — out of the domain test
files (newGameService alone was used by 10 files) into a single
backend/internal/inttest/helpers.go. Helpers used by a single file stay local.

Pure relocation: the helper bodies are unchanged, no test logic changes; the
imports the moves left unused are pruned. go vet -tags=integration is clean.
2026-06-10 17:30:53 +02:00
Ilia Denisov b47c47e969 R6(c): share the nested FB builders between notify and gateway transcode
Extract the FlatBuffers builders for the wire tables shared by the backend push
encoder and the gateway edge transcoder — GameView, MoveRecord, StateView,
AccountRef, Invitation and their nested rows — into a new scrabble/pkg/wire
package. Both callers keep their local builder signatures (no call sites move)
but now map their own source types (the backend's notify.* payloads and the
decoded engine.MoveRecord; the gateway's backendclient.* REST DTOs) to neutral
wire.* structs and delegate the construction to package wire, the single
definition of the nested-table layout.

Behaviour-preserving: the verified-identical field sets mean the wire bytes
decode the same, and the notify + transcode round-trip tests pass unchanged. The
fiddly Start/Add/End + reverse-prepend vector boilerplate now lives once; the two
encode files shrink while pkg/wire carries the shared logic.
2026-06-10 17:21:18 +02:00
Ilia Denisov 1079878654 R6(c): drop dead opponent_moved scalars (seat/action/score/total)
These pre-R4 summary scalars on OpponentMovedEvent were redundant with the
move/game delta and read by nobody — the UI codec and mock take only
move/game/bag_len, and the gateway forwards the push payload verbatim. Removed
from scrabble.fbs, the notify emit (notify/events.go) and the round-trip test;
regenerated the FB Go + TS bindings. No prod data, so the wire-slot renumber is
free and there is no DB change.
2026-06-10 17:06:25 +02:00
Ilia Denisov c31ac7088c R6(b): reconcile docs with code — restore guest-reaper mention
Pass (a) removed a stale "(reaping abandoned guest rows is deferred — TODO-3)"
note from ARCHITECTURE §3, but guest reaping is implemented (the background
reaper, BACKEND_GUEST_REAP_INTERVAL / BACKEND_GUEST_RETENTION, covered by
inttest). State the current behaviour instead.

A full section-by-section review of ARCHITECTURE / FUNCTIONAL (+_ru) / TESTING /
UI_DESIGN against the code found no other drift — each R-phase baked its own docs,
and FUNCTIONAL/TESTING already describe the reaper correctly.
2026-06-10 17:02:48 +02:00
Ilia Denisov 8881214213 R6(a): de-stage code, docs, READMEs; split stage6_test
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN)
references from comments, doc-comments, service READMEs, the current-state docs
(ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the
.fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage
history.

- Rename the only stage-named identifiers: registerStage8 -> registerSocialOps,
  registerStage11 -> registerLinkOps (gateway transcode).
- Split stage6_test.go: TestEmailLoginFlow -> email_test.go,
  TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go.
- Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged
  .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments).

go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
2026-06-10 16:56:03 +02:00
developer a372343797 Merge pull request 'R5: bundle slimming — retarget the budget to the app, no code slimming' (#36) from feature/r5-bundle-budget into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 36s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 57s
2026-06-10 13:18:27 +00:00
Ilia Denisov d4ef951db9 R5: bundle slimming — retarget the budget to the app, no code slimming
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
Analysed the real dist (gzip + sourcemap attribution): the bundle is already minified + tree-shaken and dominated by the Connect/FlatBuffers transport runtime + generated bindings + the Svelte runtime (~2/3 of main), so no in-scope code slimming is warranted. Lazy-loading was rejected (bundle-size.mjs sums every chunk -> zero total-size win, plus +N gateway fetches of latency); i18n lazy-load and chunk-collapsing likewise (caching/HTTP2).

Instead bundle-size.mjs now measures per HTML entry with three independent gates (app entry <=100 KB, Svelte+i18n shared <=30 KB, landing-own <=5 KB): the app's real payload is its entry chunk + the shared chunk (~97 KB), never landing.js. Same CLI + exit-code contract, CI step unchanged. Fixed the stale ~82 KB figure in the script and ui/README.md. No app code change.
2026-06-10 15:11:45 +02:00
developer 7ec17cdd53 Merge pull request 'R4: push enrichment — events carry a state delta, kill the last poll' (#35) from feature/r4-push-enrichment into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 37s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 59s
2026-06-10 10:30:49 +00:00
Ilia Denisov 41a642ef97 R4: push enrichment — events carry a state delta, kill the last poll
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
Enrich the in-app live stream into a delta channel so the UI renders a move from the event without a follow-up game.state, and make the matchmaking poll a stream-down fallback.

- pkg/fbs: trailing fields on opponent_moved (move+game+bag_len), your_turn (move_count), match_found (state), game_over (game), notify (account/invitation/state), MoveResult (rack+bag_len); regenerate Go + TS.
- backend: notify owns the FB encoding (encode.go + payload.go input structs); game/lobby/social map their domain types in. emitMove builds the move delta; game.Service.InitialState feeds match_found/game_started the recipient's initial StateView; friends/invitations notify carry their account/invitation. The move-commit response (submit_play/pass/exchange/resign) returns the actor's refilled rack + bag size.
- gateway: MoveResult transcode carries rack+bag_len.
- ui: pure lib/gamedelta.ts reducer advances the per-game cache keyed on move_count (idempotent + gap-safe); app.svelte seeds the cache on match_found/game_started; Game.svelte applies the delta (commit/pass/exchange/resign drop their load()); NewGame polls only while app.streamAlive is false.
- docs: ARCHITECTURE §10, FUNCTIONAL(+ru), backend/gateway/ui READMEs; PRERELEASE R4 marked done + Refinements.
2026-06-10 08:01:50 +02:00
developer e3b08461f0 Merge pull request 'R3: edge hardening — body cap, rate-limit observability, auto-flag, landing split' (#34) from feature/r3-edge-hardening into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 36s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 57s
2026-06-10 03:38:51 +00:00
Ilia Denisov 7e75c32d07 R3: dashboards, docs and tracker bake-back
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 36s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
- Edge/UX dashboard: aggregate request-rate vs rejection-rate panel
  (gateway_rate_limited_total by class; no per-user labels).
- ARCHITECTURE §2/§11/§12/§13: body cap + explicit h2c sizing, the rate-limit
  observability pipeline and auto-flag policy, the admin-limiter note (and the
  caddy-path gap), the landing container topology; fixed the stale 120/min
  per-user figure.
- FUNCTIONAL (+_ru): the Throttled view and the reversible high-rate flag.
- gateway/backend/deploy READMEs, TESTING.md, root CLAUDE.md updated.
- PRERELEASE.md: R3 interview decisions + implementation refinements logged;
  tracker R3 -> done (this PR implements it; CI gates the merge).
2026-06-10 05:12:30 +02:00
Ilia Denisov f20a4b49ff R3: split the landing into its own static container
- gateway/Dockerfile gains a `landing` target: caddy:2-alpine + the shared
  Vite build (identical build args keep the ui stage a single cached build);
  the gateway target drops landing.html from the embed.
- The contour caddy routes /app/, /telegram/ and the Connect path to the
  gateway; the catch-all — the landing at / and any stray path — goes to the
  new landing service, so junk traffic is absorbed by static file serving.
- deploy/landing/Caddyfile mirrors the webui caching (immutable assets,
  no-cache shells) and falls back unknown paths to the landing shell.
- The gateway's / now 308-redirects to /app/ (keeps a local no-caddy run
  usable); webui placeholder landing.html removed.
- CI deploy probe checks both / (landing) and /app/ (gateway).

Verified: both images build; the landing container serves landing.html at /
(no-cache) with junk-path fallback; the gateway image redirects / to /app/
and carries no landing content.
2026-06-10 02:20:10 +02:00
Ilia Denisov ab58062565 R3: backend rate-limit observability — ratewatch, auto-flag, admin throttled view
- accounts.flagged_high_rate_at baked into the R1 baseline (no prod data; the
  contour schema is wiped after merge); jet regenerated — the regen also picks
  up the previously missing game_drafts/game_hidden models.
- account.Store: FlagHighRate (set-once), ClearHighRateFlag, the flag in
  GetByID/ListUsers and a ListFlaggedHighRate review queue.
- New internal/ratewatch: ingests the gateway rejection reports, keeps a
  bounded in-memory episode window for the console and applies the
  conservative auto-flag (1000 rejected / 10 min, BACKEND_HIGHRATE_FLAG_*).
- POST /api/v1/internal/ratelimit/report (network-trusted, like
  sessions/resolve).
- Admin console: Throttled page (episodes + flagged accounts), a high-rate
  badge in the user list, the marker + operator clear action on the user card.
- Tests: ratewatch unit suite, report-route handler test, renderer cases,
  integration coverage for the store round-trip and the console flow.
2026-06-10 02:14:10 +02:00
Ilia Denisov 8878711cf3 R3: gateway edge hardening — body cap, h2c sizing, rate-limit observability
- GATEWAY_MAX_BODY_BYTES (1 MiB): connect WithReadMaxBytes + http.MaxBytesReader
  on the public mux; explicit http2.Server MaxConcurrentStreams/IdleTimeout and
  an http.Server ReadHeaderTimeout (R2 report follow-up).
- gateway_rate_limited_total{class} counter, Debug per rejection, a rejection
  tracker drained every 30 s into a Warn summary per key and a report POST to
  /api/v1/internal/ratelimit/report (feeds the admin view + auto-flag).
- The dead AdminPerMinute/AdminBurst policy now guards the /_gm mount (429),
  ahead of its Basic-Auth.
- resolve() logs the cause of infra session-resolve failures at Warn (the
  transient unauthenticated dips from the R2 run); unknown tokens stay silent.
2026-06-10 01:58:48 +02:00
developer c23ac94c4e Merge pull request 'R2: stress harness + contour resource observability + early run' (#33) from feature/r2-loadtest-observability into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 36s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 55s
2026-06-09 23:01:30 +00:00
Ilia Denisov a2265a122e R2: early-pass trip report + mark R2 done
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
Ran the moderate early pass (50/200/500, 10 min/step) against the contour: ramped
clean to 500 players, 1.2 M edge calls, 48 870 plays, 2 798 games finished, no
crash/deadlock; cleanup removed all 11 000 seeded accounts. The per-user limiter held
under the gateway-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), amplified by the harness's single shared
http2.Transport (the harness itself peaked at 86 % of a core on the same host).
Observability finding: cAdvisor yields only the root cgroup on the contour host
(separate XFS /var/lib/docker); per-container metrics captured via docker stats; R7
should adopt the otelcol docker_stats receiver. Full report in loadtest/REPORT-R2.md;
PRERELEASE refinements logged; R2 marked done.
2026-06-10 00:47:16 +02:00
Ilia Denisov 422bd14b53 R2: harness payload fixes found by the smoke pass
- display-name marker: letters-only 'Zzloadtest' (the editable-name validator
  forbids digits/colons), so profile.update resends the seeded name successfully.
- draft.save: rack_order is a string in the backend draft DTO (was sent as []),
  fixing the bad_request.
Both confirmed ok against the contour. chat_not_your_turn / nudge_own_turn are
by-design turn gates (backend/internal/social/chat.go), correctly exercised.
2026-06-10 00:11:33 +02:00
Ilia Denisov 0c55574ddd R2: drop ./loadtest from the backend/gateway/telegram image builds
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 36s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
Adding the loadtest module to go.work (use ./loadtest + the scrabble/gateway
replace it needs) broke the other services' Docker builds: their reduced
workspace still referenced ./loadtest (not in their build context), failing with
'cannot load module loadtest: open loadtest/go.mod: no such file or directory'.
Each service Dockerfile now also -dropuse=./loadtest; backend and telegram (which
do not COPY ./gateway) additionally -dropreplace the loadtest-only scrabble/gateway
replace. Verified by building all three images plus loadtest locally.
2026-06-09 23:57:26 +02:00
Ilia Denisov aa137e3558 R2: load-test harness + contour resource observability
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 38s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 3s
New scrabble/loadtest module (the pre-release stress harness): seeds 1000 guest +
10000 durable accounts with pre-created sessions directly in Postgres (token hash
matches backend/internal/session), drives virtual players through the edge protocol
(real 2-4p games assembled via invitations, mid-ranked legal moves generated locally
by the embedded scrabble-solver — the edge carries no board, so the client replays
history), plus nudge/chat/check-word/draft/profile/stats and a gateway-hammer that
verifies the rate limiter. Prints a trip-report summary (per-op latency percentiles,
result codes, live-event tally). Go unit tests cover the pure pieces; the DAWG-backed
move test runs under BACKEND_DICT_DIR.

Contour: add cAdvisor + postgres_exporter + a 'Scrabble - Resources' Grafana
dashboard and the two Prometheus scrape jobs, for the R2/R7 stress-run resource
baseline.

CI: gate ./loadtest/... (path filter + vet/build/test). Docs: TESTING, ARCHITECTURE,
project CLAUDE repo layout.
2026-06-09 23:45:24 +02:00
developer bf3ee62711 Merge pull request 'R1: mark done in PRERELEASE.md (post-merge close-out)' (#32) from feature/r1-close into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m1s
2026-06-09 20:38:47 +00:00
Ilia Denisov 8bfc44aad0 R1: mark done in PRERELEASE.md (post-merge close-out)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
scrabble-game #31 + scrabble-dictionary #2 merged, v1.0.1 cut, contour DB wiped
and re-migrated to the baseline (verified).
2026-06-09 16:11:03 +02:00
developer bf07f77078 Merge pull request 'R1: schema & naming reset — squash migrations, rename variants' (#31) from feature/r1-schema-naming-reset into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 12s
CI / ui (push) Successful in 36s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 56s
2026-06-09 14:09:31 +00:00
Ilia Denisov 26aa154547 R1: schema & naming reset — squash migrations, rename variants
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
Squash the 12 goose migrations into one 00001_baseline.sql (there is no prod
data; verified schema-identical to the chain via a pg_dump diff + the green
integration suite) and rename the game-variant labels
english/russian_scrabble/erudit -> scrabble_en/scrabble_ru/erudit_ru across the
backend, the FlatBuffers wire values and the UI.

dawg filenames and the Go enum identifiers are unchanged; the i18n display keys
are kept. Adds PRERELEASE.md (the R1-R7 pre-release tracker), linked from
CLAUDE.md. Contour DB wipe and the scrabble-dictionary tidy are follow-ups.
2026-06-09 12:09:50 +02:00
developer 70e3fab512 Merge pull request 'Stage 17: robot-nudge frequency + per-game push language' (#30) from feature/nudge-fix into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 11s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 54s
2026-06-09 06:12:10 +00:00
Ilia Denisov bf7dca0a09 Stage 17: fix the robot-nudge frequency + per-game push language
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
Two owner-reported defects from a live contour game.

A. Frequency: the robot's proactive nudge fired hourly for 12h+ (a 12h idle threshold
   then the 1h cooldown, uncapped). Replaced with a lengthening, randomized schedule
   (proactiveNudgeGap): the first nudge ~60-90 min into the human's turn, each later gap
   growing toward 1-6h (uniform sample in [60min, ceil], ceil ramping 90min->6h over 12h
   of idle, measured from the previous nudge), so a long wait gets a handful of
   increasingly-spaced reminders instead of a stream.

B. Language: out-of-app push routed by the recipient's GLOBAL service_language
   (last-login-wins), so after re-logging via 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 the game's bot is one the player has started, so delivery is
   never blocked.

Tests: proactiveNudgeGap unit + retimed TestRobotProactiveNudge; TestVariantLanguage;
emit your_turn/game_over language; TestNudgeRoutedByGameLanguage integration. Docs:
ARCHITECTURE (§7 nudge, §10/§13 routing), FUNCTIONAL (+ _ru), PLAN tracker.
2026-06-09 08:06:58 +02:00
developer 265e442252 Merge pull request 'Stage 17 #2: Connecting indicator + auto-retry (no more red toasts)' (#29) from feature/connecting-indicator into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 36s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 58s
2026-06-09 05:46:43 +00:00
Ilia Denisov d87c0fb10b Stage 17: cap display-name special characters at 5 (ui + backend)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 35s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
display_name validation gains a rule: at most 5 special characters — the '.' / '_'
punctuation (spaces, which separate words, don't count) — so a still-well-formed name
can't be mostly punctuation. Mirrored in the Go ValidateDisplayName and the UI
validDisplayName; both unit-tested (5 ok, 6 rejected, 'J. R. R. Tolkien' ok). Docs:
FUNCTIONAL (+ _ru).
2026-06-09 07:42:47 +02:00
Ilia Denisov 84ecc85f51 Stage 17 #2 fix: connection failures show only the spinner, never a toast
A dropped/reset/timed-out connection can surface as a Connect code other than
Unavailable (Canceled/DeadlineExceeded/Unknown/…) which fell through to the generic
'internal' -> a red 'something went wrong' toast appeared alongside the Connecting
spinner. Now toGatewayError (moved to the pure retry.ts, unit-tested) collapses every
transport-level code to 'unavailable' so it is retried + flips offline; and handleError
suppresses the toast for any connection code AND whenever the app is mid-reconnect
(!connection.online), covering the race where a unary error lands before the stream
reports the drop. Genuine server-internal / domain errors still toast while online.
2026-06-09 07:42:47 +02:00
Ilia Denisov efa1d0bd22 Stage 17 #2: extend the offline soft-disable to all server-action buttons
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 35s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
Following the in-game bar, the Connecting indicator now also visually disables the
other proactive (server-sending) controls while offline: chat send + nudge, profile
save / link email|telegram / merge-confirm, friends (redeem, get-code, accept/decline,
unfriend, block, unblock), New Game (auto-match variant + send-invitation) and the
lobby hide . Purely local controls (board/rack/reset, menus, navigation, settings,
copy-code) stay live. Each reads the global connection.online signal; full e2e + check
green.
2026-06-09 07:23:32 +02:00
Ilia Denisov ef61b778fc Stage 17 #2: Connecting indicator + auto-retry, instead of red toasts
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 36s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
Connectivity failures become state, not a toast on every attempt. 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).

- transport: exec auto-retries with capped exponential backoff — every op on a
  rate-limit (rejected before processing, safe), reads only on unavailable (a mutation
  is never blindly re-sent, to avoid double-applying one whose response was lost; its
  button is disabled while offline so the player re-issues on reconnect). A reachability
  watcher (profile.get probe) and any successful traffic clear the signal.
- the old red error.unavailable toast is gone (handleError suppresses connection codes;
  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 retry policy unit-tested (retry.ts); a mock-only window.__conn hook drives a
  Chromium+WebKit e2e (indicator shows offline, the action disables, both clear on
  reconnect). Full suite + build green.
- docs: ARCHITECTURE transport note, FUNCTIONAL (+ _ru), PLAN tracker (incl. #1 — the
  bot already drains all updates, no change).

Also records #1 as investigated/no-change in PLAN. Other server-action buttons (chat
send, profile save, …) still degrade to a safe no-op offline; visual disable is easy to
extend.
2026-06-09 01:48:20 +02:00
developer 844f26bbae Merge pull request 'Stage 17 #4: enrich the out-of-app your-turn push + add game-over' (#28) from feature/push-enrichment into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 35s
CI / gate (push) Successful in 1s
CI / deploy (push) Successful in 1m10s
2026-06-08 23:29:16 +00:00
Ilia Denisov f166ff30fe Stage 17 #4: enrich the out-of-app your-turn push + add game-over
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 34s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
The Telegram 'your turn' 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; a short 'swapped / passed, your turn' otherwise), and a new game-over
notification reports the result + final score when a game ends by any path (closing
play, all-pass, resign, timeout). Scores are recipient-first (the reader's score
leads), 2-4 players (120:95:80).

- schema: YourTurnEvent gains opponent_name/last_action/last_word/score_line
  (appended, backward-compatible); new GameOverEvent{result, score_line}. Go + UI
  bindings regenerated (flatc 23.5.26 + pnpm codegen).
- backend: notify.YourTurn enriched + notify.GameOver; emitMove resolves the mover's
  name and emits per-recipient (your_turn to the next mover, game_over to every seat),
  with recipient-first score lines built in one place.
- gateway: game_over joins the out-of-app whitelist (routing.go).
- connector: render builds the enriched your_turn + game_over text per language (en/ru).
- tests: notify round-trip (enriched + game_over), emit (enriched fields + game_over to
  all seats / per-seat result), connector render (en/ru), routing; integration replay
  (play → your_turn with real name; resign → game_over) green.
- docs: ARCHITECTURE push catalog + out-of-app set, FUNCTIONAL (+ _ru), PLAN tracker.
2026-06-09 01:15:18 +02:00
developer 6956dad354 Merge pull request 'Stage 17 #5: hide finished games from your own lobby list' (#27) from feature/hide-finished-games into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 35s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m5s
2026-06-08 22:45:07 +00:00
Ilia Denisov 13361c098c Stage 17 #5: make the active-row chevron open the game (not a no-op)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 36s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s
Owner review: the '>' on an active game row should be a real tap target that opens
the game, like the rest of the row — not inert. The chevron now navigates (kept out
of the tab order / a11y tree since the row's main button already does the same), and
active-row swipes no longer suppress the tap. Adds an e2e for the chevron navigation.
2026-06-09 00:39:54 +02:00
Ilia Denisov 4999478ded Stage 17 #5: hide finished games from your own lobby list
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 35s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m16s
A player can remove a finished game from their own 'my games' list. The action is
per-account, finished-only and irreversible (the game stays for the other players;
there is no un-hide).

- backend: migration 00012 game_hidden(account_id, game_id); store HideGame +
  hiddenGameIDs + ListGamesForAccount filtering; service HideGame (seat + finished
  checks, reusing ErrNotAPlayer / ErrGameActive); POST /api/v1/user/games/:id/hide.
- gateway: game.hide edge op (reuses GameActionRequest -> Ack) + backendclient.HideGame.
- ui: finished rows reveal a delete via swipe-left (touch) or a kebab tap (desktop),
  active rows get an inert chevron for icon alignment; optimistic removal + lobby-cache
  sync; mock + transport + client wiring; lobby.hideGame label (en/ru).
- tests: integration (active->ErrGameActive, outsider->ErrNotAPlayer, per-account,
  idempotent), gateway transcode round-trip, mock e2e (kebab -> delete); hardened a
  pre-existing chat-screen .back transition flake surfaced by the new test's timing.
- docs: ARCHITECTURE persistence list, FUNCTIONAL (+ _ru) lobby story, PLAN tracker.
2026-06-09 00:26:35 +02:00
developer a7c566d2d1 Merge pull request 'Round-6 follow-up: UX polish + client-IP fix' (#26) from feature/ux-polish-ipfix into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 34s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m6s
2026-06-08 21:40:13 +00:00
Ilia Denisov a84e9d8cb7 Fix screen-slide direction: by route depth, so back from chat/check slides back
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 35s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
The 'lobby is back' rule slid the chat/check back-to-the-game forward. Direction is now
computed from route depth (lobby < game < chat/check): shallower = back, deeper = forward.
2026-06-08 23:37:02 +02:00
Ilia Denisov 70110effd9 Chat + word-check as their own screens; in-game unread badge (review item 7)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 34s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
- Chat and word-check are now routed screens (/game/:id/chat, /game/:id/check) with a
  header back to the game and no tab-bar, replacing their modals. The soft keyboard just
  resizes the visible viewport (tracked into --vvh, which the Screen height uses since iOS
  does not shrink dvh for the keyboard) with the input pinned to the bottom: no modal
  relayout, no page jump. Supersedes the earlier bottom-sheet Modal attempt.
- A new chat message raises an unread badge on the in-game hamburger + the Chat menu row
  (per game, cleared on opening the chat), mirroring the lobby badge.
- TG native back + the header back chevron return chat/check to their game.
- Exposes --tg-safe-top (device notch) for the finalised TG-fullscreen header.

Tests: e2e for chat/check opening as their own screens + back. Docs: PLAN, FUNCTIONAL(+ru).
2026-06-08 23:23:05 +02:00
Ilia Denisov 295e45486d TG-fullscreen header: add height via padding (min-height wasn't binding), +12px breathing room
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 33s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
2026-06-08 22:22:43 +02:00
Ilia Denisov a132edd40a TG-fullscreen header: +6px band height so native controls aren't flush (owner tweak)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 33s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m14s
2026-06-08 22:10:44 +02:00
Ilia Denisov 461e330bfc Admin Messages CSV: defuse spreadsheet formula injection
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 33s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m15s
The sender name and message body are user-controlled; a leading =, +, -, @, tab or
CR in the CSV export would execute as a formula when a moderator opens it in a
spreadsheet. csvSafe() prefixes such values with a single quote. Unit-tested.
2026-06-08 22:09:26 +02:00
Ilia Denisov c96d714fec Admin Messages: CSV export of the whole filtered list
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 33s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m14s
A right-aligned 'Export CSV ↓' link in the filter row downloads /_gm/messages.csv
with the active filters (game / sender / name / ext masks), exporting every matching
message (capped at 100k) regardless of the page window — columns time, source,
sender_id, sender, ip, message, game_id.
2026-06-08 22:07:33 +02:00
Ilia Denisov 7e34897d6d Review fixes: swipe (capture-phase, enabled in TG), TG header aligns to the nav band, DnD zoom delay 1s->0.7s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 32s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
- Edge-swipe back now listens at the window in the CAPTURE phase (the board's
  pointer handlers can't swallow it) and is no longer skipped inside Telegram
  (where the owner tests it).
- TG-fullscreen header: expose the device safe-area top (--tg-safe-top) and
  centre the title + menu pair within Telegram's nav band ([safe-top,
  content-top]) below the notch, keeping the band's height — lining up with
  Telegram's own controls.
- DnD auto-zoom-on-hover delay reduced 1000ms -> 700ms.

(Client-IP: diagnosed as the owner's home-router SNAT — the host caddy already
receives 192.168.0.1 with no XFF, so the real IP is lost upstream of our stack;
correct in prod. No code change.)
2026-06-08 22:01:43 +02:00
Ilia Denisov 645df52c0b Round-6 follow-up: UX polish + client-IP fix
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 32s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
- Client IP: the compose caddy trusts X-Forwarded-For from private-range
  upstreams (trusted_proxies private_ranges), so the real client IP survives
  the host-caddy hop (it was logging the docker caddy hop 172.18.0.x for chat
  moderation and bucketing the gateway per-IP rate limiter on it). Correct and
  spoof-safe in both contours (prod has no host caddy); peerIP unit-tested.
- Ad banner gated off behind a compile-time SHOW_AD_BANNER=false (the if-branch,
  the AdBanner import and banner.ts are tree-shaken out of the prod bundle).
- Landing: the Telegram entry is just the 64px logo (clickable, no button/text).
- TG-fullscreen header: title + menu centred as a pair (hamburger right of the
  title), pinned to the bottom of the TG nav band.
- Edge-swipe back (Screen): a left-edge rightward drag navigates to back
  (touch/pen only, armed from <=24px; skipped inside Telegram).
- Chat soft-keyboard: a bottom-sheet Modal lifted above the keyboard by a
  visualViewport-driven transform (compositor-only, no page/sheet relayout).
  iOS-specific, needs on-device tuning; native resize=none awaits Capacitor.
- Tests: e2e for the in-game '✓ in friends' item and a board→board tile
  relocation; codec units for last_activity_unix + OutgoingRequestList.

Deferred to the next PR (agreed): #4 enrich the your-turn/game-end push; #5 hide
finished games from the lobby.
2026-06-08 21:31:44 +02:00
developer f95a6cb9c8 Merge pull request 'Stage 17 round 6 (#18, PR D): admin Messages moderation section' (#25) from feature/admin-messages into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 11s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m5s
2026-06-08 18:28:30 +00:00
developer 5d677cb282 Merge pull request 'Stage 17 round 6 (#16/#17, PR C): lobby sort + server-derived in-game friend state' (#24) from feature/lobby-friends into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 12s
CI / ui (push) Successful in 31s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m6s
2026-06-08 18:28:26 +00:00
developer c9a1eee510 Merge pull request 'Game/Telegram review polish: USSR flag, touch drag ghost, TG fullscreen header' (#23) from feature/game-ux into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 31s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s
2026-06-08 18:28:21 +00:00
developer 83e9a90d40 Merge pull request 'Landing v2: icon switchers, ephemeral theme, channel link, drop browser CTA' (#22) from feature/landing-v2 into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 12s
CI / ui (push) Successful in 31s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m7s
2026-06-08 18:28:17 +00:00
Ilia Denisov 356f490546 Stage 17 round 6 (#18, PR D): admin Messages moderation section
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 32s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m14s
A new /_gm/messages console page lists posted chat messages (nudges
excluded) newest-first — time, source (guest/robot/oldest identity kind),
sender (linked to the user card), IP, body, game (linked to the game card)
— searchable by sender name / external-id glob masks and pinnable to one
game (?game=) or sender (?user=), linked from the game and user cards.

The list query lives in social (raw SQL, kind='message', source via a SQL
CASE), reusing the now-exported account.LikePattern. Server-rendered
adminconsole MessagesView + messages.gohtml, 50/page via the shared pager.

Tests: adminconsole render case; backend integration AdminListMessages
(real Postgres) — nudge exclusion, game/sender pins, glob masks, source.
Docs: ARCHITECTURE section 8 chat moderation, PLAN round-6.
2026-06-08 20:10:27 +02:00
Ilia Denisov 6b6baf5710 Stage 17 round 6 (#16/#17, PR C): lobby sort + server-derived in-game friend state
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Lobby: group the my-games list into your-turn / opponent-turn / finished
(empty sections hidden), ordered by last activity (your-turn oldest-first,
the other two newest-first), as a compact line-separated list. gameDTO and
FB GameView gain last_activity_unix (turn start while active, finish time
once finished); a pure lib/lobbysort.ts holds the grouping/ordering.

Friends: the in-game 'add to friends' item is now server-derived via a new
GET /user/friends/outgoing (+ friends.outgoing op), returning addressees with
a pending OR declined request (both read as 'request sent'), so it is correct
across reloads; it shows a disabled '✓ in friends' once accepted. It
live-updates when the opponent answers: RespondFriendRequest now publishes
friend_added (accept) / friend_declined (new notify sub-kind, decline) to the
original requester, whose open game re-derives its friend state.

Tests: lobbysort unit test; gateway outgoing + last_activity transcode tests;
backend integration ListOutgoingRequests + respond-publishes-to-requester;
e2e updated for the new lobby section labels + a non-friend active opponent.
Docs: ARCHITECTURE notify catalog, FUNCTIONAL(+ru) lobby/friends, PLAN.
2026-06-08 19:23:48 +02:00
Ilia Denisov b720907db2 Review fixes #2: bigger flag star, TG header below nav, board-tile relocation
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
Addressing the review on #23:
- Flag star scaled up ~25% (the hammer&sickle emblem unchanged, kept clear of it).
- TG fullscreen header: drop the WHOLE header below the content-safe-area top
  inset (the hamburger stays to the right of the title), instead of pinning the
  hamburger to the physical top edge.
- DnD: a placed (pending) tile can now be relocated by dragging it to another
  board cell (board->board); it lifts off its source cell while dragged; and it
  can be grabbed even on the zoomed board (touch-action:none on the pending
  cell, so the drag wins over the board pan). The manual-selection blue frame
  now clears on recall.
2026-06-08 18:23:10 +02:00
Ilia Denisov 34385240b9 Game/Telegram review polish: USSR flag, touch drag ghost, TG fullscreen header
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
Backlog item 2 of ~4 (owner review pass):
- USSR flag emblem redrawn (canonical hammer & sickle, scaled down 1.5x
  below the star).
- Touch drag-and-drop: enlarge the drag ghost 1.5x on touch only (the finger
  hides the tile); suppress the iOS tap-highlight that lingered on a rack tile
  sliding into a dragged tile's slot.
- Telegram fullscreen: its native nav no longer hides our header -- the header
  drops below the content-safe-area top inset and the menu (hamburger) lifts
  into the nav band, centred (--tg-content-top from the SDK inset + a
  tg-fullscreen class; new telegram.ts helper + app wiring).

Tests: UI check/test:unit/build + full e2e (60) green. The iOS tap-highlight
fix and the TG-fullscreen layout want on-device verification on the deploy.
2026-06-08 17:11:10 +02:00
Ilia Denisov 3fd279cf8c Landing v2: icon switchers, ephemeral theme, channel link, drop browser CTA
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 7s
CI / integration (pull_request) Successful in 10s
CI / ui (pull_request) Successful in 32s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
Owner review-pass rework of the landing page:
- Rename the per-language Telegram link build var
  VITE_TELEGRAM_LINK_EN/_RU -> VITE_TELEGRAM_GAME_CHANNEL_NAME_EN/_RU
  (it carries a channel username; the landing builds https://t.me/<name> --
  the same channels the connector posts to via TELEGRAM_GAME_CHANNEL_ID_*).
- Language switcher -> a globe icon dropdown (flags + names), saved + synced
  to the app prefs.
- Theme switcher -> a sun/moon icon toggle, ephemeral (follows the system
  scheme, no auto, never persisted) -- galaxy-game style.
- Drop the "Play in browser" CTA (no standalone-web onboarding yet).

Docs: FUNCTIONAL(+ru), PLAN, deploy + ui READMEs.
2026-06-08 16:40:07 +02:00
developer 5928be40b0 Merge pull request 'Stage 17 round 6 (#16-20): landing page, /app/ move, cache + stream fixes' (#21) from feature/stage-17-round-6-landing into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 31s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m6s
2026-06-08 14:07:49 +00:00
Ilia Denisov e16076c89e Stage 17 round 6 (#16-20): landing page, /app/ move, cache + stream fixes
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
Close out Stage 17 round 6:

- Landing page at / — one Vite build with two entries (index.html = game
  SPA, landing.html = a lightweight landing reusing the theme/i18n/
  aboutContent leaf modules, not the app store).
- Move the web game SPA to /app/; the Telegram Mini App stays at /telegram/
  (gateway webui.Handler(stripPrefix, indexName): landing at /, SPA at /app/
  + /telegram/). Per-language "Play in Telegram" link via new
  VITE_TELEGRAM_LINK_EN/_RU build vars (button hides when unset).
- Cache headers: hash-named /assets/* immutable, HTML shells no-cache (the
  go:embed zero modtime emitted no validators, so the client re-downloaded
  the whole bundle every launch).
- Live-stream 15s abort fix: an immediate heartbeat on open + a 10s default
  interval (the first tick at 15s raced the edge idle timeout -> reconnect
  storm).

PLAN/ARCHITECTURE(§13)/FUNCTIONAL(+ru)/gateway+ui+deploy READMEs updated;
round 6 closed. Tests: gateway webui/connectsrv units, ui landing unit + e2e,
full e2e (60) green.
2026-06-08 13:33:05 +02:00
developer b8787a4123 Merge pull request 'Stage 17 round 6 (#4/#5/#6): draft persistence wire + gateway + UI' (#20) from feature/stage-17-round-6-drafts into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 32s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m1s
2026-06-08 11:08:42 +00:00
Ilia Denisov f5c2404123 Stage 17 round 6 (#4/#5/#6): draft persistence wire + gateway + UI
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 32s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
Complete the client-side draft feature on top of the shipped backend
foundation (the game_drafts store/service):

- FB: DraftRequest{game_id,json} + DraftView{json} (a draft get reuses
  GameActionRequest); regenerated committed Go + TS bindings.
- Backend REST: GET/PUT /games/:id/draft, a draftDTO
  (rack_order/board_tiles) mapped to game.Draft.
- Gateway: draft.get/draft.save transcode forwarding the composition
  JSON verbatim (json.RawMessage both ways -- no double-encode).
- UI: debounced save of the rack order + board tiles and restore on
  load (lib/draft.ts), plus #5 -- tiles may be arranged on the
  opponent's turn (placement relaxed; the preview and Make-move stay
  your-turn-only, so an off-turn draft is position-only).

Tests: backend handler validation, gateway pass-through round-trip, UI
draft/codec units, and a draft-restore e2e.
2026-06-07 22:25:29 +02:00
developer 353dff20c4 Merge pull request 'Stage 17: test-contour verification & defect fixes' (#19) from feature/stage-17-contour-verification-fixes into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 29s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m9s
2026-06-07 19:20:40 +00:00
Ilia Denisov 3632c2239f Stage 17 round 6: log progress + the next-pass plan in the tracker
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
Record round 6 in PLAN.md so the repository (not conversation memory) carries the trace:
the shipped follow-ups (profile, tap-flash, alphabet-keyed variant names + title, chat/nudge
by turn + cooldown reset, About + git-describe version, quick-game rules plaques, the two
fixes, #3 rack drag-reorder, and the #4/#5/#6 persistence backend foundation), plus the
REMAINING next-pass work with ready designs — the persistence gateway op-slice + UI wiring
(lean JSON-string FB; #5 off-turn placement) and the landing + /app/ move (#16-20).
2026-06-07 21:16:45 +02:00
Ilia Denisov 06c8039281 Stage 17 round 6 (#4/#5/#6 backend): per-game draft store + conflict reset
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
Foundation for persisting a player's client-side composition: a game_drafts table
(game_id, account_id, rack_order, board_tiles jsonb) with raw-SQL store/service methods —
GetDraft/SaveDraft (seated-player check) and, on every committed move, clearing the actor's
own draft and resetting any opponent's board draft whose cell the play overlapped (the
draft can no longer be placed; the rack order is kept). Integration tests cover the
round-trip, the actor clear, the overlap reset, a non-conflicting survival, and the
outsider rejection. The gateway op slice + UI wiring follow.
2026-06-07 12:29:32 +02:00
Ilia Denisov 2b0b1c0035 Stage 17 round 6 (#3): drag-reorder rack tiles with a visual gap
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
Dragging a rack tile and dropping it back on the rack reorders it: the dragged tile is
lifted out (the drag ghost stands in) and the tiles at/after the pointer's drop slot slide
right to open a gap there, so the drop position is visible. On drop the rack and its stable
ids are permuted (reorderIndices, unit-tested). Reorder applies only with no pending tiles,
so it stays a clean permutation; dropping on a board cell still places as before. Server
persistence of the order follows (#4).
2026-06-07 12:21:09 +02:00
Ilia Denisov 35666e1705 Stage 17 round 6 fixes: pin the nudge button right; schematic USSR flag emblem
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
- Chat: always render the (possibly empty) flex:1 caption before the nudge button, so the
  nudge stays pinned right whether or not the cooldown text shows (it drifted left when
  available).
- USSR flag: redraw the hammer & sickle as a thin schematic sketch — an elongated
  semicircle sickle with a handle, crossed by a T-shaped hammer (per the original's
  structure), instead of the bold over-filled emblem; the star is a touch smaller.
2026-06-07 12:10:52 +02:00
Ilia Denisov d3657fdf5c Stage 17 round 6 (#11/#12): quick-game variant plaques with rules, flag, and move-limit
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
Each auto-match variant is now a lobby-style plaque: the display name with a flag on the
right (🇺🇸 / 🇷🇺; Erudit uses a bundled minimalist USSR flag SVG) and a one-line rules
summary below — bag size, the ё rule, and bonus differences, sourced from the engine
rulesets (Scrabble 100 · Скрэббл 104, ё a letter · Эрудит 131, ё=е, no centre ×2, +15).
The move-time limit (24h auto-match clock) is shown under the buttons. e2e locks it.

(Multiple-words-per-move is the same for every variant, so it is described in About/landing
rather than repeated on each button.)
2026-06-07 11:48:19 +02:00
Ilia Denisov 74683f294f Stage 17 round 6 (#13/About): About screen content + app version from git describe
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 29s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 56s
- About screen: prominent localized title (Scrabble / Эрудит (Скрэббл)), a rules link
  (en/ru Wikipedia), and the Random-game / Game-with-friends sections; copy lives in a
  shared aboutContent module (the landing will reuse it). The random-game move limit
  inlines the 24h auto-match clock.
- App version: Vite define __APP_VERSION__ from VITE_APP_VERSION (default 'dev'), wired as
  a Docker build-arg sourced from `git describe --tags --always` in the deploy step — no
  manual version bumps. The fallback keeps a plain/local build working.
2026-06-07 11:39:31 +02:00
Ilia Denisov cdf616d6c4 Stage 17 round 6 (#7): reset the nudge cooldown once the player acts
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m4s
The hourly nudge cooldown now clears as soon as the sender has moved or posted a chat
since their last nudge — engagement lifts the 'don't spam' limit. Backend: Nudge checks
game.LastMoveAt + the sender's last non-nudge chat against the last nudge time
(GameReader gains LastMoveAt). UI: nudgeOnCooldown mirrors it — a chat reset is read from
the message list, a move is tracked client-side (lastActedAt on commit/pass/exchange; the
backend stays authoritative across a reload). Integration test covers the reset.
2026-06-07 11:32:08 +02:00
Ilia Denisov 2cb2b57cdb Stage 17 round 6 (#10 backend): enforce chat only on your turn
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
PostMessage now rejects a chat sent on a finished game or when it is not the sender's
turn (ErrChatNotYourTurn -> 409 chat_not_your_turn), matching the UI where the message
field is hidden off-turn and only the nudge shows. Existing chat tests post on the
to-move seat and are unaffected; adds an off-turn-rejection integration test + the dto
mapping case + the UI error message.
2026-06-07 11:23:43 +02:00
Ilia Denisov 512ad4dfb9 Stage 17 round 6 (cluster 1): profile, tap flash, variant naming, chat/nudge by turn
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
- Profile: drop the hint-balance line.
- Board: no mobile tap flash on a cell tap (-webkit-tap-highlight-color: transparent),
  matching the web click; the only intentional cell animation stays the last-word flash.
- Variant names keyed by the game's alphabet, not the UI language: english -> Scrabble
  always, russian_scrabble -> Скрэббл always (unlocalized, never collide), erudit localized.
- Chat/nudge are mutually exclusive by turn: the message field + Send show on your turn,
  the nudge replaces them on the opponent's turn; while the nudge cooldown is active the
  button is disabled with a grey 'awaiting reply' caption to its left.
2026-06-07 11:18:25 +02:00
Ilia Denisov a420d6a2cd Stage 17 round 5 docs: bake the bug fixes + UI polish + L2 into live docs
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 29s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 52s
- ARCHITECTURE: resign on the opponent's turn (ResignSeat + turn-check bypass); robots
  block chat but accept-and-ignore friend requests; quick-match /lobby/cancel; the admin
  robot play-to-win intent + next-move ETA panel.
- UI_DESIGN: even A->B zoom (recentre only on zoom-in), pinch, drop-target highlight,
  shuffle ≤0.3s + reduce-motion, borderless make-move disabled on illegal, variant title.
- FUNCTIONAL (+ru): variant display names (Scrabble/Erudite); robot ignores friend requests.
- PLAN: round-5 refinements bullet (+ the bilingual two-Scrabble open edge).
2026-06-07 09:48:08 +02:00
Ilia Denisov f916d5e0ca Stage 17 round 5 (L2): robot play-to-win intent + next-move ETA in the admin game card
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 29s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m14s
The admin game detail now shows, per robot seat, the game's deterministic play-to-win
decision (from the bag seed) and — while it is that robot's turn — its scheduled next-move
ETA (sampled think-time delay, deferred past the sleep window), plus a caption with the
~40% global target. Wiring: robot.PlayToWin/NextMoveAt/PlayToWinTargetPercent exports,
account.IsRobot, game RobotSchedule (seed + turn-start). Tests: NextMoveAt invariants
(never early, never in the sleep window), PlayToWin export, and an admin render integration
test asserting the intent + ETA + target appear.
2026-06-07 09:42:23 +02:00
Ilia Denisov 29d1193a0a Stage 17 round 5 — board interaction & UI polish
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 29s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
- Even zoom: interpolate the board scroll toward a pre-clamped target as the real width
  grows/shrinks, so it magnifies A->B in one motion instead of lurching and snapping back
  near the edges/centre. Recentre only on a zoom toggle, never on a focus change — so a
  2nd+ placed tile and a hovered dragged tile no longer jump the board.
- Drag: highlight the aimed-at empty cell as a drop target; hover-hold auto-zoom now
  fires only for the first (zoom-in) hold.
- Pinch zoom: two-finger spread/close toggles zoom toward the pinch midpoint (preventDefault
  only for two touches, so one-finger scroll stays native); a second finger aborts a drag.
- Shuffle hop capped at 0.3s and disabled under reduce-motion.
- Make-move is a borderless icon button, disabled while the pending word is known illegal.
- Variant display names: english & russian_scrabble -> Scrabble/Скрэббл, erudit ->
  Erudite/Эрудит; the in-game title shows the variant name (was always 'Scrabble').
2026-06-07 09:34:07 +02:00
Ilia Denisov 3899ffda0f Stage 17 round 5: fix robot-pool test for the new friend-request policy
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 11s
TestRobotPoolProvisionsRobotAccounts asserted robots block friend requests; they no
longer do (a request stays pending and expires like a human ignore). Assert chat is
blocked and friend requests are open. (Unblocks the integration job / contour deploy.)
2026-06-07 09:21:22 +02:00
Ilia Denisov 10412fee8e Stage 17 round 5 — backend/correctness bug fixes
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Failing after 12s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Failing after 1s
CI / deploy (pull_request) Has been skipped
- Resign on the opponent's turn: engine ResignSeat(seat) resigns a specific seat
  (not just toMove); game.Resign bypasses the turn check and forfeits the actor's seat.
- Quick-match cancel was a UI no-op (only stopped polling): add the full path
  (REST /lobby/cancel -> gateway lobby.cancel -> client) and clear the matchmaker's
  pending result on Cancel, so a cancelled search is dequeued (no 'already queued', no
  later robot-substituted game). NewGame dequeues on cancel and on abandon.
- Lobby win/loss: result.ts ranked by score, so a 0-0 resignation read as a win.
  The winner now takes rank 1 and the viewer is placed from rank 2 — matching the
  game-detail screen.
- Friend request to a robot: robots no longer block requests; the request stays
  pending and expires (friendRequestTTL), mirroring a human who ignores it.
- Nudge cooldown: ErrNudgeTooSoon now maps to a distinct nudge_too_soon code with a
  correct message; the chat nudge button disables during the hourly cooldown; the
  nudge note reads 'Waiting for your move!' (button keeps the Nudge action label).
Tests: engine/service off-turn resign, matchmaker cancel-clears-result, friend-to-robot
inttest, result.ts 0-0 resignation, nudge_too_soon mapping.
2026-06-07 09:17:35 +02:00
Ilia Denisov 3856b34f8a Stage 17 docs: round-4 UI (inline profile, double-tap/drag recall, hover-zoom, animated shuffle, lines-off board)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 29s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 41s
- UI_DESIGN: double-tap recall vs zoom, hover-hold drag auto-zoom, placing & recall
  rules, grid-lines toggle (gapless checkerboard default), animated shuffle; fix the
  stale MakeMove/Reset description (direct  button + ↩️ Reset tab, no popover).
- FUNCTIONAL (+ru): optional trailing '.' in display names; profile edited inline.
- PLAN: robot early band [1,5]→[3,10] (#14); round-4 refinements + deferred #2/#16.
2026-06-06 14:55:17 +02:00
Ilia Denisov 71b054227a Stage 17 (#12): lines-off board variant (gapless checkerboard), Settings toggle
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
Add a 'Grid lines' preference (default off): when off the board drops the 1px grid
gaps for a gapless checkerboard (plain cells alternate shades; tiles get rounded
corners and a soft right-side shadow so adjacent gapless tiles still read apart),
saving ~14px of width. When on, the classic lined grid returns. Persisted with the
other board-style prefs; wired through Board's new lines prop. e2e locks the default
and the toggle.
2026-06-06 14:51:48 +02:00
Ilia Denisov d0c1306d9b Stage 17 (#9): animated shuffle — tiles hop along a low parabola to new slots
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 28s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 53s
Give each rack slot a stable id permuted with the letters on shuffle, so the keyed
rack reorders (rather than relabelling in place) and Svelte's animate directive fires.
hop flies each tile along a parabola (apogee ~half a tile height) with a duration that
scales with the horizontal distance (arc length): the longest 1<->7 swap takes ~0.5s,
shorter swaps land sooner. Ordinary reflow (place/recall) stays instant via a guard.
e2e locks that a shuffle preserves the rack's tile multiset.
2026-06-06 14:46:59 +02:00
Ilia Denisov 1bbf0bc654 Stage 17 (#3,#5,#10): hover-hold drag zoom, always-editable profile, drag-back + double-tap recall
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 56s
- Board drag now auto-zooms toward a cell after holding the tile over it ~1s (#3).
- Profile is inline-editable: drop the Edit/Cancel toggle, form is always shown
  for durable accounts; hint balance stays read-only; re-populate after link/merge (#5).
- A pending tile recalls by double-tap (same cell) or by dragging it back onto the
  rack (unzoomed board); a single tap no longer recalls (#10).
- e2e: lock double-tap recall + single-tap no-op; drop the removed Edit-profile click.
2026-06-06 14:42:09 +02:00
Ilia Denisov 4fd82335db Stage 17 (#14): raise robot early-move band [1,5] -> [3,10] min (slower openings)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m1s
2026-06-06 14:26:13 +02:00
Ilia Denisov 54497374e4 Stage 17 (#15): admin users people/robots toggle + display-name & external-id glob filters
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m11s
- account.ListUsers/CountUsers with a UserFilter: people vs robots (by a robot identity),
  case-insensitive '*'/'?' glob masks on display_name and any identity's external_id
- admin users list shows the real kind (robot/guest/registered), defaults to people,
  with a People/Robots toggle + a filter form; pager preserves the filter
- integration test for the filter; SQL verified against the live contour DB
2026-06-06 14:14:28 +02:00
Ilia Denisov b15fd30c4f Stage 17 (contour round 4a): quick fixes
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
- #4 bag label: '{n} in the bag' / 'Bag is empty' (was 'Bag {n}')
- #6 allow a single trailing dot in display names (backend + UI regex + tests)
- #1 double-tap zooms toward the tapped cell, not the top-left
- #8 shuffle fires a short multi-pulse haptic
- #11 highlighted/flashing tiles darken their bottom edge too (shadow joins the flash)
- #13 toast slides up from the bottom and fades out
- #7 hide the logout button (kept wired behind `hidden`)
- #16 admin game seats: left-align numeric columns, clarify the 'Hints used' header
2026-06-06 14:08:40 +02:00
Ilia Denisov f6bffd1f57 Stage 17 (contour round 3): Telegram Mini Apps polish, board scroll, keyboard overlay
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 10s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 54s
- Telegram (lib/telegram.ts): chrome colours (setHeaderColor/setBackgroundColor/setBottomBarColor) match Telegram's header/bg/bottom bar to the app; native BackButton on sub-screens (app chevron hidden in TG); HapticFeedback on tile place/commit/error; enableClosingConfirmation while a game is open; disableVerticalSwipes so swipe-to-minimise doesn't fight tile drag / board scroll
- #9 board-only vertical scroll: Screen 'column' mode lets the board area scroll while score/status/rack/tab bar stay fixed (zoom keeps its own scroll)
- #10 check-word dialog opens in Modal keyboard-overlay mode (top-anchored, keyboard overlays the empty area) — no resize/relayout jank; other modals stay keyboard-aware
- docs: UI_DESIGN Telegram integration + vertical fit/keyboard; PLAN round 2-3 follow-ups
2026-06-06 12:55:46 +02:00
Ilia Denisov 645a503532 Stage 17 (#4): in-memory lobby cache — render instantly on the back-slide, refresh in background
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 10s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m4s
2026-06-06 12:38:04 +02:00
Ilia Denisov c94cd3c3bf Stage 17 (contour round 2): Grafana Live/reload, rate-limit, iOS reconnect, hint/plaque/make-move UX
- Grafana: disable Live (GF_LIVE_MAX_CONNECTIONS=0) so its WebSocket no longer trips caddy Basic-Auth and re-prompts; admin console gains a Grafana nav link
- deploy: force-recreate config-only services so reseeded Grafana dashboards / Caddyfile are actually picked up (the move-duration panel was invisible because the bind-mount went stale)
- rate-limit: raise per-user budget 120/40 -> 300/80; UI skips reloading on the echo of the player's own move (fewer requests, no double-load)
- iOS/Telegram reconnect: suppress the connection banner while backgrounded and for a short grace after resume; reconnect silently; wire visibilitychange + pageshow/pagehide + Telegram activated/deactivated (Bot API 8.0)
- hint button disabled when 0 hints remain; nudge button shows a disabled state on your own turn
- players plaque: invert so the active seat pops (accent chip, raised) and others recede
- make-move UX: a direct  commit button (no hold/popover); the Shuffle tab becomes ↩️ Reset while tiles are pending
2026-06-06 12:33:52 +02:00
Ilia Denisov 09fec2b83c Stage 17: bake decisions into PLAN, ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN, READMEs; mark stage done
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 26s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m4s
- PLAN: Stage 17 Refinements entry + caveats resolved summary + tracker done
- ARCHITECTURE §7 (move-number robot timing, composed variant-aware names), §10 (move event to the actor too), §11 (game_move_duration metric + offline admin per-user analytics), §14 (current branch model, path-conditional CI + gate, connector liveness)
- FUNCTIONAL(+ru): robot draws language-appropriate names
- UI_DESIGN: screen transitions, Telegram theme/nav, ad-banner accent, players plaque + history drawer
- backend README: robot timing/names refinements
2026-06-06 10:31:22 +02:00
Ilia Denisov 1d0bafaabb Stage 17: UI defect fixes (russian variant, Telegram theme/nav/banner, reconnect, hint zoom, plaque, history, transitions, per-game cache)
- #6 align the UI variant id to the backend canonical 'russian_scrabble' (type, variants, Lobby, mock, tests) — fixes the New->Russian 400
- #11/#12 inside Telegram force the colour scheme from WebApp.colorScheme (over OS prefers-color-scheme, fixing the Telegram Desktop breakage) and hide the theme switcher
- #14/#15 nav bar takes Telegram's bg; announcement banner gets a dedicated subtle --ad-bg accent token
- #16 suppress the reconnect banner while backgrounded and silently reconnect the live stream on return to the foreground
- #17 hint zoom scrolls to the placement's bounding box, not the top-left
- #19/#20 players plaque: active seat raised with side shadows, others sunk; tap toggles history
- #21/#23 history: scrollbar-gutter:stable (no word jitter); fixed-height drawer pins the bottom shadow to the board
- #3 (UI) disable nudge on the player's own turn
- #18a directional screen slide transitions (forward in from the right, back reveals the lobby)
- #13 per-game in-memory cache: instant render on re-entry + background refresh
- e2e: openGame waits for the slide transition to settle
2026-06-06 10:23:42 +02:00
Ilia Denisov c0b46a7ca6 Stage 17: path-conditional CI behind an aggregate gate + connector liveness probe; Grafana move-duration panel
- #10 a `changes` job path-filters unit/integration/ui; an always-running `gate` job aggregates them (success-or-skipped) and becomes the only required check
- #9 deploy adds a Telegram-connector liveness probe (docker inspect: running, not restarting, stable restart count) with a VPN-handshake grace period
- #1a Game-domain dashboard gains a 'Move think-time by phase (p50/p95)' panel
- deploy README: branch protection now requires only CI / gate
2026-06-06 10:05:01 +02:00
Ilia Denisov 635f2fd9fc Stage 17: backend defect fixes (nudge code, TG name, robot names/timing, multi-device push, move-duration metric + admin analytics)
- #3 nudge-on-own-turn: distinct result code nudge_own_turn + i18n (was reused 'not_your_turn')
- #2 sanitize connector registration name to the editable format; Player/Игрок-XXXXX fallback
- #5 variant-aware robot name pools (composed full/colloquial first + surname forms; ru gets <=20% latin)
- #4 move-number-aware robot move timing (early 1-5min -> late 10-90min, skew k=4)
- #7 emit move event to the actor too (multi-device sync); opponent_moved stays in-app only
- #1 live game_move_duration{variant,phase} histogram + admin console per-user min/avg/max columns and an inline-SVG move-time-by-move-number chart (offline from the journal)
- ProvisionRobot bypasses editor name validation (system names like 'Peter J.')
2026-06-06 09:59:12 +02:00
developer 6886efb6c0 Merge pull request 'Fix Grafana dashboards mount; connector OTLP via AWG_CONF (no DNS=)' (#18) from feature/contour-defect-fixes into development
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 19s
CI / deploy (push) Successful in 38s
2026-06-05 15:46:59 +00:00
Ilia Denisov 831ecd0cab Fix dangling config binds: seed configs to a stable host path
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 19s
CI / deploy (pull_request) Successful in 20s
Root cause of the Grafana "readdirent /etc/grafana/dashboards: no such file or
directory": the CI runner checks out into an ephemeral act workspace that is
removed after the job, so binding the compose config files straight from it
dangles the mounts in the long-lived containers (verified the act source dir is
emptied after the job). caddy/otelcol/prometheus/tempo read their config once at
startup so they survive, but would break on a restart — same latent bug.

Fix (mirrors ../galaxy-game's $HOME/.galaxy-dev/monitoring): the deploy job seeds
the config dirs to a stable $HOME/.scrabble-deploy and the compose binds them via
${SCRABBLE_CONFIG_DIR:-.} (local runs keep "."). Documented in the compose header,
deploy/README.md and the ci.yaml step.
2026-06-05 17:42:21 +02:00
Ilia Denisov 4a07d48a7b Fix Grafana dashboards mount; keep connector OTLP (AWG_CONF must omit DNS=)
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 20s
CI / deploy (pull_request) Successful in 19s
- deploy/docker-compose.yml: mount the provisioned dashboards at
  /etc/grafana/dashboards, not /var/lib/grafana/dashboards — the grafana-data
  volume mounts over the latter and shadows the nested bind, so the provider
  logged "readdirent /var/lib/grafana/dashboards: no such file or directory".
  dashboards.yaml provider path updated to match.
- Connector telemetry stays OTLP. The VPN sidecar's netns reaches the collector's
  internal IP fine (connected route, off-tunnel), but the sidecar's DNS hijacks
  name resolution: AWG_CONF must NOT carry a DNS= directive, else otelcol won't
  resolve ("produced zero addresses"). Without DNS= the netns uses Docker's
  resolver (resolves both otelcol and api.telegram.org). Documented in
  deploy/README.md (AWG_CONF row + wiring note), ARCHITECTURE §13, compose comment.
2026-06-05 17:34:33 +02:00
developer dce3edacee Merge pull request 'Stage 16: deploy infra & test contour' (#17) from feature/stage-16-deploy-test-contour into development
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 12s
CI / ui (push) Successful in 19s
CI / deploy (push) Successful in 20s
2026-06-05 15:00:45 +00:00
Ilia Denisov efbaf657c6 Stage 16: insert Stage 17 (test-contour verification); renumber prod deploy to 18
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 20s
CI / deploy (pull_request) Successful in 21s
- PLAN.md: new Stage 17 "Test-contour verification & defect fixes" (exercise the
  deployed contour end-to-end and fix what it surfaces — connector liveness check,
  path-conditional CI); the former prod-deploy stage becomes Stage 18.
- Renumber every "Stage 17" prod-deploy reference to "Stage 18" across docs,
  compose, Caddyfile, ci.yaml and CLAUDE.md; the post-Stage-14 split range is now
  "Stages 15–18".
2026-06-05 16:57:17 +02:00
Ilia Denisov 0ea35fe991 Stage 16: connector test-env via UseTestEnvironment; pin it in the test contour
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 10s
CI / ui (pull_request) Successful in 20s
CI / deploy (pull_request) Successful in 30s
- bot.New now selects Telegram's test environment with the library's native
  tgbot.UseTestEnvironment() instead of a token += "/test" hack (functionally
  identical URL /bot<token>/test/METHOD, but idiomatic) + a bot test asserting
  the getMe path for both test and prod.
- ci.yaml pins TELEGRAM_TEST_ENV=true for the test contour (it IS the test
  environment) instead of a TEST_TELEGRAM_TEST_ENV variable: removes the
  confusing double-TEST, telegram-specific, prefixed operator knob and the
  secret-vs-variable footgun. Prod (Stage 17) leaves it false.
- deploy/README.md + PLAN.md updated.
2026-06-05 16:44:10 +02:00
Ilia Denisov ee8d4fd85e Stage 16: deploy/README.md — full environment-variable reference
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 10s
CI / ui (pull_request) Successful in 20s
CI / deploy (pull_request) Successful in 20s
- deploy/README.md documents the services, how to run it locally and in CI, and
  every variable: required (the four :? ones + ≥1 bot token) and optional with
  defaults, marked secret-vs-variable and with the TEST_/PROD_ Gitea mapping;
  plus the fixed internal wiring and the host-side setup.
- ci.yaml maps the remaining POSTGRES_DB/USER, DICT_VERSION and LOG_LEVEL (unset
  renders empty -> the compose ":-" defaults apply), so every documented var is
  per-contour overridable.
- .env.example points at the README for the full reference.
2026-06-05 12:01:31 +02:00
Ilia Denisov 8700fbfae1 Stage 16: deploy infra & test contour
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 19s
CI / deploy (pull_request) Failing after 1s
- backend + gateway multi-stage distroless Dockerfiles; the gateway embeds and
  serves the SPA at / and /telegram/ via go:embed (committed dist placeholder,
  real build baked in by the image's node stage)
- deploy/docker-compose.yml: backend + gateway + Postgres + Telegram connector
  (VPN sidecar) + OTel Collector + Prometheus (15d) + Tempo (72h) + Grafana,
  fronted by a caddy owning a single /_gm Basic-Auth (admin console + Grafana
  subpath); inter-service on a private network, only caddy on the edge network
- new metrics: backend accounts_created_total{kind} (robots excluded) and an
  in-memory gateway active_users{window=24h,7d} gauge
- CI: single .gitea/workflows/ci.yaml (unit/integration/ui + a gated test-contour
  deploy) on the new feature/* -> development -> master branch model; the old
  go-unit/integration/ui-test workflows are folded in; the connector-scoped
  compose is retired (superseded by deploy/)
- docs: ARCHITECTURE §11/§12/§13, root + gateway READMEs, CLAUDE.md branching,
  PLAN.md (stage 16 done + refinements + Stage 17 forward-notes)
2026-06-05 11:42:26 +02:00
developer 8c8f8c4d42 Merge pull request 'Stage 15: dual Telegram bots & language-gated variants' (#16) from feature/stage-15-language-service-split into master
Tests · Go / test (push) Successful in 9s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Successful in 19s
2026-06-05 07:40:53 +00:00
Ilia Denisov e9f836db87 Stage 15: dual Telegram bots & language-gated variants
Tests · Go / test (push) Successful in 9s
Tests · Integration / integration (push) Successful in 10s
Tests · UI / test (push) Successful in 20s
Tests · Go / test (pull_request) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
Service-agnostic refinement of the owner's idea: the sign-in service returns a
set of supported game languages with the user identity, and the lobby gates the
New Game variant choice by it (en -> English; ru -> Russian + Эрудит).

- Connector hosts two bots in one container (one per service language, each its
  own token + game channel; the same telegram_id spans both). ValidateInitData
  tries each token and returns the validating bot's service_language +
  supported_languages. Per-language config (TELEGRAM_BOT_TOKEN_EN/_RU, channels).
- supported_languages rides the Session (fbs, session-scoped, not persisted); the
  UI offers only the matching variants on New Game — gating only the START of a
  new game (auto-match + friend invite), not accept/open/play; backend does not
  enforce.
- service_language persisted (accounts.service_language, migration 00010, written
  every login, last-login-wins) and routes the user-facing Notify push back
  through the right bot (push-target coalesces with preferred_language).
- Admin SendToUser/SendToGameChannel gain an operator-chosen language selector in
  the console (unrelated to ValidateInitData).
- Non-Telegram logins carry the gateway default set
  (GATEWAY_DEFAULT_SUPPORTED_LANGUAGES, all variants).

Wire (committed regen): ValidateInitDataResponse +service_language
+supported_languages; Session +supported_languages; SendToUser/SendToGameChannel
+language. Docs (ARCHITECTURE/FUNCTIONAL/_ru/READMEs) + PLAN updated; stage marked done.
2026-06-05 09:35:53 +02:00
Ilia Denisov 23b5c3b5cc refine plan stage order 2026-06-05 08:17:00 +02:00
developer e7c9d301ba Merge pull request 'Stage 14: solver & dictionary split (publish solver + scrabble-dictionary artifact)' (#15) from feature/stage-14-solver-dictionary-split into master
Tests · Go / test (push) Successful in 9s
Tests · Integration / integration (push) Successful in 10s
2026-06-05 06:03:36 +00:00
Ilia Denisov ec435c0e7f Stage 14: solver & dictionary split — consume published module + DAWG artifact (TODO-1/TODO-2)
Tests · Go / test (push) Successful in 8s
Tests · Integration / integration (push) Successful in 11s
Tests · Go / test (pull_request) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 11s
- backend/go.mod pins gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0; the engine's
  imports use the published module path; go.work drops the solver replace (GOPRIVATE fetches
  it directly from Gitea). The solver's wordlist/dictdawg are now public packages.
- CI (go-unit, integration): drop the solver sibling-clone, set GOPRIVATE, and download the
  dictionary DAWG release artifact (scrabble-dawg-<DICT_VERSION>.tar.gz from the new
  scrabble-dictionary repo) for BACKEND_DICT_DIR.
- Docs: ARCHITECTURE §5/§11/§13/§14 + backend/README updated to the published-module +
  release-artifact model. PLAN.md re-scoped Stage 14 to the split and added Stages 15 (deploy
  infra & test contour), 16 (prod contour), 17 (dual Telegram bots); TODO-1/TODO-2 marked done.
2026-06-04 20:00:36 +02:00
developer da6665b967 Merge pull request 'Stage 13: alphabet on the wire (UI alphabet-agnostic, TODO-4)' (#14) from feature/stage-13-alphabet-on-the-wire into master
Tests · Go / test (push) Successful in 10s
Tests · Integration / integration (push) Successful in 12s
Tests · UI / test (push) Successful in 19s
2026-06-04 15:00:57 +00:00
Ilia Denisov 90eaf4964b Stage 13: alphabet on the wire (UI alphabet-agnostic, TODO-4)
Tests · Go / test (push) Successful in 10s
Tests · Integration / integration (push) Successful in 12s
Tests · UI / test (push) Successful in 19s
Tests · Go / test (pull_request) Successful in 9s
Tests · Integration / integration (pull_request) Successful in 12s
Tests · UI / test (pull_request) Successful in 19s
Live play now exchanges per-variant alphabet indices instead of concrete
letters (rack out; submit-play, evaluate, exchange, word-check in). The client
caches each variant's (index, letter, value) table behind
StateRequest.include_alphabet and renders the rack and blank chooser from it,
dropping the hardcoded value/alphabet tables. History, the durable journal and
GCG stay decoded concrete characters (ARCHITECTURE §9.1, unchanged).

- pkg/fbs: new AlphabetEntry + PlayTile; StateView.rack -> [ubyte] + alphabet;
  StateRequest.include_alphabet; SubmitPlay/Eval tiles -> [PlayTile];
  Exchange tiles + CheckWord word -> [ubyte] (committed Go + TS regenerated).
- engine: AlphabetTable + a cached per-variant codec (LetterForIndex/EncodeRack/
  DecodeTiles/DecodeWord) + BlankIndex sentinel; Go parity test.
- backend server edge maps index<->letter (new thin game.Service.GameVariant);
  game.Service domain methods, engine.Game and the robot keep one letter-based
  play path. The gateway forwards indices verbatim (no alphabet table).
- ui: lib/alphabet.ts in-memory cache; codec encodes/decodes indices; premiums.ts
  is geometry-only; the mock seeds a fixture table; the UI normalises display to
  upper case (codec + cache), leaving placement/board/checkword unchanged.

Parity moved to the Go engine.AlphabetTable test; premiums.ts loses its value
tables. Discharges TODO-4.
2026-06-04 16:26:43 +02:00
developer 6537082397 Merge pull request 'Stage 12: observability & performance (OTel/OTLP, metrics, guest GC)' (#13) from feature/stage-12-observability into master
Tests · Go / test (push) Successful in 10s
Tests · Integration / integration (push) Successful in 13s
2026-06-04 12:57:53 +00:00
Ilia Denisov d99705645f Stage 12: mark done in the stage tracker (CI green)
Tests · Go / test (pull_request) Successful in 9s
Tests · Integration / integration (pull_request) Successful in 12s
2026-06-04 14:24:42 +02:00
Ilia Denisov dcd8de8b00 Stage 12: observability & performance (OTel/OTLP, domain metrics, guest GC)
Tests · Go / test (push) Successful in 11s
Tests · Integration / integration (push) Successful in 12s
Tests · Go / test (pull_request) Successful in 10s
Tests · Integration / integration (pull_request) Successful in 11s
- pkg/telemetry: shared OTel provider bootstrap (none/stdout/otlp + W3C
  propagators + Go runtime metrics); backend/internal/telemetry becomes a thin
  facade keeping its gin middleware.
- Telemetry parity: gateway and the Telegram connector gain telemetry runtimes
  and config (GATEWAY_/TELEGRAM_ SERVICE_NAME + OTEL_*); otelgrpc instruments the
  backend push server, the gateway's backend+connector clients and the connector
  server. Default exporter stays none (collector/dashboards are Stage 14).
- Operational metrics (variant attribute on game-scoped ones): game_replay_duration,
  game_move_validate_duration, games_started_total, games_abandoned_total,
  game_cache_active, chat_messages_total{kind}, gateway edge_request_duration.
  Wired via the SetMetrics setter pattern (default no-op meter).
- TODO-3: account.GuestReaper deletes guests with no game seat past
  BACKEND_GUEST_RETENTION (default 30d, swept every BACKEND_GUEST_REAP_INTERVAL).
- Tests: pkg/telemetry exporter selection; game/social/edge metric recording via
  a manual reader; config (otlp accepted, guest knobs); inttest guest reaper.
- Docs: PLAN.md re-scopes Stage 12 and adds Stage 13 (alphabet-on-wire) + Stage 14
  (CI/deploy) with the agreed dictionary-versioning resolution; ARCHITECTURE 11/13,
  TESTING, the three READMEs and FUNCTIONAL(+ru) updated.
2026-06-04 14:22:15 +02:00
developer 01485d8fc6 Stage 11: account linking & merge (email + Telegram Login Widget) (#12)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Successful in 18s
2026-06-04 09:18:17 +00:00
developer 3a640a17a4 Stage 10: admin console & dictionary ops (complaint review, hot-reload, broadcasts) (#11)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 13s
2026-06-04 07:27:49 +00:00
developer 4c4beace85 Stage 9: Telegram integration (connector, Mini App, out-of-app push) (#10)
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Successful in 19s
2026-06-04 05:12:54 +00:00
Ilia Denisov cf66ed7e26 Stage 9: Telegram integration (connector side-service, Mini App, out-of-app push)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 12s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
New platform/telegram connector (own container, bot token only there):
- go-telegram/bot long-poll loop: /start deep-links + Mini App launch button.
- gRPC API pkg/proto/telegram/v1 (Telegram service): ValidateInitData, Notify
  (renders a localized message + deep-link button), SendToUser/SendToGameChannel
  (admin, wired in Stage 10). Generic methods are platform-agnostic (external_id).
- Bot API base override for Telegram's test environment; Dockerfile + compose
  (VPN sidecar, no public ingress); README.

Gateway:
- initData validation relocated from the gateway into the connector; the gateway
  calls ValidateInitData over gRPC (GATEWAY_CONNECTOR_ADDR), drops the bot token,
  and deletes internal/auth.
- Out-of-app push: runPushPump routes events whose recipient has no live in-app
  stream to connector.Notify, gated by /internal/push-target + the in-app-only
  flag (race-free de-dup); HasSubscribers added to the push hub.

Backend:
- Migration 00007 accounts.notifications_in_app_only (default true) + jetgen.
- ProvisionTelegram seeds a new account's language/display name from the launch
  fields; IdentityExternalID reverse lookup; /internal/push-target handler.

UI:
- Telegram Mini App launch: detect initData, apply themeParams, authTelegram,
  route the deep-link start_param (g/i/f); /telegram/ guard redirects outside
  Telegram. Vite relative base + telegram-web-app.js. In-app-only profile toggle;
  share-to-Telegram link for a friend code. Vitest + Playwright coverage.

Wire/docs/CI: fbs Profile/UpdateProfileRequest gain notifications_in_app_only
(Go + TS); go.work uses ./platform/telegram; go-unit.yaml covers it; PLAN,
ARCHITECTURE, FUNCTIONAL (+ru), UI_DESIGN, READMEs updated.
2026-06-04 07:10:21 +02:00
394 changed files with 28126 additions and 3520 deletions
+349
View File
@@ -0,0 +1,349 @@
name: CI
# Single gated pipeline for the test contour. Gitea cannot express
# cross-workflow `needs`, so the full test suite and the auto test-deploy live in
# one workflow.
#
# Branch model (CLAUDE.md): feature branches are cut from `development`; a commit
# to a feature branch triggers nothing. The pipeline runs on a PR into
# `development` or `master` (the full test suite — the merge gate) and on a push
# to `development` (after a merge). The deploy job runs only for `development`
# (PR or merge), so a PR into `master` is test-only; the prod deploy is a manual
# workflow.
#
# Path-conditional jobs: `unit`/`integration`/`ui` run only when their
# code changed (the `changes` job decides). Because a skipped required check would
# block a merge under branch protection, the always-running `gate` job aggregates
# their results and is the ONLY required status check; it passes when every
# upstream job either succeeded or was skipped.
#
# Console output is kept plain (NO_COLOR + `docker compose --ansi never` +
# `--progress plain`) so the Gitea logs stay readable.
on:
pull_request:
branches: [development, master]
push:
branches: [development]
jobs:
# changes detects which areas a PR/push touched, so the test jobs can skip when
# irrelevant. It defaults to running everything when the diff cannot be computed.
changes:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
outputs:
go: ${{ steps.filter.outputs.go }}
ui: ${{ steps.filter.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changed paths
id: filter
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
git fetch -q origin "${{ github.base_ref }}" || true
range="origin/${{ github.base_ref }}...HEAD"
else
before="${{ github.event.before }}"
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ] || ! git cat-file -e "${before}^{commit}" 2>/dev/null; then
range="HEAD~1...HEAD"
else
range="${before}...HEAD"
fi
fi
echo "comparison range: $range"
# Default to running everything; narrow only when the diff is computable.
go=true; ui=true
files="$(git diff --name-only "$range" 2>/dev/null || echo __DIFF_FAILED__)"
if [ "$files" != "__DIFF_FAILED__" ]; then
echo "changed files:"; echo "$files"
go=false; ui=false
if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|loadtest/|go\.work)'; then go=true; fi
if echo "$files" | grep -qE '^ui/'; then ui=true; fi
# A workflow or deploy change re-runs everything as a safety net.
if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi
else
echo "diff failed; running all jobs"
fi
echo "selected: go=$go ui=$ui"
echo "go=$go" >> "$GITHUB_OUTPUT"
echo "ui=$ui" >> "$GITHUB_OUTPUT"
unit:
needs: changes
if: ${{ needs.changes.outputs.go == 'true' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
# The engine consumes the published scrabble-solver module from this Gitea;
# GOPRIVATE makes go fetch it directly (skipping the public proxy/checksum DB).
GOPRIVATE: gitea.iliadenisov.ru/*
DICT_VERSION: v1.0.0
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fetch dictionary DAWGs
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
ls -la "${GITHUB_WORKSPACE}/dawg"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.work
cache: true
- name: gofmt
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "gofmt needed on:"; echo "$unformatted"; exit 1
fi
- name: vet
run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/...
- name: build
run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/...
- name: test
env:
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/...
integration:
needs: changes
if: ${{ needs.changes.outputs.go == 'true' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
# Ryuk (testcontainers' reaper) does not start cleanly on every runner; the
# suite's TestMain terminates its own container, so disable it.
TESTCONTAINERS_RYUK_DISABLED: "true"
GOPRIVATE: gitea.iliadenisov.ru/*
DICT_VERSION: v1.0.0
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fetch dictionary DAWGs
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
ls -la "${GITHUB_WORKSPACE}/dawg"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.work
cache: true
- name: Integration tests
# -count=1 disables the cache; -p=1 -parallel=1 keeps the container-backed
# tests serial; the 15-minute timeout bounds a stuck container pull.
env:
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/...
ui:
needs: changes
if: ${{ needs.changes.outputs.ui == 'true' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: ui
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
run: npm install -g pnpm@11.0.9
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Type-check
run: pnpm run check
- name: Unit tests
run: pnpm run test:unit
- name: Build
run: pnpm run build
- name: Bundle-size budget
run: node scripts/bundle-size.mjs
- name: Install Playwright browsers
run: pnpm exec playwright install chromium webkit
timeout-minutes: 5
- name: E2E smoke (mock)
run: pnpm run test:e2e
timeout-minutes: 5
# gate is the single branch-protection required check. It always runs and passes
# only when each upstream job succeeded or was skipped (a path-filtered no-op),
# failing the merge if any actually failed or was cancelled.
gate:
needs: [unit, integration, ui]
if: always()
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Aggregate required checks
run: |
fail=
for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}"; do
name="${r%%:*}"; res="${r#*:}"
echo "$name = $res"
case "$res" in
success|skipped) ;;
*) echo "::error::$name=$res"; fail=1 ;;
esac
done
[ -z "$fail" ] || { echo "one or more required jobs failed"; exit 1; }
echo "all required jobs passed or were skipped"
deploy:
# Auto test-deploy on a PR into development and on the push that merges it.
# A PR into master is test-only (this job is skipped); prod deploy is manual.
# Gates on `gate` (so a real test failure blocks the deploy) but runs even when
# some test jobs were path-skipped.
needs: [gate]
if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development') }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
NO_COLOR: "1"
DOCKER_CLI_HINTS: "false"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build and (re)deploy the test contour
working-directory: deploy
env:
# Sensitive values -> secrets; non-sensitive -> variables. The compose
# interpolates these unprefixed names (see deploy/.env.example).
POSTGRES_PASSWORD: ${{ secrets.TEST_POSTGRES_PASSWORD }}
AWG_CONF: ${{ secrets.TEST_AWG_CONF }}
GM_BASICAUTH_HASH: ${{ secrets.TEST_GM_BASICAUTH_HASH }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN_EN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_EN }}
TELEGRAM_BOT_TOKEN_RU: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_RU }}
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID_EN: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_EN }}
TELEGRAM_GAME_CHANNEL_ID_RU: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_RU }}
# The test contour always uses Telegram's test environment — pinned here,
# not an operator variable. The prod workflow leaves it false.
TELEGRAM_TEST_ENV: "true"
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_EN }}
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_RU }}
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${{ vars.TEST_GATEWAY_DEFAULT_SUPPORTED_LANGUAGES }}
# Unset vars render empty -> the compose ":-" defaults apply.
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
DICT_VERSION: ${{ vars.TEST_DICT_VERSION }}
LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }}
run: |
# Seed the config files to a stable host path. The runner checks out into
# an ephemeral act workspace that is removed after the job, which would
# dangle the compose config bind mounts in the long-lived containers
# (e.g. Grafana then logs "no such file or directory"). Bind from a stable
# dir instead (mirrors ../galaxy-game's $HOME/.galaxy-dev/monitoring).
conf="$HOME/.scrabble-deploy"
rm -rf "$conf"
mkdir -p "$conf"
cp -r caddy otelcol prometheus tempo grafana "$conf"/
export SCRABBLE_CONFIG_DIR="$conf"
# App version for the About screen: the git tag if present, else the short SHA
# (the test checkout is shallow/untagged, so this is the SHA here — fine).
export APP_VERSION="$(git -C "$GITHUB_WORKSPACE" describe --tags --always 2>/dev/null || echo dev)"
docker compose --ansi never build --progress plain
docker compose --ansi never up -d --remove-orphans
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
# changed Caddyfile or Grafana dashboard is ignored — force-recreate them to
# pick up the fresh config.
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
- name: Probe the landing and the gateway through caddy
run: |
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
if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/ &&
docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/app/; then
echo "healthy: GET http://scrabble/ (landing) + /app/ (gateway)"
exit 0
fi
sleep 3
done
echo "probe failed; recent landing + gateway logs:"
docker logs --tail 50 scrabble-landing || true
docker logs --tail 50 scrabble-gateway || true
exit 1
- name: Probe the Telegram connector liveness
run: |
set -u
# The gateway probe cannot see a crash-looping connector (it long-polls and
# egresses through the VPN sidecar, with no public ingress). Inspect the
# container directly: it must be running, not restarting, with a stable
# restart count. A grace period lets the VPN handshake settle (the connector
# may restart a few times first).
sleep 20
for i in $(seq 1 20); do
status="$(docker inspect -f '{{.State.Status}}' scrabble-telegram 2>/dev/null || echo missing)"
restarting="$(docker inspect -f '{{.State.Restarting}}' scrabble-telegram 2>/dev/null || echo true)"
if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then
c1="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)"
sleep 5
c2="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)"
if [ "$c1" = "$c2" ]; then
echo "connector healthy: status=$status restarts=$c2"
exit 0
fi
echo "connector still restarting ($c1 -> $c2); waiting"
fi
sleep 3
done
echo "connector not healthy; recent logs:"
docker logs --tail 80 scrabble-telegram || true
exit 1
- name: Prune dangling images
if: always()
run: docker image prune -f
-72
View File
@@ -1,72 +0,0 @@
name: Tests · Go
# Fast unit tests for the Go side of the monorepo. Runs on every push and pull
# request whose path filter matches a Go source directory. The module list
# grows as new go.work modules (gateway, pkg/*, platform/*) are added by later
# stages.
on:
push:
paths:
- 'backend/**'
- 'gateway/**'
- 'pkg/**'
- 'platform/**'
- 'go.work'
- 'go.work.sum'
- '.gitea/workflows/go-unit.yaml'
- '!**/*.md'
pull_request:
paths:
- 'backend/**'
- 'gateway/**'
- 'pkg/**'
- 'platform/**'
- 'go.work'
- 'go.work.sum'
- '.gitea/workflows/go-unit.yaml'
- '!**/*.md'
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fetch scrabble-solver (sibling)
# The engine package consumes scrabble-solver in-process; go.work points
# its bare module path at this sibling checkout. The repository is public,
# so the clone needs no credentials. It tracks master HEAD (see PLAN.md
# TODO-1 for the move to a published, versioned module).
run: git clone --depth 1 https://gitea.iliadenisov.ru/developer/scrabble-solver.git "${GITHUB_WORKSPACE}/../scrabble-solver"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.work
cache: true
- name: gofmt
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "gofmt needed on:"; echo "$unformatted"; exit 1
fi
- name: vet
run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
- name: build
run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
- name: test
# -count=1 disables the test cache so a green run never depends on a
# previous runner's cached state. BACKEND_DICT_DIR points the engine
# tests at the committed DAWGs in the sibling checkout.
env:
BACKEND_DICT_DIR: ${{ github.workspace }}/../scrabble-solver/dawg
run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
-62
View File
@@ -1,62 +0,0 @@
name: Tests · Integration
# Postgres-backed integration tests for the Go backend, gated behind the
# `integration` build tag. They spin a throwaway postgres:17-alpine container via
# testcontainers-go, which reaches the host Docker daemon through the socket the
# Gitea runner exposes. Slower than the unit job (go-unit.yaml); run serially
# (-p=1) with Ryuk disabled — TestMain terminates its own container. The module
# list grows as new go.work modules are added by later stages.
on:
push:
paths:
- 'backend/**'
- 'pkg/**'
- 'go.work'
- 'go.work.sum'
- '.gitea/workflows/integration.yaml'
- '!**/*.md'
pull_request:
paths:
- 'backend/**'
- 'pkg/**'
- 'go.work'
- 'go.work.sum'
- '.gitea/workflows/integration.yaml'
- '!**/*.md'
jobs:
integration:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
# Ryuk (testcontainers' reaper) does not start cleanly on every runner;
# the suite's TestMain terminates its own container, so disable it.
TESTCONTAINERS_RYUK_DISABLED: "true"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fetch scrabble-solver (sibling)
# The backend now imports the engine package, which consumes
# scrabble-solver in-process; go.work points its bare module path at this
# sibling checkout. The repository is public, so the clone needs no
# credentials. It tracks master HEAD (see PLAN.md TODO-1).
run: git clone --depth 1 https://gitea.iliadenisov.ru/developer/scrabble-solver.git "${GITHUB_WORKSPACE}/../scrabble-solver"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.work
cache: true
- name: Integration tests
# -count=1 disables the test cache; -p=1 -parallel=1 keeps the
# container-backed tests serial; the 15-minute timeout bounds a stuck
# container pull. The engine package's (untagged) tests also compile and
# run here, so BACKEND_DICT_DIR points them at the committed DAWGs.
env:
BACKEND_DICT_DIR: ${{ github.workspace }}/../scrabble-solver/dawg
run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/...
-67
View File
@@ -1,67 +0,0 @@
name: Tests · UI
# Hermetic UI checks: type-check, Vitest unit tests, production build with a
# bundle-size budget, and a Playwright smoke (Chromium + WebKit) against the in-memory
# mock transport (no backend/gateway/Postgres). The committed src/gen/ codegen is built, not
# regenerated (the same model as the Go committed jet/fbs output).
on:
push:
paths:
- 'ui/**'
- '.gitea/workflows/ui-test.yaml'
pull_request:
paths:
- 'ui/**'
- '.gitea/workflows/ui-test.yaml'
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: ui
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
run: npm install -g pnpm@11.0.9
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Type-check
run: pnpm run check
- name: Unit tests
run: pnpm run test:unit
- name: Build
run: pnpm run build
- name: Bundle-size budget
run: node scripts/bundle-size.mjs
# The Playwright system libraries are provisioned once on the runner host
# (`sudo npx playwright@<version> install-deps chromium`), so the job needs no
# apt and no sudo: it only downloads the browser binaries into the runner cache
# (persisted by the host executor) and runs the suite. WebKit's Debian build
# bundles most of its own libraries and runs headless without extra host deps; if
# a runner ever lacks one, provision it once on the host with
# `sudo npx playwright install-deps webkit`. The timeouts guard against a future
# hang. Keep this in lockstep with @playwright/test in package.json — re-run
# install-deps on the host after a major bump.
- name: Install Playwright browsers
run: pnpm exec playwright install chromium webkit
timeout-minutes: 5
- name: E2E smoke (mock)
run: pnpm run test:e2e
timeout-minutes: 5
+3
View File
@@ -16,3 +16,6 @@
# Local, unstaged env overrides
**/.env.local
**/.env.*.local
# Claude Code harness runtime artifacts
.claude/scheduled_tasks.lock
+27 -5
View File
@@ -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
details to interview*.
- [`PRERELEASE.md`](PRERELEASE.md) — pre-release hardening tracker (phases R1R7
before Stage 18); same per-phase *interview + bake-back* discipline as `PLAN.md`.
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport,
security, the decision record. Always describes current state.
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)
@@ -49,9 +51,20 @@ conversation memory — is the source of continuity. Keep it that way.
## Branching & CI
- Trunk is **`master`** (owner preference). From Stage 1, work on `feature/*`
and merge via PR with a green CI gate. The genesis commit (Stage 0) lands on
`master` by necessity (an empty branch has nothing to PR into).
- **Two long-lived branches** (Stage 16 onward): **`development`** is the
integration branch; **`master`** is the production trunk. Cut `feature/*`
branches **from `development`** and PR them back into it. (Stages 015 used
`master` as the trunk with `feature/* → master`; the genesis Stage 0 commit is
on `master` by necessity.)
- A commit to a `feature/*` branch triggers **nothing**. The single workflow
`.gitea/workflows/ci.yaml` runs the full suite (`unit` + `integration` + `ui`)
on a PR into `development` or `master`, and the gated **`deploy`** job auto-rolls
the **test contour** on a PR into — or a push to — `development`
(`docker compose up -d --build` on the runner host + a `GET /` probe). A PR into
`master` is test-only.
- Merge `development → master` only when CI is green; the **prod** deploy is then a
**manual** workflow (Stage 18), never automatic. Secrets/variables are prefixed
`TEST_` / `PROD_` per contour (Gitea 1.26 has no deployment environments).
- After any push, watch the run to green before declaring a stage done — use the
ready-made watcher, never an inline poll loop:
`python3 ~/.claude/bin/gitea-ci-watch.py` (background). It reads `$GITEA_URL`
@@ -113,6 +126,9 @@ backend/ # module scrabble/backend
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
gateway/ ui/ pkg/ # added by their stages
platform/telegram/ # Telegram connector side-service (Stage 9): bot + gRPC API
loadtest/ # module scrabble/loadtest: the pre-release stress harness (R2)
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless (Stage 16; loadtest R2); gateway/Dockerfile also has the `landing` target (R3)
deploy/ # docker-compose (per-service limits, R7) + caddy + landing + otelcol (OTLP + docker_stats per-container metrics) + prometheus/tempo/grafana + postgres_exporter
```
## Build & test
@@ -127,9 +143,15 @@ go run ./backend/cmd/backend # /healthz, /readyz on :8080
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+)
pnpm start # UI mock mode: lobby -> game, no backend
docker build -f backend/Dockerfile -t scrabble-backend . # images (Stage 16); gateway embeds the SPA
docker build -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
```
The `ui` module is a Node project (pnpm), **not** in `go.work`; its CI is
`.gitea/workflows/ui-test.yaml`. Committed edge codegen under `ui/src/gen/`
The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` job
of the single `.gitea/workflows/ci.yaml` (Stage 16 folded the former go-unit /
integration / ui-test workflows into it). Committed edge codegen under `ui/src/gen/`
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
+837 -37
View File
@@ -43,9 +43,15 @@ independent (see ARCHITECTURE §9.1).
| 7 | UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | **done** |
| 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | **done** |
| 9 | Telegram integration (bot side-service, deep-link, push) | **done** |
| 10 | Admin & dictionary ops (complaint review, version reload) | todo |
| 11 | Account linking & merge | todo |
| 12 | Polish (observability, perf with evidence, deploy) | todo |
| 10 | Admin & dictionary ops (complaint review, version reload) | **done** |
| 11 | Account linking & merge | **done** |
| 12 | Observability & performance (telemetry, metrics, guest GC) | **done** |
| 13 | Alphabet on the wire (UI alphabet-agnostic) | **done** |
| 14 | Solver & dictionary split (publish solver + scrabble-dictionary repo/artifact) | **done** |
| 15 | Dual Telegram bots & language-gated variants | **done** |
| 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | **done** |
| 17 | Test-contour verification & defect fixes | **done** |
| 18 | Prod contour deploy (SSH export/import, manual after merge) | todo |
Scaffolding is incremental: `go.work` lists only existing modules; each stage
adds the modules it needs.
@@ -204,10 +210,214 @@ dedupe). High blast-radius — focused regression tests.
Open details: conflict resolution (active games on both, duplicate friends,
display-name collisions); irreversibility/audit; confirm-flow per platform.
### Stage 12 — Polish
Scope: observability dashboards, evidence-based performance work, prod
build/deploy.
Open details: deployment target/host; dashboards; load expectations.
### Stage 12 — Observability & performance
Scope: wire a configurable **OTLP** exporter (alongside `none`/`stdout`), shared in a
new `pkg/telemetry`; add telemetry to the **gateway** and the **Telegram connector**
(providers + `otelgrpc` on the gRPC hops) for parity with the backend; add
domain/operational **metrics** close to the business (game replay/validate timings,
started/abandoned games, live-cache size, chat/nudge counts, the edge roundtrip, Go
runtime metrics); discharge **TODO-3** (abandoned-guest GC). The OTLP collector and
dashboards are stood up with the deploy (Stage 15); the default exporter stays `none`,
so CI needs no collector. Performance is operational-metric instrumentation, not
speculative optimisation (the standing "evidence first" rule — no measured hotspot yet).
Open details: exporter default and whether a collector is stood up now; the metric set
and its attributes; the guest-reaper trigger given revoke-only sessions.
### Stage 13 — Alphabet on the wire (TODO-4)
Scope: make the UI **alphabet-agnostic**. On game-screen load the client receives the
variant's alphabet table `(letter, index, value)` for **display only**, caches it in
memory by variant (a request flag gates whether the table is included, so it is not
resent on every state poll); live play then exchanges **letter indices** both ways, and
**word-check** sends indices, constraining input to the variant's alphabet. The engine
already works in alphabet-index bytes, so the wire does *less* decoding in live play; the
durable journal / history / GCG stay decoded concrete characters (the §9.1
dictionary-independent invariant is untouched). The alphabet comes from the **solver's
rules** (not the DAWG), so the wire table is pinned by the solver version. **Index-drift
caveat:** the running solver, the DAWGs (built against it — Stage 14 / TODO-2) and the
wire table must agree, or letter indexing silently corrupts. Blast radius: `pkg/fbs`
(a new Alphabet table; index fields in `StateView`/rack and in
`SubmitPlay`/`Exchange`/`check_word`) → backend DTO encode/decode → UI
`codec.ts`/`premiums.ts` → board/rack render, the move/exchange/word-check senders, the
mock transport and the Vitest tests.
Open details: the fbs shape and `include_alphabet` flag placement; whether to keep
concrete-letter fields during the transition; whether tile exchange moves fully to
indices; the premiums.ts parity-test rework.
### Stage 14 — Solver & dictionary split (TODO-1 + TODO-2)
Re-scoped from the original "CI & deploy": that was several sessions of work, so the
deploy + observability + the two-bots idea were split into **Stages 1518** below and this
stage took only the dependency/artifact split that everything else builds on. Scope: publish
`scrabble-solver` as a versioned Gitea module and split the dictionary build into a new
`scrabble-dictionary` repo delivering a **release artifact**, then make `scrabble-game` consume
both — discharging **TODO-1** and **TODO-2**.
- **TODO-1 — solver published.** `scrabble-solver` renamed to module
`gitea.iliadenisov.ru/developer/scrabble-solver`, tagged **v1.0.0**; `wordlist`/`dictdawg`
de-internalised to public packages (the dict repo imports them); `cmd/builddict`/`dictprep`/the
`dictionaries` submodule moved out; `internal/dict` repointed at the committed `dawg/*.dawg`
fixtures. `backend/go.mod` pins `v1.0.0`; the `go.work` replace and the CI sibling-clone are
gone; `GOPRIVATE=gitea.iliadenisov.ru/*` makes go fetch it directly (no public proxy/checksum DB).
- **TODO-2 — dictionary artifacts.** New repo `developer/scrabble-dictionary` holds the word-list
sources + `cmd/builddict` and builds the three DAWGs against the **published solver + pinned
`dafsa`/`alphabet` v1.1.0**, so they are byte-identical to the solver's fixtures (no index drift).
Released as `scrabble-dawg-vX.Y.Z.tar.gz` (flat, one semver per set); the Go workflows download it
and point `BACKEND_DICT_DIR` at it. The runtime contract is unchanged (additive
`BACKEND_DICT_DIR/<version>/`, `engine.OpenWithVersions`, per-game `dict_version` pin; a version is
safe to retire once no active game pins it).
### Stage 15 — Dual Telegram bots & language-gated variants *(done)*
Re-framed at its start to be **service-agnostic**: the sign-in service returns, with the user identity, a
**set of supported game languages** (subset of `{en, ru}`, ≥ 1) that gates the New Game variant choice.
Built: the connector hosts **two bots in one container** (one per service language, each its own token +
game channel; the same Telegram user id spans both); `ValidateInitData` tries each token in turn and
returns the validating bot's **`service_language`** + **`supported_languages`** set. The set rides the
`Session` (FlatBuffers, session-scoped, not persisted) and the UI offers only the matching variants on New
Game (en → English; ru → Russian + Эрудит) — gating **only** the start of a new game (auto-match + friend
invite); existing games of any language are unrestricted and the backend does not enforce. The service
language is persisted (`accounts.service_language`, migration `00010`, written on every login —
last-login-wins) and routes the user-facing out-of-app push (`Notify`) back through the right bot (falls
back to `preferred_language`). Non-Telegram logins (web/email/guest) carry the gateway default set
(`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants). Admin broadcasts (`SendToUser`/`SendToGameChannel`)
pick the bot by an **operator-chosen** language in the console — unrelated to `ValidateInitData`.
### Stage 16 — Deploy infra & test contour *(done)*
Scope: the deploy machinery + the **test contour** (the bulk of the original Stage 14). Backend +
gateway **Dockerfiles** (multi-stage distroless, mirroring the Stage 9 connector image); the gateway
gains **static UI serving****embedded** via `go:embed` (a node build stage in the gateway image),
SPA served at both `/` (web) and `/telegram/` (Mini App), the §13 single-origin model; prod UI build
vars (`VITE_TELEGRAM_BOT_ID`, `VITE_TELEGRAM_LINK`, `VITE_GATEWAY_URL`) as image build-args; a root
`deploy/docker-compose.yml` (backend + gateway + Postgres + connector + VPN sidecar + the **full
observability stack** — OTel Collector + Prometheus + Tempo + Grafana with provisioned dashboards) on
the external `edge` network behind the host caddy (VPN sidecar only for the connector); the backend
image pulls the DAWG release artifact (Stage 14). **The test contour deploys automatically on push to
a feature branch** (`docker compose up -d --build` on the local host where the gitea runner lives),
with a post-deploy probe (`GET /` on the gateway). Test-contour secrets use the **`TEST_`** prefix
(see Stage 16).
Open details (re-interview at start): the dashboard set; the gateway static-serving hook (before the
h2c wrap — `/` + `/telegram/` mounts; a committed `dist` placeholder so `go build` works without a UI
build); Postgres healthcheck/volume; whether the connector-scoped compose is retired for the root one;
collector/Tempo/Prometheus retention.
### Stage 17 — Test-contour verification & defect fixes *(done)*
Scope: exercise the deployed **test contour** end-to-end and fix the defects it surfaces — the
"does it actually work in the contour" pass before prod. Bring up the `development` deploy, then
verify each piece against a real run: the gateway serves the SPA at `/` and `/telegram/`; the admin
console and Grafana sit behind the single `/_gm` Basic-Auth; the Telegram **bots** start (test
environment) and the Mini App launches/authenticates; a game can be created and played through (web
+ Mini App); the **observability** stack receives data (Prometheus targets up, the dashboards
populate incl. `accounts_created_total`/`active_users`, traces reach Tempo); the out-of-app push
works. Fix the defects found and harden where the run exposes gaps — notably a CI **connector
liveness check** (the deploy probe only hits the gateway today, so a crash-looping connector is
invisible — that is how the Stage 16 test-env miss went unnoticed) and **path-conditional CI** (skip
the jobs whose code did not change, behind a single always-running gate job so branch-protection
required checks stay satisfiable — a skipped required check otherwise blocks the merge).
Open details (interview at start): the verification checklist + pass bar; which discovered defects
are in-scope vs deferred; the changed-paths design + the aggregate gate job; the connector
liveness-check grace period (the VPN sidecar handshake lets the connector restart a few times before
it settles).
#### Found caveats (all resolved in Stage 17 — see *Refinements → Stage 17*)
The owner's collected caveats below were classified (fix-now / verify-then-fix / discuss),
discussed where they were forks, and resolved in one session with tests where practical. The
per-item outcomes are recorded under *Refinements logged during implementation → Stage 17*; the
raw list is kept here as the record of what the first contour run surfaced.
- /_gm/grafana/ требует повторного ввода пароля basic auth, хотя до этого я уже зашёл в /_gm/
Такого быть не должно: графана живёт под /_gm/ и ей не нужен свой auth.
- нужна ещё метрика "продолжительность хода" - сколько игроки тратят на каждый ход,
скорее всего, понадобится новое поле last_move_ts если ещё нет, так же нужно будет завести
метрику в графане как общую, так и и по конкретному пользователю (можно ли? дорого ли?),
а так же с привязкой к номеру хода и без номера хода. Всё это понадобится для анализа
способностей игроков, чтобы подогнать под них роботоа. А так же - выявлять читеров.
- регистрация пользователя из телеграм (как и других коннекторов):
пытаться очистить имя от посторонних символов, аналогично проверке при вводе имени в профиле.
если после очистки ничего не осталось, поставить имя Player/Игрок-XXXXX (5 рандомных цифр),
язык в зависимости от внешнего коннектора.
- game - chat - nudge. Когда мой ход и я жму nudge, появляется сообщение "сейчас не ваш ход".
Думаю, опечатка - "не" лишняя, проверь на всех языках.
- если открыли игру через telegram, надо в настройках вообще полностью скрыть переключатель темы "авто/светлая/темная",
т.к. тему задаёт сам телеграм (уточни, в какой проперти её можно забрать, и нужно ли, сейчас оно уже нормально работает
на самих стилях)
- возможно, к предыдущему пункту: запускаю мини апп на macos/telegram desktop. в самой macos у меня темная тема.
когда я включаю тему "авто" в настройках mini app, а в самом телеграме - светлую, всё ломается, nav bar и tab bar
рисуются темным фоном, список игр и меню - светлым, поле игры - тёмное, вокруг него светлоая рамка.
Провернул тот же трюк на ios - всё чётко, в режиме "авто" он полностью держит ту настройку, которая в
самом телеграме задана. Проверь, можно ли это починить для desktop-версии тг, скорее всего там
системные настройки как-то в браузер протекают. Ну если не получится понять причину, тогда и черт с ним.
- не знаю, ошибка это или by design - если у меня открыта игра сразу в desktop telegram и на ios,
то когда я делаю ход, в другом окне не обновляется ничего - ни само игровое поле, ни лобби.
интересно, как ходят уведомления через gateway - по последнему активному push-каналу, что ли?
если так, стоит ли чинить, чтобы у пользователя все пуш-каналы поддерживались или это дорого?
нужен твой анализ и совет.
- надо подкрутить тайминг автоматического хода работа. идея такая: сейчас, насколько я помню, время хода
выбирается от 2 до 90 минут с перекосом ближе к 2 минутам (поправь если что). я предлагаю этот интервал
сделать динамическим в зависимости от хода. Например, средяя партия это 25-30 ходов, предположительно.
На первом ходу интервал должен быть 1..5 минут, на последнем - 10..90 минут, всё так же с перекосом в меньшую сторону.
А то я сейчас поиграл, роботы на первых ходах по 15 минут думают.
Сможешь такую хитрую формулу составить? Цифры ориентировочные. Потом после набора реальной статистики подкрутим цифры.
Заодно напомни, как работает формула "перекоса", можно ли её "заставить" косить почаще в меньшую сторону, как бы имитируя
активного игрока. Этот пункт требует тщательного обсуждения, пожалуй.
- при навигации между лобби и игрой есть задержка едва заметная на глаз, думаю, связанная с тем, что UI все данные по игре перезапрашивает
каждый раз. Кроме этого, когда я в лобби возвращаюсь, глаз ловит перерисовку экрана, довольно быстро, но есть какое-то
неприятное ощущение, что туда что-то подгружается. А мы можем внутри UI наполнять кэш этими данными и экраны не рисовать
каждый раз, а просто подменять? не знаю, как это работает, если честно. Но вот информацию по игре, в которую пользователь
проваливался 1 раз, совершенно точно можно положить в кэш и обновлять его когда с сервера приходит новый ход и т.п.
- при запуске в telegram, надо бы цвет фона nav bar сделать фоном телеграма, а то он "выпадает" из общего дизайна.
- а вот фон рекламной строчки под nav bar наоборот, сделать бы чуть светлее (в тёмной теме) или темнее (в светлой),
чтобы был акцентирован, но не ярко. что-то там есть в стилях телеграма такое готовое?
ну и для собственного дефолтного стиля тоже надо выбрать соответствующие.
- Переключаюсь в ios в другое приложение, по возвращении ловлю "проблема соединения, повторяем".
Вроде бы в телеграм-бандле есть обработчики всяких событий, в том числе background in/out, или как там оно зовётся.
Посмотри, можно ли что-то с этим сделать? Если да, то именно в случаях когда приложение уходит в фон - не надо рисовать
плашку с ошибкой, просто молча пытаться соединиться, то есть плашка появится когда приложение на в фоне на следующем retry.
- при использовании подсказки в игре ато зум ведёт в лево-верх, а не туда, где была поставлена подсказка.
- В русских партиях нужны русские имена для роботов, но можно вперемешку с латинскими именами, только чтобы латинских имён
было не больше 20%.
- Сделать анимацию переходов между экранами: наезд справа если из лобби куда-то переходим и наоборот, уезжание вправо и открытие лобби, когда нажимаем back в навигации.
- Цвет и размер плашки с игроками над доской: давай сделаем не "кнопками" самих игроков, а просто поделим это пространство
поровну между игроками, а активного игрока будем показывать за счёт "поднятия" его плашки, за счёт теней слева и справа, чтобы
остальные игроки были как бы "утоплены" внутрь.
- В игре клик/тач по плашке с именами игроков открывает/закрывает историю.
- В истории ходов странное выравнивание колонки со словами, они буквально скачут влево-вправо.
- В многословных партиях надо в истории показывать основное слово + дополнительное (если это ещё не сделано, надо проверить)
- При открытии истории нижнюю границу таблицы ("тень") сразу прибивать к доске, а не растягивать вслед за таблицей.
- Баг. Открыл игру через ru-телеграм бота, пытаюсь сделать "new -> русский" (это скрэбл с русским алфавитом), появляется красная плашка
"что-то пошло не так". при этом "new -> эрудит" работает. Попробуй посмотреть в логах сейчас, может что-то есть. Или как-то иначе проанализируй, или давай вместе будем смотреть, если не получится.
### Stage 18 — Prod contour deploy
Scope: the **production contour** on a remote host over SSH. Deploy by **container export/import**
(`docker save``scp`/ssh → `docker load``docker compose up` on the remote), the SSH key + host IP
in Gitea secrets; **strictly manual** (`workflow_dispatch`) after `development` is merged to `master`
(the Stage 16 branch model: `feature/* → development → master`, merge gated green). Two-contour config
uses **`TEST_`/`PROD_` secret/variable prefixes** — Gitea 1.26 has no deployment environments (verified:
the `environments` API 404s), so a flat prefixed namespace is the convention.
Reuses the Stage 16 `deploy/docker-compose.yml` as-is, mapping the **`PROD_`** set onto the same
unprefixed compose vars. **No host caddy on prod**, so the contour's own caddy terminates TLS — set
`CADDY_SITE_ADDRESS` to the prod domain so caddy does its own ACME (the Caddyfile is already
parameterised for this; the test contour leaves it `:80` behind the host caddy).
Open details (re-interview): export/import vs a registry trade-off; prod domain/cert source (ACME vs a
provided cert) at the contour caddy; prod VPN; rollback.
## Refinements logged during implementation
@@ -682,39 +892,629 @@ Open details: deployment target/host; dashboards; load expectations.
- **Stage 10 forward-note**: the admin surface will wire `connector.SendToUser`/
`SendToGameChannel` (backend gains its own connector client) for operator
broadcasts to a user and the game channel.
- **Verification-time fixes** (caught by the CI gate): (1) the gateway transcode
dropped `notifications_in_app_only` in four places (`ProfileResp`, `encodeProfile`,
`profileUpdateHandler`, the `UpdateProfile` body) so the toggle never reached the
backend — fixed, with a round-trip transcode test added. (2) The e2e suite was made
**hermetic** (a shared `ui/e2e/fixtures.ts` blocks the real `telegram-web-app.js`):
the render-blocking CDN `<script>` hung every page load on the CI runner, where
telegram.org is unreachable, timing out all non-Telegram specs. (3) A pre-existing
time-of-day flake in `TestTimeoutSweep` (the default 00:0007:00 away window made
the sweeper skip when CI ran with `now-1h` inside it) was made deterministic by
clearing the test account's away window.
- **Stage 10** (interview + implementation):
- **Admin console = backend-rendered `/_gm`, gateway Basic-Auth** (interview, two
rounds): the owner chose a dedicated web console but, pointing at `../galaxy-game`
and asking to keep it simple, the deliverable is **server-rendered Go
`html/template` + one embedded CSS** (`backend/internal/adminconsole`: a
framework-agnostic renderer + page view-models, `//go:embed` templates/assets, zero
JS, no build step), **not** a SPA. It lives **in the backend** on its own route
`/_gm/*`; the **gateway** (the project's built-in reverse proxy) gates `/_gm/*` with
the existing `GATEWAY_ADMIN_USER/PASSWORD` Basic-Auth on its **public** listener and
proxies **verbatim** to backend `/_gm/*` (mounted on the edge mux below the h2c wrap
so Connect keeps working). This **supersedes Stage 6's** gateway-fronts-
`/api/v1/admin` model: the separate admin port `GATEWAY_ADMIN_ADDR` is dropped (only
the port — user/password stay), the backend `/api/v1/admin` group + `ping` are
removed, and `gateway/internal/admin` is repurposed to the verbatim proxy. The
backend keeps **no operator identity** and no `admin_accounts` table; CSRF on the
console's POSTs is a **same-origin** check (`Origin`/`Referer` vs `Host`, the gateway
preserves the inbound Host) — no token. Discharges Stage 1's "admin bootstrap" (it is
config, not a DB seed).
- **Complaint resolution + dictionary pipeline** (interview): migration **00008**
(+ jetgen) adds `disposition`/`resolution_note`/`resolved_at`/`applied_in_version`
to `complaints` and the deferred `status` CHECK (`open|resolved`) — **discharges
Stage 3's** deferral (no `resolved_by`: operator identity is not tracked). Resolution
sets a disposition (`reject`/`accept_add`/`accept_remove`); accepted complaints are
**derived by query** into a pending dictionary-change list (no new table), stamped
`applied_in_version` once a rebuilt version is loaded. New `game` reads
`ListComplaints`/`GetComplaint`/`CountComplaints`/`ResolveComplaint`/
`DictionaryChanges`/`MarkChangesApplied`; admin list/count reads
`account.ListAccounts/CountAccounts/Identities` and `game.ListGames/CountGames/
GameByID`.
- **Dictionary hot-reload = per-version subdir** (interview): the launch version stays
in the flat `BACKEND_DICT_DIR` (CI/dev untouched); a reloaded version `X` loads from
`BACKEND_DICT_DIR/X/` via the new `Registry.LoadAvailable` (present variants only),
and boot re-loads every subdirectory via `engine.OpenWithVersions` so reloaded
versions survive a restart. **Partially addresses TODO-2** (the runtime reload
contract; the offline DAWG generator stays future work).
- **Operator broadcasts** (discharges Stage 9's forward-note): the backend gains its
own connector gRPC client (`backend/internal/connector`, `BACKEND_CONNECTOR_ADDR`,
nil when unset) over the existing `pkg/proto/telegram/v1`; the console messages a
user by `account_id` (backend resolves the Telegram `external_id`) and posts to the
game channel via `SendToUser`/`SendToGameChannel`.
- **Config/CI**: backend adds `BACKEND_CONNECTOR_ADDR`; gateway drops
`GATEWAY_ADMIN_ADDR` (keeps user/password). No new module and no fbs/proto/UI codegen
(the console is server-rendered Go). The Go workflows already span
`./backend/... ./gateway/... ./pkg/...`; integration stays `./backend/...`.
- **Stage 11** (interview + implementation):
- **Scope = link-via-confirm + merge for email and Telegram** (interview): the
current account is the merge **primary**; a linked identity that already has its
own account is merged into the current one and the secondary is retired as an
**audit tombstone** (`accounts.merged_into`/`merged_at`, migration `00009`
+ jetgen). Linkable this stage: **email** (the existing confirm-code) and
**Telegram via the Login Widget** (the web sign-in). New `internal/accountmerge`
(the single-transaction data merge) and `internal/link` (the orchestrator over
account + accountmerge + session).
- **Tombstone, not delete** (interview): the secondary row is kept so a **shared
finished game**'s no-cascade `game_players`/`chat`/`complaints` foreign keys stay
valid; its seat in such a game is left in place. The merge is **refused**
(`ErrActiveGameConflict`) only when the two share an **active** game.
- **Merge algorithm** (one tx): stats summed (wins/losses/draws) + max kept;
`hint_balance` summed; identities repointed; non-shared `game_players` transferred
(shared kept); `chat_messages`/`complaints` reassigned; friendships/blocks repointed
with self-edge drop and dedupe (friendships by status precedence
accepted>pending>declined); invitations: secondary's as inviter deleted, invitee
rows deduped; secondary's `email_confirmations`/`friend_codes` dropped; secondary
tombstoned. Sessions are handled one layer up: `session.Service.RevokeAllForAccount`
(+ `Cache.RemoveByAccount`) retires the secondary's sessions after the tx.
- **Primary direction + guest inversion** (interview): primary = the current account,
**except** when the initiator is a **guest** and the linked identity already has a
**durable** owner — then the **durable account wins**, the guest's active games
transfer into it, the guest is retired, and a **fresh session for the durable
account is minted and returned** (the client adopts it). Binding a **free** identity
to a guest is a plain upgrade (clear `is_guest`, same session). Discharges Stage 8's
"guest email-binding is Stage 11".
- **API/UX = dedicated ops; reveal only after the code** (interview): new edge ops
`link.email.request/confirm/merge` (Email-rate-limited) and
`link.telegram.confirm/merge`. `request` **always** mails a code (no pre-send
"taken" signal, so a probe cannot enumerate registered addresses); a required merge
is revealed **only after** the code is verified, gating an explicit irreversible
merge step (the Profile screen's confirmation dialog). This **supersedes Stage 8's**
`email.bind.*` ops (and their fbs `EmailBindRequest`/`EmailConfirmRequest` tables),
which were retired from the gateway/UI for that reason; the backend
`EmailService.RequestCode`/`ConfirmCode` primitives stay (still covered by inttest).
- **Field policy** (interview): `display_name` = primary's; profile prefs/flags
(language, timezone, away window, block toggles, `notifications_in_app_only`) =
primary's; `hint_balance` = **sum**. A new service column **`paid_account`**
(`bool`, default false; lifetime one-time-payment marker, no purchase flow yet) is
added in `00009` and **ORed** on merge (`true` always wins). It is not user-editable
and is shown read-only on the admin account-detail page.
- **Telegram Login Widget** (interview, owner chose the broader scope): the connector
validates it (`internal/loginwidget`, secret = `SHA-256(bot_token)`, distinct from
initData) via a new `Telegram.ValidateLoginWidget` RPC; the gateway validates the
widget payload and passes the **trusted** `external_id` to the backend link route
(same trust model as `auth.telegram`). The UI offers "Link Telegram" only in a plain
web context (`loginWidgetAvailable`), driving the popup `Telegram.Login.auth`; it is
**inert in production until BotFather `/setdomain`** registers the site domain and
`VITE_TELEGRAM_BOT_ID` is configured (a deploy concern, Stage 12). e2e mocks the
widget (telegram.org is blocked on CI).
- **Wire/CI**: new fbs `LinkEmailRequest`/`LinkEmailConfirm`/`LinkTelegramRequest`/
`LinkResult` (committed Go + TS); new proto RPC (committed Go); new REST routes under
`/api/v1/user/link/*`. The Go workflows already span `./backend/... ./gateway/...
./pkg/... ./platform/telegram/...`; integration stays `./backend/...`. UI ~90 KB gzip
JS (budget 100 KB). New error code `merge_active_game_conflict`.
- **Stage 12** (interview + implementation):
- **Re-scoped & split** (interview): the original "Polish (observability + perf +
deploy)" was too large for one session, so it was split — **Stage 12** = observability
+ performance + guest GC; **Stage 13** = alphabet-on-the-wire (TODO-4); **Stage 14** =
CI & deploy (TODO-1, TODO-2, the collector + dashboards). The latter two were written
into the plan now as the agreed baseline (each still re-interviews at its own start).
(Stage 14 was itself later re-scoped to the solver/dictionary split alone; deploy +
observability + the dual-bot idea split into Stages 1518.)
- **Shared telemetry** (interview): a new `pkg/telemetry` owns the OTel provider
bootstrap (exporter selection, W3C propagators, shutdown, Go runtime metrics); the
backend `internal/telemetry` is now a thin facade over it (keeping its gin middleware),
and the gateway and connector gained telemetry runtimes. A configurable **`otlp`**
exporter was added alongside `none`/`stdout`; the **default stays `none`**, the OTLP
endpoint comes from the standard `OTEL_EXPORTER_OTLP_*` env, and the collector +
dashboards are Stage 15 (so CI needs none). `otelgrpc` instruments the backend push
server, the gateway's backend + connector clients, and the connector's gRPC server.
New config `GATEWAY_SERVICE_NAME`/`GATEWAY_OTEL_*` and `TELEGRAM_SERVICE_NAME`/
`TELEGRAM_OTEL_*`; the backend's existing `BACKEND_OTEL_*` gained the `otlp` value.
- **Metrics = operational, business-near** (interview): histograms
`game_replay_duration` and `game_move_validate_duration`; counters
`games_started_total`, `games_abandoned_total` (a turn-timeout seat drop) and
`chat_messages_total` (`kind`=message/nudge); an observable gauge `game_cache_active`;
the gateway `edge_request_duration` (`message_type`/`result`); plus Go runtime/heap
metrics. Game-scoped metrics carry a **`variant`** attribute
(english/russian_scrabble/erudit — chosen over a coarser `language`, which it
subsumes); the gateway edge metric is variant-agnostic. Optional wiring uses the
established `SetMetrics`/`SetNotifier` setter pattern (default no-op meter), so existing
constructors and tests are untouched. **No speculative optimisation** — there is no
measured hotspot; the deliverable is the instrumentation (the standing "performance only
with evidence" rule). pprof was not added (reframed away by the owner).
- **Guest GC** (interview, TODO-3): age-based, no-seat-only — see the discharged TODO-3
below; new config `BACKEND_GUEST_REAP_INTERVAL`/`BACKEND_GUEST_RETENTION`.
- **Deps/CI**: new OTel modules (the OTLP exporters,
`contrib/instrumentation/runtime`, `otelgrpc`) added with the no-tidy pattern
(`go mod edit` + `go mod download` + `go work sync`; `pkg` carries no bare-path dep, so
it tidies cleanly). No workflow change — the Go workflows already span
`./backend/... ./gateway/... ./pkg/... ./platform/telegram/...`, integration stays
`./backend/...`, and the default `none` exporter keeps CI collector-free.
- **Stage 13** (interview + implementation, discharges TODO-4):
- **Scope = live play only** (interview): indices ride the wire for `StateView.rack`
(out) and `SubmitPlay`/`Evaluate`/`Exchange`/`CheckWord` (in). The **board path is
untouched** — `MoveRecord` (history, move results, hint), formed `words`,
`ComplaintRequest.word` (durable, admin-reviewed) and `WordCheckResult.word` (echo) stay
decoded concrete characters, so the durable journal / GCG and the §9.1 invariant are
unchanged. **Hard cutover**, no dual letter/index fields (single client; the fbs Go + TS
regenerate in one PR; no external wire consumer). Exchange moved fully to indices, a
blank = the shared sentinel index **255** (`engine.BlankIndex`).
- **Edge-mapping layering** (engineering): the engine gained a cached per-variant codec —
`AlphabetTable` (the `(index, letter, value)` table from the solver ruleset),
`LetterForIndex`, `EncodeRack`, `DecodeTiles`, `DecodeWord` — and the backend **server
edge** owns the index↔letter mapping. `game.Service`'s domain methods, `engine.Game` and
the **robot** keep a single **letter-based** play path (untouched); a new thin
`game.Service.GameVariant` (a single-column `SELECT variant`, cheaper than `GetGame`)
lets the inbound handlers resolve the variant without doubling the play-path read. The
**gateway carries no alphabet table** — it passes indices through verbatim; `check_word`
rides as repeated `?idx=` query params.
- **`include_alphabet` flag** (interview): `StateRequest.include_alphabet` gates the table
so it is not resent on every poll; the client sets it only on a **per-variant cache
miss** (first open of a variant), and the table then arrives with the index rack so the
rack is always decodable. The client caches the table in memory by variant
(`ui/src/lib/alphabet.ts`).
- **Letter case** (discovered): the solver emits **lower-case** letters and the rest of
the UI works in **upper case**. The wire and the journal stay lower case; the **UI
normalises display to upper case** (the codec upper-cases decoded board tiles and words,
and the alphabet cache upper-cases on ingest), so `placement.ts` / `board.ts` /
`checkword.ts` are unchanged and the latent real-backend lower-case display is fixed.
- **Parity rework** (interview): the real value/alphabet parity moved to a **Go engine
test** (`engine.AlphabetTable`: EN/RU/Эрудит sizes, EN a=1/q=10, **Эрудит ё=index 6,
value 0**); `ui/src/lib/premiums.ts` is now **geometry only** (its value tables,
`tileValue` and `alphabet` were removed, its parity test trimmed to the premium grid);
the codec test round-trips the index tiles + the alphabet table; the **mock keeps a
fixture table** (relocated from `premiums.ts`) seeded into the client cache, so the
mock-driven UI is alphabet-agnostic too.
- **Wire/codegen/CI**: new fbs `AlphabetEntry` + `PlayTile`; `StateView.rack`→`[ubyte]` +
`alphabet`; `StateRequest.include_alphabet`; `SubmitPlay`/`Eval` tiles→`[PlayTile]`;
`Exchange` tiles→`[ubyte]`; `CheckWord.word`→`[ubyte]` (committed Go + TS regenerated).
UI ~90 KB gzip JS (budget 100 KB). **No CI workflow change** — the Go workflows already
span `./backend/... ./gateway/... ./pkg/...` and the UI workflow runs check/unit/build +
a chromium/webkit e2e. `docs/FUNCTIONAL.md` is **untouched** (no user-visible behaviour
change — the UI looks and plays the same; like Stage 2). The index-drift caveat is
handled by construction (the running backend produces the table, so client↔server cannot
drift); the DAWG/solver build-time agreement remains **Stage 14 / TODO-2**.
- **Stage 14** (interview + implementation, re-scoped + discharges TODO-1/TODO-2):
- **Re-scoped to the split** (interview): the original "CI & deploy" was several sessions of work,
so it was cut to the **solver/dictionary split** (the dependency foundation) and the deploy +
observability + the dual-bot idea were written into the plan as new **Stages 1518**. The deploy
decisions taken at the interview are recorded there (embed the UI in the gateway via `go:embed`;
full Collector+Prometheus+Tempo+Grafana stack; **two contours** — test = auto on feature-branch
push on the local host, prod = manual SSH `docker save`/`load` after merge; `TEST_`/`PROD_` secret
prefixes since Gitea 1.26 has no environments — verified).
- **TODO-1 — publish solver** (interview: "опубликовать и запинить"): `scrabble-solver` renamed to
module `gitea.iliadenisov.ru/developer/scrabble-solver`, `internal/{wordlist,dictdawg}`
**de-internalised** to public packages (so the dict repo imports one builder — no drift), the build
pipeline (`cmd/builddict`, `dictprep`, the `dictionaries` submodule) moved out, `internal/dict`
repointed at the committed `dawg/*.dawg` fixtures, tagged **v1.0.0**. scrabble-game pins it in
`backend/go.mod`, drops the `go.work` replace + the CI clone, and sets `GOPRIVATE=gitea.iliadenisov.ru/*`
(go fetches the module directly from Gitea — verified end-to-end). The solver hash lives in
`go.work.sum` (workspace mode; the bare-path `scrabble/pkg` replace still blocks `go mod tidy`).
- **TODO-2 — dictionary repo** (interview: "полный TODO-2, новый репо"): `developer/scrabble-dictionary`
builds the three DAWGs against the published solver + pinned `dafsa`/`alphabet` v1.1.0,
**byte-identical** to the solver fixtures; published as the release artifact
`scrabble-dawg-v1.0.0.tar.gz`; both Go workflows download it for `BACKEND_DICT_DIR` instead of
cloning the solver. English source vendored from `kamilmielnik/scrabble-dictionaries`; the Эрудит
fold is committed as `dictprep/russian/erudit.txt`, so the build needs no `python`.
- **Bootstrap nuances** (encountered): the dict repo was created empty with a protected `master`, so
it was seeded once via an owner-authorised protection lift→push→restore (a subsequent CI-fix push
correctly went through a PR, not another lift); it was made **public** (like the solver) so the Go
workflows fetch the artifact anonymously. Its CI is a **build-only** validation gate — the
auto-release step's `${{ github.* }}` contexts failed the Gitea workflow compile, so releases are
published manually for now (a logged follow-up).
- **Stage 15** (interview + implementation):
- **Re-framed service-agnostic** (interview): the owner kept the two-bots-in-one-container model but
generalised the language signal — the sign-in service returns a **set** of supported game languages
(subset of `{en, ru}`, ≥ 1) on the validate response, and the **UI gates** the New Game variant choice
by it. Two distinct scopes, deliberately not conflated: the **gating set** is per-session (rides the
`Session` fbs, never persisted — so the same `telegram_id` logged in through the en- and ru-bot gates
differently, which is correct), and the **routing language** is per-account.
- **Push routing resolved** (interview, the original "which bot delivers" open detail): only the
**user-facing `Notify`** carries the `en`/`ru` language from the user's **last `ValidateInitData`**,
persisted as `accounts.service_language` (migration `00010`, written every login — new and existing —
last-login-wins, read by `/internal/push-target` with a `preferred_language` fallback). It is NOT the
game's variant language. **Correction mid-interview:** the admin broadcasts `SendToUser` /
`SendToGameChannel` are admin-panel-only and unrelated to `ValidateInitData`; they pick the bot by an
**operator-chosen** language (a console `<select>`), so a `language` field was added to those two RPCs
sourced from the form, not from `service_language`.
- **Gating = UI-only, creation-only** (interview): the backend does not enforce (a valid game is
harmless, not a trust boundary); only the New Game pickers (auto-match + friend invite) filter — there
is no variant picker on accept/open/play, so those are inherently ungated. Non-Telegram logins
(web/email/guest) carry the gateway default set (`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, default all).
- **Wire/connector**: `ValidateInitDataResponse` gained `service_language` + `supported_languages`; the
fbs `Session` gained `supported_languages:[string]`; `SendToUser`/`SendToGameChannel` gained
`language` (committed Go + TS regenerated via `make -C pkg gen` + `pnpm -C ui codegen`). The connector
config moved to **per-language** bots (`TELEGRAM_BOT_TOKEN_EN/_RU`, `TELEGRAM_GAME_CHANNEL_ID_EN/_RU`;
`TELEGRAM_MINIAPP_URL` shared; ≥ 1 token required — a breaking config change, no prod yet); the
server hosts a bot map and routes by language. The push template language now follows the routing bot
(was `preferred_language`) — a documented change. The deploy compose/Dockerfile env was updated to the
per-language vars (the full deploy stack is Stage 16). No CI workflow change (the Go and UI workflows
already span the touched modules).
- **Stage 16** (interview + implementation):
- **Branch model reshaped** (interview, supersedes the Stage 0 `feature/* → master`): a long-lived
**`development`** integration branch + **`master`** as the prod trunk. Feature branches are cut from
`development`; a feature-branch commit triggers nothing. A single consolidated
`.gitea/workflows/ci.yaml` (Gitea has no cross-workflow `needs`) runs `unit`+`integration`+`ui` on a PR
into `development`/`master` and a **gated `deploy`** job (`needs` the three) that auto-rolls the test
contour **on a PR into — or a push to — `development`** (owner's "и PR, и push"). A PR into `master` is
test-only; prod is the manual Stage 18. The former `go-unit`/`integration`/`ui-test` workflows were
folded in (no path filters — full CI on every PR, per the owner). Console kept plain (`NO_COLOR`,
`docker compose --ansi never`, `--progress plain`).
- **Gateway serves the UI** (interview, the §13 single-origin): a new `gateway/internal/webui` embeds
`dist` via `go:embed` (a committed placeholder index so `go build`/CI compile without a UI build) and
serves the SPA at `/` and `/telegram/` (a path-stripping SPA handler, index.html fallback for the hash
router), mounted in the edge mux **below** the h2c wrap; `/_gm` stays an explicit 404 when the local
admin proxy is off so the catch-all does not leak the shell. The `gateway/Dockerfile` node stage builds
the UI with the `VITE_*` build-args and copies it into the embed dir before `go build`.
- **Images** (interview): multi-stage distroless `backend/Dockerfile` (a DAWG stage `curl`s the
`scrabble-dawg` release pinned to `DICT_VERSION`, `GOPRIVATE` fetches the solver) and `gateway/Dockerfile`
(node UI stage + Go stage), both trimming `go.work` like `platform/telegram/Dockerfile`. Built and
verified locally.
- **Contour = caddy-fronted** (interview, "caddy всё равно нужен для https"): a new `caddy` service owns
a **single `/_gm` Basic-Auth** and routes `/_gm/grafana/*` → Grafana (anonymous-admin + sub-path, no
own accounts) and the rest of `/_gm/*` → the backend console; everything else → the gateway. This
**supersedes Stage 10's** gateway-fronts-`/_gm` model **in the deploy topology** (the gateway's own
`/_gm` proxy stays for a local non-caddy run). TLS: the **host caddy** terminates it for the test
contour and forwards to `scrabble:80`; the in-compose caddy is parameterised (`CADDY_SITE_ADDRESS`) to
own ACME on prod (Stage 18) where there is no host caddy.
- **Networks** (engineering): inter-service traffic on a private `internal` network (project-scoped DNS,
no name collisions on the shared `edge`); only caddy joins the external `edge` (alias `scrabble`). The
connector keeps its VPN sidecar (the only egress that needs the tunnel). The connector-scoped
`platform/telegram/deploy/docker-compose.yml` was **retired** (the root `deploy/docker-compose.yml`
supersedes it; the connector Dockerfile stays).
- **Observability stack** (interview): OTel Collector (OTLP/gRPC → a Prometheus scrape endpoint +
Tempo OTLP) + Prometheus (**15d**) + Tempo (**72h**) + Grafana (provisioned Prometheus+Tempo datasources
+ four dashboards: Service overview, Edge/UX, Game domain, Users; Traces via the Tempo datasource +
Explore, no fixed panels). The collector's prometheus exporter uses `add_metric_suffixes:false` +
`resource_to_telemetry_conversion` so the dashboards' PromQL matches the in-code metric names and carries
`service_name`. The three services export `otlp` in the contour (default stays `none`, so CI needs no
collector). Loki/logs were left out of scope (container stdout / zap JSON).
- **User metrics** (interview): a backend `accounts_created_total{kind}` counter (telegram/email/guest;
robots excluded — they are a provisioned pool, not users) via the Stage-12 `SetMetrics` no-op pattern,
and a gateway **in-memory** `active_users{window=24h,7d}` observable gauge (distinct authenticated edge
actors). The owner chose the in-memory gauge over a DB `last_seen_at` (overkill); its single-instance /
reset-on-restart limits are documented (a live gauge, not billing).
- **Owner actions before the contour is green** (surfaced, not blockers): set the **`TEST_`** Gitea
secrets/variables (see `deploy/.env.example`) and add a host-caddy route `<test domain> → scrabble:80`
on the runner host. CI bootstrap nuance: the first PR introducing `ci.yaml` may first deploy on the
post-merge push to `development` (depending on whether Gitea runs head/base workflows for a PR), after
which PR-time deploys work.
- **Telegram test environment** (post-deploy fix): the connector now selects Telegram's test env with the
library's native `tgbot.UseTestEnvironment()` (was a `token += "/test"` hack — functionally identical,
verified, but the option is idiomatic and now has a `bot` test asserting the `/bot<token>/test/getMe`
path). The test contour **pins `TELEGRAM_TEST_ENV=true` in `ci.yaml`** (the contour is the test
environment) rather than via a `TEST_`-prefixed variable — removing a confusing double-`TEST` operator
knob and the secret-vs-variable footgun; prod (Stage 18) leaves it `false`.
- **Stage 17** (interview + implementation): the test-contour verification pass. The owner's
collected caveats were classified (fix-now / verify-then-fix / discuss) and resolved in one session.
- **Russian Scrabble fixed** (#6): the UI sent the variant id `russian` while the backend's canonical
string (and `StateView`) is `russian_scrabble`, so `lobby.enqueue`/invite returned 400 (confirmed in
the contour logs). The UI was aligned to `russian_scrabble` (the `Variant` type, `variants.ts`,
`Lobby.svelte`, mock fixtures, premium/alphabet keys, tests); the backend label is unchanged
(persisted games, GCG and the `variant` metric attribute keep it).
- **Nudge message** (#3): `social.ErrNudgeOnOwnTurn` shared the `not_your_turn` result code with
`game.ErrNotYourTurn`, so nudging on your own turn read "it is not your turn" — backwards. A distinct
`nudge_own_turn` code + i18n message was added, and the UI disables the nudge control on your own turn.
- **Connector name sanitization** (#2): `account.ProvisionTelegram` now cleans the platform name to the
editable display-name format (`sanitizeDisplayName`) and falls back to `Player`/`Игрок-NNNNN` (by
language) when nothing remains. A new `account.ProvisionRobot` lets system robot names bypass editor
validation (e.g. "Peter J.").
- **Robot names** (#5, interview): per-language composed pools — 32 full + 32 colloquial first names
paired by index, plus a surname pool (gender-agreed for Russian) rendered in three forms (first only /
first + surname initial / first + full surname), composed deterministically per pool slot (stable
across restarts). `Pick(variant)` is variant-aware: a Russian game draws Russian names with ≤ ~20%
Latin, an English game the Latin pool. Robot identities are keyed `robot-<lang>-<index>`.
- **Robot timing** (#4, interview): the fixed `2 + 88·u^3.5` move delay became move-number-aware — the
band interpolates from [3,10] min at the first move (raised from [1,5] in round 4, #14) to [10,90] min
by ~28 moves, right-skewed by k=4, so early moves are quick and the endgame can be long. A daytime
nudge pulls the reply toward the move's lower band.
- **Multi-device push** (#7, interview): `emitMove` no longer skips the acting seat, so the mover's own
other devices (and their lobby) refresh. `opponent_moved` stays in-app only (no out-of-app push to the
actor), and the gateway already fans each event out to all of a user's live streams.
- **Move-duration analytics** (#1, interview): a live `game_move_duration{variant,phase}` histogram
(opening/middle/endgame) + a Grafana panel, plus offline per-user analytics in the admin console —
min/avg/max columns in the user list and an inline-SVG chart of think-time by the player's move number,
computed from the journal (`game_moves.created_at` deltas; no schema change). Per-user stays offline,
not a Prometheus label, to avoid cardinality blow-up; the live histogram aggregates all seats (robots
included), so the per-human admin view is authoritative.
- **CI** (#9/#10, interview): `unit`/`integration`/`ui` are path-conditional behind a `changes` job; an
always-running `gate` job aggregates them (success-or-skipped) and is the single branch-protection
required check (`CI / gate`), so a skipped job never blocks a merge. The deploy job gained a
Telegram-connector liveness probe (`docker inspect`: running, not restarting, stable restart count,
with a VPN-handshake grace period) — closing the Stage 16 blind spot where a crash-looping connector
was invisible to the gateway-only probe.
- **UI theming / UX**: inside Telegram the colour scheme is forced from `WebApp.colorScheme` over the OS
`prefers-color-scheme` (fixes the Telegram Desktop breakage, #12) and the theme switcher is hidden
(#11); the nav bar takes Telegram's bg and the announcement banner a subtle `--ad-bg` accent (#14/#15);
the reconnect banner is suppressed while backgrounded and the stream reconnects on return (#16); hint
zoom scrolls to the placement (#17); the players plaque raises the active seat and sinks the others
with a tap toggling history (#19/#20); history fixes the word-column jitter and pins its bottom shadow
to the board (#21/#23); directional screen-slide transitions (#18a); a per-game in-memory cache renders
instantly on re-entry and refreshes in the background (#13).
- **Grafana repeated password (#8) — not a server defect**: verified live that caddy challenges `/_gm`
and `/_gm/grafana` with one identical realm and Grafana serves anonymously, so the repeated prompt is a
browser Basic-Auth scoping quirk (likely Safari/Desktop), not infra — left for the owner to re-verify,
no server change. **Multi-word history (#22)** was already implemented (all formed words shown).
- **Contour-verification follow-ups** (rounds 23, from live testing): the Grafana
double-password was its **Live WebSocket** tripping caddy Basic-Auth — Grafana Live is
disabled (`GF_LIVE_MAX_CONNECTIONS=0`) and the admin console links to Grafana; the
move-duration panel was invisible because the deploy reseed (`rm -rf`) left the
config-only services on a stale bind mount — the deploy now **force-recreates**
caddy/otelcol/prometheus/tempo/grafana; the **per-user rate limit** was raised 120/40 →
300/80 and the UI no longer reloads on the echo of its own move; the iOS/Telegram
reconnect banner gained a resume **grace window** (visibilitychange + pageshow/pagehide
+ Telegram `activated`/`deactivated`); **Telegram Mini Apps polish** was adopted —
chrome colours (`setHeaderColor`/`setBackgroundColor`/`setBottomBarColor`), native
**BackButton**, **HapticFeedback**, **closing confirmation** in a game,
**disableVerticalSwipes**; the players-plaque highlight was inverted so the active seat
pops; the make-move popover became a direct **✅** with a tab-bar **↩️ Reset**; the hint
button disables at zero hints; plus **board-only vertical scroll** (#9) and a
**keyboard-overlay** check-word dialog (#10).
- **Contour-verification follow-ups** (round 4, from live testing): the robot early-move band was raised
[1,5] → [3,10] min so openings are less hasty (#14); the **profile** is edited inline (the Edit/Cancel
toggle is gone, the form is always shown for durable accounts, hint balance stays read-only) and a
single trailing "." is allowed in display names (#5/#6); a pending tile now recalls by **double-tap**
or by **dragging it back onto the rack** (single-tap recall removed), and **holding a dragged tile over
a cell ~1 s auto-zooms** there (#3/#10); **🔀 Shuffle is animated** — tiles hop along a low parabola to
their new slots, duration scaled by distance, with a haptic shake (#9); a **lines-off board** Settings
toggle (default off) renders a gapless checkerboard with rounded, right-shadowed tiles, reclaiming
~14px of width (#12). **Deferred (discussed):** board **pinch zoom** (#2) — it fights both the native
scroll and the new one-finger drag-back, so it stays dropped in favour of double-tap + hover-hold zoom;
**robot win% in the admin game detail** (#16) — needs the seed-derived play-to-win decision exposed
across the game/robot package boundary, to be picked up when that seam is added.
- **Contour-verification follow-ups** (round 5, from live testing): **resign on the opponent's turn**
now works — the engine gained `ResignSeat(seat)` and `game.Resign` bypasses the turn check to forfeit
the actor's own seat (it no longer returned "not your turn"); **quick-match cancel** was a UI no-op
(only stopped polling) — added the full path (REST `/lobby/cancel` → gateway → client) and clear the
matchmaker's pending result on cancel, so a cancelled search is dequeued (no "already queued", no later
robot-substituted game); **lobby win/loss** ranked by score, so a 0-0 resignation read as a win —
`result.ts` now places the viewer below the winner (rank ≥ 2), matching the game detail; a **friend
request to a robot** is accepted as pending and expires like a human ignore (robots no longer set
`BlockFriendRequests`); the **nudge cooldown** error got its own `nudge_too_soon` code/message and the
chat button disables for the hour, with the note reworded to "Waiting for your move!". UI polish:
**even zoom** (interpolate the scroll toward a pre-clamped target as the real width grows/shrinks — no
lurch-and-snap) that **recentres only on the first zoom-in**; a drag **drop-target highlight**; **pinch
zoom** (two-finger, preventDefault only on two touches; a second finger aborts a drag); shuffle hop
capped at **0.3 s** and off under reduce-motion; a **borderless** make-move icon, disabled on a known-
illegal pending word; **variant display names** (english & russian_scrabble → Scrabble/Скрэббл, erudit
→ Erudite/Эрудит) shown on the create-game controls and the in-game title. **L2 done:** the admin game
card now shows each robot seat's per-game play-to-win **intent** + the ~40% target and, on the robot's
turn, its deterministic **next-move ETA**. *Open edge:* a bilingual New Game (both en+ru offered) would
show two "Scrabble" buttons — left as the owner's chosen naming; disambiguate if it bites.
- **Contour-verification follow-ups** (round 6, from live testing) — **shipped & deployed:** profile drops
the hint-balance line; no mobile tap-flash on a board cell (`-webkit-tap-highlight-color`); variant
display names keyed by the game's **alphabet**, not the UI language (english → "Scrabble",
russian_scrabble → "Скрэббл", both unlocalized so they never collide; erudit localized), and the in-game
title shows the variant name; **chat & nudge are mutually exclusive by turn** (message field on your
turn, nudge on the opponent's, grey "awaiting reply" caption during the cooldown), with chat enforced
server-side to your own turn (`ErrChatNotYourTurn`); the **nudge cooldown resets** once the player has
moved or chatted since the last nudge (`game.LastMoveAt` + last chat vs last nudge; the UI mirrors it);
the **About** screen got localized titles + a rules link + the random/friends sections, and the app
**version comes from `git describe`** (Vite define `__APP_VERSION__` ← Docker build-arg in the deploy
step, default "dev"); the **quick-game buttons** became lobby-style plaques — name + flag (🇺🇸/🇷🇺 + a
bundled minimalist USSR-flag SVG) + a one-line rules summary (bag size, the ё rule, bonus differences
from the engine rulesets) + the 24h move-limit beneath; two follow-up fixes (pin the nudge right when
available; redraw the USSR emblem as a thin schematic hammer & sickle); **#3 drag-reorder of rack tiles**
with a visual gap (the dragged tile lifts out, the rest slide to open a slot; `reorderIndices`
unit-tested; only with no pending tiles); and the **persistence backend foundation** (#4/#5/#6): a
`game_drafts` table (migration 00011) + raw-SQL store/service (`GetDraft`/`SaveDraft`) that, on every
committed move, clears the actor's own draft and resets any opponent's board draft whose cell the play
overlapped — 5 integration tests.
- **Stage 17 round 6 — final pass (#4/#5/#6 + #1620), shipped:**
1. **Draft persistence — gateway slice + UI (#4/#5/#6, PR #20).** FB `DraftRequest{game_id, json}`
(save) + `DraftView{json}` (get reuses `GameActionRequest`); the client serializes
`{rack_order, board_tiles}` itself (no FB tile array), the gateway forwards it as `json.RawMessage`
both ways (no double-encode), and `GET`/`PUT /games/:id/draft` (a server `draftDTO` ↔ `game.Draft`)
is the only place that reads the shape. UI: debounced save of the rack order (#4) + board draft (#6)
and restore on load (`lib/draft.ts`, reconciling against the committed board); **#5** — tiles may be
arranged on the opponent's turn (placement relaxed; the preview and Make-move stay your-turn-only,
so an off-turn draft is position-only). Off-turn tiles keep the **existing pending highlight** — no
caption, no new style (owner's call). The backend draft endpoint is sub-ms.
2. **Landing + `/app/` move (#1620, this PR).** One Vite build with **two HTML entries** — the game
SPA (`index.html`) and a new lightweight landing (`landing.html` → `Landing.svelte`, reusing the
theme/i18n/`aboutContent` leaf modules, not the app store, so it stays small). The gateway serves the
**landing at `/`** and the **game SPA at `/app/` and `/telegram/`** (`webui.Handler(stripPrefix,
indexName)`); relative base keeps one build serving every mount with a shared `dist/assets/` (the
planned per-target `base` conditional proved unnecessary). **Correction to the original note:** the
Telegram **Mini App stays at `/telegram/`** — only the plain web app moved off `/` to `/app/`, so
BotFather is untouched. The landing's "Play in Telegram" link is **per-language** via two new build
vars `VITE_TELEGRAM_LINK_EN` / `VITE_TELEGRAM_LINK_RU` (test/prod bots differ → no hardcoding; the
button hides when unset). Logo copied `.claude/telegram-logo.svg` → `ui/public/` (source stays
untracked).
- **Edge robustness (folded into the landing PR).** (a) **Static cache headers** — the embedded
`http.FileServer` over `go:embed` has a zero modtime, so it emitted no validators → the client
re-downloaded the whole bundle every launch; now hash-named `/assets/*` are `immutable` (a relaunch
is a cache hit) and the HTML shells are `no-cache`. (b) **Live-stream 15 s abort** — the `Subscribe`
heartbeat only fired after the first 15 s tick, so the stream sat silent and raced a ~15 s edge idle
timeout (constant reconnects in the caddy log); now an **immediate heartbeat on open** + a **10 s**
default interval. Both surfaced while diagnosing a reported "slow load in Telegram" that was actually
the owner's **external network** (the server is sub-ms end-to-end) — not a regression.
- **Landing follow-up (owner review pass):** reworked from the first cut — the per-language Telegram
link var renamed `VITE_TELEGRAM_LINK_EN/_RU` → **`VITE_TELEGRAM_GAME_CHANNEL_NAME_EN/_RU`** (it carries
a channel **username**, the landing builds `https://t.me/<name>`; the connector keeps the matching
`..._CHANNEL_ID_..` to post). Switchers became icons — a 🌐 language dropdown (saved, synced to the app)
and a ☼/☾ theme toggle that is **ephemeral** (follows the system scheme, never persisted, no "auto").
The "Play in browser" CTA was dropped (no standalone-web onboarding yet).
- **Game/Telegram review-pass polish:** the USSR flag emblem redrawn (canonical hammer & sickle,
scaled down ×1.5 below the star, the star itself +25%); touch drag enlarges the drag ghost ×1.5
(touch only — the finger hides the tile) and suppresses the iOS tap-highlight that lingered on a
rack tile sliding into a dragged tile's slot; a placed tile can be **dragged to another board
cell** (it lifts off its origin for the drag, and `touch-action:none` lets the drag win over the
board pan when zoomed) and the manual-select ring clears when a tile is recalled; and **Telegram
fullscreen** no longer hides our header under its native nav — the whole header drops below the
content-safe-area top inset (title and the right-aligned menu both clear the nav), via
`--tg-content-top` from the SDK + a `tg-fullscreen` class. (Telegram's Mini App SDK exposes no way
to set the native nav-bar title, move its buttons, or add items to its "⋯" menu, so we keep our
own header and simply push it clear.)
- **Lobby sort + in-game friend state (review pass, PR C):** the **my-games** lobby now groups games
into *your turn* / *opponent's turn* / *finished* (empty sections hidden) and orders them by last
activity — your-turn oldest-first (the longest-waiting on top), the other two newest-first — in a
compact, line-separated list (the owner's density pick over bordered cards). `gameDTO` / FB
`GameView` gained `last_activity_unix` (the turn start while active, the finish time once
finished). The in-game **"add to friends"** item is now **server-derived** (new `GET
/user/friends/outgoing` + `friends.outgoing` op, returning the addressees already requested —
pending **or** declined, which both read as "request sent") so it is correct across reloads, shows
a disabled **"✓ in friends"** once accepted, and **live-updates** when the opponent answers:
`RespondFriendRequest` now publishes `friend_added` (accept) / `friend_declined` (a new notify
sub-kind, decline) to the **original requester**, whose open game re-derives its friend state.
Owner decisions: a declined request stays "request sent" (non-revealing); an accepted opponent
reads "✓ in friends"; rack-tile reorder while tiles are placed stays disabled by design.
- **Admin "Messages" moderation section (#18, PR D):** a new `/_gm/messages` console page lists
posted chat messages (**nudges excluded**) newest-first — time · **source** (guest / robot /
oldest identity kind) · sender (→ user card) · IP · body · game (→ game card) — searchable by
sender name / external-id glob masks and pinnable to one game (`?game=`) or sender (`?user=`),
linked from the game and user cards. Server-rendered (`adminconsole` `MessagesView` +
`messages.gohtml`, 50/page via the shared pager); the list query lives in `social` (raw SQL,
`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
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)
- **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable,
give it a real module URL and switch `backend` to a versioned dependency,
dropping the `go.work` replace and the CI clone. Removes the floating
`master` dependency accepted for now (Stage 2 interview).
- **TODO-2 — split the solver into engine vs dictionary generator + versioned
dictionary artifacts.** Owner's idea, with the caveats agreed at the Stage 2
interview: the split is sound (build-time wordlist→DAWG vs runtime load have
different lifecycles and shrink the runtime dependency surface), **but** the
generator must pin the **same** `dafsa`/`alphabet` versions and alphabet
definitions as the runtime engine or the on-disk format / letter indexing
drifts and silently corrupts validation. For delivery prefer **Git LFS or an
artifact store** (Gitea releases / OCI artifact / object storage) over a raw
git submodule (the ~0.50.7 MB DAWGs are regenerated wholesale and bloat git
history); pin by tag/hash for a reproducible startup set. A submodule/LFS pull
is a **deploy-time** way to populate the directory, **not** the runtime
dynamic-reload mechanism (Stage 10) — keep the `BACKEND_DICT_DIR` directory as
the runtime contract: a new `.dawg` appears in it and is loaded with
`dawg.Load`.
- **TODO-3 — garbage-collect abandoned guest accounts.** Stage 6 makes a guest a
durable `accounts` row (no identity, `is_guest`), so an ephemeral guest leaves a
row behind. Add a periodic reaper (or a finished-and-idle sweep) that deletes
guest accounts with no active games once their last session is gone; the
`ON DELETE CASCADE` foreign keys clean up the dependent rows.
- **TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7).** Today the
client hardcodes each variant's letters/values (ported into `ui/src/lib/premiums.ts`
from `scrabble-solver/rules/rules.go`) and the edge exchanges plays/hints by concrete
letters. Consider extending `game.state` to carry the variant's `(letter, index,
value)` table so the UI stops duplicating it, and optionally moving tile exchange to
letter **indices** end-to-end. Caveat (as for the dictionaries, TODO-2): the wire table
must stay pinned to the same `rules.Alphabet` the engine uses, or indices drift.
- ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is
published as module `gitea.iliadenisov.ru/developer/scrabble-solver` (tagged `v1.0.0`, with
`wordlist`/`dictdawg` de-internalised to public packages); `backend/go.mod` pins it, the `go.work`
replace and the CI sibling-clone are gone, and `GOPRIVATE=gitea.iliadenisov.ru/*` fetches it directly
(no public proxy/checksum DB). Removes the floating `master` dependency accepted since Stage 2.
- ~~**TODO-2 — split the solver into engine vs dictionary generator + versioned dictionary
artifacts.**~~ **Done in Stage 14.** A new repo `developer/scrabble-dictionary` holds the word-list
sources + `cmd/builddict` (moved out of the solver, with `dictprep` and the `dictionaries` submodule)
and builds the three DAWGs against the **published solver + pinned `dafsa`/`alphabet` v1.1.0** — the
output is **byte-identical** to the solver's committed fixtures, so the index-drift caveat is handled
by construction. Delivered as a Gitea **release artifact** `scrabble-dawg-vX.Y.Z.tar.gz` (not
`go get`; DAWGs are data; **one semver label for the whole set**); the Go workflows download it for
`BACKEND_DICT_DIR`. The runtime dynamic-reload contract (per-version `BACKEND_DICT_DIR/<version>/` via
`Registry.LoadAvailable` / `engine.OpenWithVersions`, Stage 10) is unchanged — a deploy drops a new
set into the directory; a version is safe to retire once no active game pins it.
- ~~**TODO-3 — garbage-collect abandoned guest accounts.**~~ **Done in Stage 12.**
A periodic `account.GuestReaper` deletes guests (`is_guest`) **with no game seat at
all** whose account age exceeds `BACKEND_GUEST_RETENTION` (default 30 d, swept every
`BACKEND_GUEST_REAP_INTERVAL`, default 1 h). Two schema facts shaped this, narrowing
the original sketch: (1) `game_players`/`chat_messages`/`complaints` reference accounts
**without** `ON DELETE CASCADE`, and a finished game belongs to the other players'
history, so a guest with any seat is retained (a delete would be blocked anyway) — hence
"no seat", not "no active game"; (2) sessions are revoke-only with no maintained
`last_seen_at`, so a lingering session never expires and **account age** is the
abandonment trigger, not "last session gone". The reaped guest's `sessions`/`identities`/
`account_stats` fall away via their own `ON DELETE CASCADE`.
- ~~**TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7).**~~ **Done in
Stage 13.** The client is now alphabet-agnostic: it caches each variant's `(index, letter,
value)` table — sent by the backend behind `StateRequest.include_alphabet` on a per-variant
cache miss — and live play exchanges **letter indices** both ways (rack, submit-play,
evaluate, exchange, word-check; a blank rides as the sentinel index 255). The table is
produced from the solver ruleset (`engine.AlphabetTable`), so it is pinned by the solver
version and cannot drift from the running backend, and `ui/src/lib/premiums.ts` is now
geometry only. The durable journal / history / GCG stay decoded concrete characters (§9.1,
unchanged). The DAWG/solver build-time agreement (the original caveat, shared with TODO-2) was
discharged in Stage 14: the dict repo builds against the published solver + pinned
`dafsa`/`alphabet`, byte-identical to the fixtures.
- **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9:*
the deep-link scheme now exists (`f<code>`, shared Go ↔ TS), the bot redeems it on
launch, and the UI shows a **share-to-Telegram** link for an issued code when
+415
View File
@@ -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 35 concurrent 24-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`.
+22 -2
View File
@@ -8,14 +8,13 @@ supports English Scrabble, Russian Scrabble and Эрудит.
- **`gateway`** — the only public ingress: anti-abuse, platform authentication
(resolves the player and injects `X-User-ID`), routing to `backend`, and an
admin surface behind Basic Auth. *(added in a later stage)*
admin surface behind Basic Auth.
- **`backend`** — internal-only service that owns every domain concern and
embeds the [`scrabble-solver`](../scrabble-solver) engine library in-process.
- **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite) over Connect-RPC
+ FlatBuffers, embeddable in platform webviews and packageable to native via
Capacitor. See [`ui/README.md`](ui/README.md).
- **`platform/*`** — per-platform side-services (e.g. the Telegram bot).
*(added in a later stage)*
## Documentation (sources of truth)
@@ -80,3 +79,24 @@ pnpm dev # against a running gateway (Vite proxies the RPC path to :8081)
`pnpm check` (type-check), `pnpm test:unit` (Vitest), `pnpm test:e2e` (Playwright
smoke vs the mock), `pnpm build` (static bundle). Details — including the committed
edge codegen (`pnpm codegen`) — are in [`ui/README.md`](ui/README.md).
## Deploy (`deploy/`)
The full contour is [`deploy/docker-compose.yml`](deploy/docker-compose.yml):
`backend` + `gateway` (with the UI embedded via `go:embed`, baked in by its node
build stage) + Postgres + the Telegram connector (with a VPN sidecar) + an
observability stack (OTel Collector → Prometheus + Tempo → Grafana) + a front
**caddy** that owns a single `/_gm` Basic-Auth (admin console + Grafana). The Go
services build from multi-stage distroless `*/Dockerfile`.
```sh
docker build -f backend/Dockerfile -t scrabble-backend . # pulls the DAWG release artifact
docker build -f gateway/Dockerfile -t scrabble-gateway . # node stage builds + embeds the UI
docker compose -f deploy/docker-compose.yml config # validate (needs the TEST_/PROD_ env)
```
CI auto-deploys the **test contour** on a PR into — or push to — `development`
(`.gitea/workflows/ci.yaml`); the **prod contour** is a manual deploy after
`development → master`. Env reference: [`deploy/.env.example`](deploy/.env.example);
the topology and the two-contour model are in
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §13.
+43
View File
@@ -0,0 +1,43 @@
# Multi-stage build for the backend service. Mirrors platform/telegram/Dockerfile:
# a golang-alpine builder yields a static binary shipped on distroless nonroot.
#
# The dictionary DAWGs are baked in from the scrabble-dictionary release artifact
# — the same set the Go CI downloads — and BACKEND_DICT_DIR points the
# binary at them. The published solver module is fetched directly from Gitea
# (GOPRIVATE), so the build stage needs git and network.
#
# Build from the repository root so go.work, go.work.sum, pkg/ and backend/ are all
# in the Docker context:
# docker build -f backend/Dockerfile -t scrabble-backend .
# --- dictionary artifact -----------------------------------------------------
FROM alpine:3.20 AS dawg
ARG DICT_VERSION=v1.0.0
RUN apk add --no-cache curl tar
RUN mkdir -p /dawg \
&& curl -fsSL -o /tmp/dawg.tar.gz \
"https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz" \
&& tar xzf /tmp/dawg.tar.gz -C /dawg
# --- build -------------------------------------------------------------------
FROM golang:1.26.3-alpine AS build
WORKDIR /src
# git: the published solver module is fetched from Gitea directly (GOPRIVATE).
RUN apk add --no-cache git
ENV GOPRIVATE=gitea.iliadenisov.ru/*
COPY go.work go.work.sum ./
COPY pkg ./pkg
COPY backend ./backend
# Reduce the workspace to what the backend needs: backend + pkg. loadtest and the
# gateway replace it requires are not in this context, so drop both.
RUN go work edit -dropuse=./gateway -dropuse=./platform/telegram -dropuse=./loadtest -dropreplace=scrabble/gateway@v0.0.0
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/backend ./backend/cmd/backend
# --- runtime -----------------------------------------------------------------
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/backend /usr/local/bin/backend
COPY --from=dawg /dawg /opt/dawg
ENV BACKEND_DICT_DIR=/opt/dawg
ENTRYPOINT ["/usr/local/bin/backend"]
+105 -58
View File
@@ -1,24 +1,24 @@
# backend
Internal-only domain service for the Scrabble platform (module `scrabble/backend`).
It owns identity/sessions, accounts, and — in later stages — the lobby, game
runtime, robot, chat, history and administration. Its only network consumers are
the `gateway` and the platform side-services; it is never exposed publicly.
It owns identity/sessions, accounts, the lobby, game runtime, robot, chat, history
and administration. Its only network consumers are the `gateway` and the platform
side-services; it is never exposed publicly.
As of Stage 1 the backend provides the foundation: configuration, the HTTP
listener with the `/api/v1` route-group skeleton and probes, the Postgres pool
with embedded goose migrations, OpenTelemetry wiring, an in-memory session cache,
and the durable accounts / identities / sessions data model. The session and
account REST endpoints are added with the `gateway` (Stage 6); Stage 1 ships the
store/service layer they will call.
The backend provides the foundation: configuration, the HTTP listener with the
`/api/v1` route-group skeleton and probes, the Postgres pool with embedded goose
migrations, OpenTelemetry wiring, an in-memory session cache, and the durable
accounts / identities / sessions data model. The session and account REST
endpoints live in the `gateway`; the backend ships the store/service layer they
call.
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
rules `Game` (legal plays, passes, exchanges, resignations and end-condition
detection) that emits dictionary-independent move records. It is a library only;
the game domain wires it into the process 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
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
@@ -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
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
Stages 12 it is a service/store layer; the HTTP surface lands with the
`gateway` (Stage 6).
the engine it is a service/store layer; the HTTP surface lives in the `gateway`.
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
friend-game invitations (invite → accept, starting a 24 player game once every
invitee accepts). `internal/social` owns the friend graph (request/accept),
@@ -41,40 +40,72 @@ development log mailer). The engine now also handles **multi-player drop-out**:
a 34 player game a resignation or timeout drops that seat and the rest play on
(the tile disposition is a per-game setting), the game ending when one active seat
remains. As before this is a service/store layer — chat and nudges are persisted
but their live delivery, and all REST endpoints, arrive with the `gateway`
(Stage 6); the services are exposed via `Server` accessors for those handlers.
but their live delivery, and all REST endpoints, live in the `gateway`; the
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
requests blocked — backs a human-like name pool. 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
`internal/engine` imports the solver): it decides once per game whether to play to
win (≈ 40%), targets a small score margin, and times its moves with a right-skewed
delay, a night-sleep window anchored to the opponent's timezone, and nudge
win (≈ 40%), targets a small score margin, and times its moves with a move-number-aware
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
behaviour — all derived deterministically from the game seed, so it keeps no extra
state. The matchmaker now substitutes a pooled robot after a 10-second wait and
exposes `Poll` so a waiting player can collect the started game (the live
match-found notification arrives with the `gateway`).
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 — it is the **stream-down
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
`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a
slice of authenticated `/api/v1/user` operations (profile, submit play, game
state, lobby enqueue/poll, chat). Stage 8 fills in the social/account/history
operations under `/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
state, lobby enqueue/poll, chat). The social/account/history operations under
`/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`,
`stats`, and `games/:id/gcg` (finished-only). 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
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
Telegram `external_id`, language and `notifications_in_app_only` flag) that the gateway
uses to route out-of-app push to the Telegram connector, extends the Telegram login to
seed a new account's language and display name from the launch fields, and adds
migration `00007` (`accounts.notifications_in_app_only`, default true).
Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row
with no identity, excluded from statistics. The shared wire contracts live in the
sibling [`../pkg`](../pkg) module.
gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's
Telegram `external_id`, language and `notifications_in_app_only` flag) lets the gateway
route out-of-app push to the Telegram connector; the Telegram login
seeds a new account's language and display name from the launch fields, and the
`accounts.notifications_in_app_only` flag (default true).
`accounts.is_guest` marks an ephemeral guest a durable row
with no identity, excluded from statistics. The server-rendered
**admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`;
the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the
**complaint resolution** lifecycle (the `complaints` `disposition`/`resolution_note`/
`resolved_at`/`applied_in_version` columns + the `status` CHECK) feeding a dictionary-change
pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR/<version>/`
(`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a
backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each
broadcast picks the delivering bot by an operator-chosen language. `accounts.service_language`
holds the language tag of the bot a Telegram
user last signed in through, written on every login and returned by
`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes
to the right bot. The shared wire contracts live in the sibling [`../pkg`](../pkg) module.
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is
attached to the current account, and when the identity already has its own account
the two are merged in one transaction (`internal/accountmerge`) — stats and the hint
wallet summed, `paid_account` ORed, identities/games/chat/complaints transferred,
friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (so a
shared finished game's foreign keys hold); a shared **active** game blocks the merge.
The current account is primary, except a guest initiator whose linked identity has a
durable owner — then the durable account wins and a fresh session is minted for it.
The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the
former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
Rate-limit observability: the gateway posts its periodic rejection
summaries to `POST /api/v1/internal/ratelimit/report`; `internal/ratewatch` keeps a
bounded in-memory episode window for the console's **Throttled** page and applies the
conservative auto-flag — an account sustaining `BACKEND_HIGHRATE_FLAG_THRESHOLD`
rejected calls within `BACKEND_HIGHRATE_FLAG_WINDOW` gets the soft, reversible
`accounts.flagged_high_rate_at` marker (set-once; a badge in the user list and a
**Clear** action on the user card; never an automatic ban).
## Package layout
@@ -86,14 +117,19 @@ internal/telemetry/ # OpenTelemetry providers + per-request timing middleware
internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations
migrations/ # embedded *.sql (goose), schema `backend`
jet/ # generated go-jet models + table builders (committed)
internal/account/ # durable accounts + platform/email identities (store)
internal/session/ # opaque tokens, sessions store, write-through cache, service
internal/account/ # durable accounts + platform/email identities (store) + email/identity link primitives
internal/accountmerge/ # single-transaction merge of a secondary account into a primary
internal/link/ # link/merge orchestrator over account + accountmerge + session
internal/session/ # opaque tokens, sessions store, write-through cache, service (incl. RevokeAllForAccount)
internal/server/ # gin engine, route groups, X-User-ID middleware, probes
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper
internal/social/ # friend graph, per-user blocks, per-game chat + nudge, content filter
internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + friend-game invitations
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
internal/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)
@@ -109,8 +145,8 @@ internal/robot/ # human-like robot opponent: account pool, seed-derived str
| `BACKEND_POSTGRES_CONN_MAX_LIFETIME` | `30m` | Max connection lifetime. |
| `BACKEND_POSTGRES_OPERATION_TIMEOUT` | `5s` | Connect attempt + `/readyz` ping bound. |
| `BACKEND_SERVICE_NAME` | `scrabble-backend` | OpenTelemetry `service.name`. |
| `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none` or `stdout` (OTLP arrives later). |
| `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none` or `stdout`. |
| `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from the standard `OTEL_EXPORTER_OTLP_*`). |
| `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp`. |
| `BACKEND_DICT_DIR` | — | **Required.** Directory of committed `.dawg` dictionaries. |
| `BACKEND_DICT_VERSION` | `v1` | Dictionary version new games pin. |
| `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. |
@@ -123,13 +159,21 @@ internal/robot/ # human-like robot opponent: account pool, seed-derived str
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. |
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. |
| `BACKEND_CONNECTOR_ADDR` | — | Telegram connector gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
| `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged. |
| `BACKEND_HIGHRATE_FLAG_WINDOW` | `10m` | The rolling window those rejections accumulate over. |
## Run
```sh
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
# DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg):
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.0.0/scrabble-dawg-v1.0.0.tar.gz | tar xz -C /tmp/dawg
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
BACKEND_DICT_DIR=../../scrabble-solver/dawg \
BACKEND_DICT_DIR=/tmp/dawg \
GOPRIVATE='gitea.iliadenisov.ru/*' \
go run ./cmd/backend
```
@@ -143,7 +187,10 @@ warmed.
## Migrations & generated code
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):
```sh
@@ -152,19 +199,17 @@ go run ./cmd/jetgen # rewrites internal/postgres/jet against a temp containe
## Engine & dictionaries
`internal/engine` consumes the sibling `scrabble-solver` module in-process. Its
bare module path (`scrabble-solver`, not a URL) cannot be fetched via VCS, so the
workspace `go.work` carries `replace scrabble-solver => ../scrabble-solver` and
the build must run from the repository root (the workspace), not from this module
in isolation. `github.com/iliadenisov/dafsa` (the DAWG loader) is a direct
dependency. CI clones the public solver repository into `../scrabble-solver`
before building (see `.gitea/workflows/`); locally, check it out next to this
repository. Committed dictionaries (`en_sowpods.dawg`, `ru_scrabble.dawg`,
`ru_erudit.dawg`) live in the solver's `dawg/` directory; the engine loads them
by `(variant, dict_version)` from a directory path. Since Stage 3 the backend
loads them at startup from the **required** `BACKEND_DICT_DIR` (a missing
dictionary aborts the boot); the future versioned-artifact direction is recorded
in [`../PLAN.md`](../PLAN.md) TODO-2.
`internal/engine` consumes `scrabble-solver` in-process as a **published, versioned
module** (`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `go.mod`). Set
`GOPRIVATE=gitea.iliadenisov.ru/*` so go fetches it directly from this Gitea (skipping
the public proxy/checksum DB); no sibling checkout or `go.work` replace is needed (for
local solver co-development you may add a temporary replace — see `go.work`).
`github.com/iliadenisov/dafsa` (the DAWG loader) is a direct dependency. The dictionaries
(`en_sowpods.dawg`, `ru_scrabble.dawg`, `ru_erudit.dawg`) ship as a **release artifact**
from the [`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary)
repo (one semver per set); the engine loads them by `(variant, dict_version)` from
`BACKEND_DICT_DIR`. The backend loads them at startup as a hard dependency
(a missing dictionary aborts the boot).
## Tests
@@ -175,6 +220,8 @@ go test -tags=integration -count=1 -p=1 ./... # Postgres-backed (needs Docker)
Integration tests are guarded by the `integration` build tag and run against a
throwaway `postgres:17-alpine` container; they fail loudly when Docker is absent
rather than skipping. The `internal/engine` tests load the committed DAWGs from
`BACKEND_DICT_DIR` (defaulting to the sibling `../scrabble-solver/dawg`) and fail
loudly when that directory is absent.
rather than skipping. The `internal/engine` tests load the DAWGs from
`BACKEND_DICT_DIR` (CI sets it to the extracted dictionary release artifact; locally it
defaults to a `scrabble-solver/dawg` sibling checkout) and fail loudly when that directory
is absent. `GOPRIVATE=gitea.iliadenisov.ru/*` is needed for go to fetch the pinned solver
module.
+50 -5
View File
@@ -18,13 +18,17 @@ import (
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountmerge"
"scrabble/backend/internal/config"
"scrabble/backend/internal/connector"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/link"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/pushgrpc"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/server"
"scrabble/backend/internal/session"
@@ -77,6 +81,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Warn("telemetry shutdown", zap.Error(err))
}
}()
if err := tel.StartRuntimeMetrics(); err != nil {
logger.Warn("telemetry: start runtime metrics", zap.Error(err))
}
db, err := postgres.Open(ctx, cfg.Postgres,
postgres.WithTracerProvider(tel.TracerProvider()),
@@ -92,7 +99,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
}
logger.Info("database migrations applied")
registry, err := engine.Open(cfg.Game.DictDir, cfg.Game.DictVersion)
registry, err := engine.OpenWithVersions(cfg.Game.DictDir, cfg.Game.DictVersion)
if err != nil {
return fmt.Errorf("load dictionaries: %w", err)
}
@@ -101,6 +108,19 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
zap.String("dir", cfg.Game.DictDir),
zap.String("version", cfg.Game.DictVersion))
// Admin console: an optional backend client to the Telegram connector
// side-service for operator broadcasts. Unset (BACKEND_CONNECTOR_ADDR empty)
// leaves broadcasts disabled — the console shows a "not configured" notice.
var conn *connector.Client
if cfg.ConnectorAddr != "" {
conn, err = connector.New(cfg.ConnectorAddr)
if err != nil {
return fmt.Errorf("dial connector: %w", err)
}
defer func() { _ = conn.Close() }()
logger.Info("connector client ready", zap.String("addr", cfg.ConnectorAddr))
}
sessions := session.NewService(session.NewStore(db), session.NewCache())
if err := sessions.Warm(ctx); err != nil {
return fmt.Errorf("warm session cache: %w", err)
@@ -113,21 +133,34 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
hub := notify.NewHub(0)
accounts := account.NewStore(db)
accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
games.SetNotifier(hub)
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
logger.Info("game turn-timeout sweeper started",
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
// Stage 4 lobby & social domains. Their REST and stream surface is added with
// the gateway in Stage 6, so they are handed to the server (like the route
// groups) for the handlers to come.
// Reap abandoned guest accounts (no game seat, account age past
// the retention window). Dependent rows fall away via ON DELETE CASCADE.
guestReaper := account.NewGuestReaper(accounts, cfg.GuestRetention, logger)
go guestReaper.Run(ctx, cfg.GuestReapInterval)
logger.Info("guest reaper started",
zap.Duration("interval", cfg.GuestReapInterval),
zap.Duration("retention", cfg.GuestRetention))
// Lobby & social domains. Their REST and stream surface lives in the gateway,
// so they are handed to the server (like the route groups) for the handlers.
mailer := newMailer(cfg.SMTP, logger)
emails := account.NewEmailService(accounts, mailer)
// Account linking & merge: the orchestrator over the account, merge and
// session layers. Wired to the /api/v1/user/link REST surface below.
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
socialSvc := social.NewService(social.NewStore(db), accounts, games)
socialSvc.SetNotifier(hub)
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
// 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
// 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)
@@ -144,6 +177,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
invitations.SetNotifier(hub)
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{
Logger: logger,
DB: db,
@@ -156,6 +196,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
Matchmaker: matchmaker,
Invitations: invitations,
Emails: emails,
Links: links,
Registry: registry,
DictDir: cfg.Game.DictDir,
Connector: conn,
RateWatch: rateWatch,
})
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
+2 -1
View File
@@ -3,6 +3,7 @@ module scrabble/backend
go 1.26.3
require (
gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0
github.com/XSAM/otelsql v0.42.0
github.com/gin-gonic/gin v1.12.0
github.com/go-jet/jet/v2 v2.14.1
@@ -20,7 +21,6 @@ require (
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
go.uber.org/zap v1.27.1
scrabble-solver v0.0.0-00010101000000-000000000000
)
require (
@@ -101,6 +101,7 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.22.0 // indirect
+205 -13
View File
@@ -12,7 +12,6 @@ import (
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
@@ -25,8 +24,8 @@ import (
// Identity kinds recognised by the backend. Email is modelled as an identity
// alongside platform identities; its confirmed flag is driven by the email
// confirm-code flow in a later stage. Robot is a synthetic kind: each pooled
// robot opponent is a durable account bound to one robot identity (Stage 5).
// confirm-code flow. Robot is a synthetic kind: each pooled
// robot opponent is a durable account bound to one robot identity.
const (
KindTelegram = "telegram"
KindEmail = "email"
@@ -56,25 +55,56 @@ type Account struct {
HintBalance int
BlockChat bool
BlockFriendRequests bool
// ServiceLanguage is the language tag (en/ru) of the bot the account last
// authenticated through (its last Telegram ValidateInitData); it routes the
// account's out-of-app push back through the right bot. Empty when the account
// has never signed in through a tagged bot. Distinct from PreferredLanguage (the
// interface language) and from a game's variant language.
ServiceLanguage string
// IsGuest marks an ephemeral guest account: a durable row with no identity,
// excluded from statistics, friends and history.
IsGuest bool
// NotificationsInAppOnly confines notifications to the in-app live stream when
// true (the default): the platform side-service skips out-of-app push for the
// account (Stage 9).
// account.
NotificationsInAppOnly bool
// PaidAccount marks a lifetime one-time-payment account. It is a service field
// (no purchase flow yet); an account linking & merge ORs it so a paid status is
// never lost when accounts are consolidated.
PaidAccount bool
// MergedInto is the primary account a retired (merged) secondary points at, or
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
// foreign keys of a shared finished game stay valid.
MergedInto uuid.UUID
// FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the
// zero time for an unflagged account, otherwise when the gateway-reported
// rate-limiter rejections first crossed the sustained threshold. An
// operator clears it in the admin console; it never gates any request.
FlaggedHighRateAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// Identity is one of an account's platform/email identities, surfaced on the
// admin account-detail view. ExternalID is the platform user id (or the email
// address for an email identity); Confirmed tracks the email confirm-code flow.
type Identity struct {
Kind string
ExternalID string
Confirmed bool
CreatedAt time.Time
}
// Store is the Postgres-backed query surface for accounts and identities.
type Store struct {
db *sql.DB
metrics *accountMetrics
}
// NewStore constructs a Store wrapping db.
// NewStore constructs a Store wrapping db. Metrics default to a no-op meter until
// SetMetrics installs the real one during startup wiring.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
return &Store{db: db, metrics: defaultAccountMetrics()}
}
// ProvisionByIdentity returns the account bound to (kind, externalID), creating
@@ -86,10 +116,43 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
return s.provision(ctx, kind, externalID, provisionSeed{})
}
// ProvisionRobot provisions (or finds) the durable account backing a robot pool
// member: a KindRobot identity carrying displayName, with chat blocked but friend
// requests NOT blocked — a request to a robot is accepted as pending and, since the
// robot never responds, simply expires (friendRequestTTL), exactly mirroring a human
// who ignores the request. Robot names are system-generated, not player-edited, so they
// bypass the editable display-name validation and may carry forms the editor rejects (an
// abbreviated surname like "Peter J."). It is idempotent: repeated calls converge the
// display name and both block flags.
func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName string) (Account, error) {
acc, err := s.provision(ctx, KindRobot, externalID, provisionSeed{displayName: displayName})
if err != nil {
return Account{}, err
}
if acc.DisplayName == displayName && acc.BlockChat && !acc.BlockFriendRequests {
return acc, nil
}
stmt := table.Accounts.UPDATE(
table.Accounts.DisplayName, table.Accounts.BlockChat,
table.Accounts.BlockFriendRequests, table.Accounts.UpdatedAt,
).SET(
postgres.String(displayName), postgres.Bool(true),
postgres.Bool(false), postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(acc.ID))).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
return Account{}, fmt.Errorf("account: provision robot %q: %w", externalID, err)
}
return modelToAccount(row), nil
}
// ProvisionTelegram provisions (or finds) the account bound to a Telegram
// identity. On first contact only, it seeds the new account's preferred language
// from the Telegram client languageCode (when it maps to a supported language) and
// its display name from firstName (falling back to username); an already-existing
// its display name sanitized from firstName (falling back to username, then to a
// generated placeholder when neither yields any letters); an already-existing
// account is returned unchanged, so a later profile edit is never overwritten.
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) {
return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
@@ -129,19 +192,21 @@ type provisionSeed struct {
// telegramSeed derives the create-time seed from Telegram launch fields: a
// supported preferred language from languageCode (an ISO-639 code, possibly
// region-tagged like "ru-RU"), and a display name from firstName or, failing that,
// username (capped to maxDisplayName runes).
// region-tagged like "ru-RU"), and a display name sanitized from firstName or,
// failing that, username (sanitizeDisplayName strips disallowed characters to the
// editable format). When neither yields any letters, it falls back to a generated
// placeholder in the seeded language (placeholderDisplayName).
func telegramSeed(languageCode, username, firstName string) provisionSeed {
var seed provisionSeed
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
seed.preferredLanguage = lang
}
name := strings.TrimSpace(firstName)
name := sanitizeDisplayName(firstName)
if name == "" {
name = strings.TrimSpace(username)
name = sanitizeDisplayName(username)
}
if utf8.RuneCountInString(name) > maxDisplayName {
name = string([]rune(name)[:maxDisplayName])
if name == "" {
name = placeholderDisplayName(seed.preferredLanguage)
}
seed.displayName = name
return seed
@@ -187,6 +252,54 @@ func (s *Store) IdentityExternalID(ctx context.Context, accountID uuid.UUID, kin
return row.ExternalID, nil
}
// Identities returns the account's platform/email identities, oldest first, for
// the admin account-detail view.
func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity, error) {
stmt := postgres.SELECT(table.Identities.AllColumns).
FROM(table.Identities).
WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))).
ORDER_BY(table.Identities.CreatedAt.ASC())
var rows []model.Identities
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("account: list identities %s: %w", accountID, err)
}
out := make([]Identity, 0, len(rows))
for _, r := range rows {
out = append(out, Identity{Kind: r.Kind, ExternalID: r.ExternalID, Confirmed: r.Confirmed, CreatedAt: r.CreatedAt})
}
return out, nil
}
// ListAccounts returns accounts for the admin user list, newest first, paginated
// by limit and offset.
func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) {
stmt := postgres.SELECT(table.Accounts.AllColumns).
FROM(table.Accounts).
ORDER_BY(table.Accounts.CreatedAt.DESC()).
LIMIT(int64(limit)).
OFFSET(int64(offset))
var rows []model.Accounts
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("account: list accounts: %w", err)
}
out := make([]Account, 0, len(rows))
for _, r := range rows {
out = append(out, modelToAccount(r))
}
return out, nil
}
// CountAccounts returns the total number of accounts, for admin-list pagination.
func (s *Store) CountAccounts(ctx context.Context) (int, error) {
stmt := postgres.SELECT(postgres.COUNT(table.Accounts.AccountID).AS("count")).
FROM(table.Accounts)
var dest struct{ Count int64 }
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
return 0, fmt.Errorf("account: count accounts: %w", err)
}
return int(dest.Count), nil
}
// findByIdentity joins identities to accounts and returns the matching account,
// or ErrNotFound.
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
@@ -259,6 +372,11 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
if err != nil {
return Account{}, fmt.Errorf("account: create for identity (%s, %s): %w", kind, externalID, err)
}
// Count genuinely new durable accounts; robots are a fixed provisioned pool,
// not users, so they are excluded.
if kind != KindRobot {
s.metrics.recordCreated(ctx, kind)
}
return created, nil
}
@@ -283,6 +401,7 @@ func (s *Store) ProvisionGuest(ctx context.Context) (Account, error) {
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
return Account{}, fmt.Errorf("account: provision guest: %w", err)
}
s.metrics.recordCreated(ctx, kindGuest)
return modelToAccount(row), nil
}
@@ -308,12 +427,82 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
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
// user authenticated through. It is called on every Telegram login — new and
// existing accounts — so it tracks the bot the user last came through (last-login-
// wins), and the out-of-app push routes by it. It is a no-op for an empty language
// (a non-Telegram login carries none) and does not bump updated_at (an infra
// routing field, not a user profile edit).
func (s *Store) SetServiceLanguage(ctx context.Context, id uuid.UUID, language string) error {
if language == "" {
return nil
}
stmt := table.Accounts.
UPDATE(table.Accounts.ServiceLanguage).
SET(postgres.String(language)).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id)))
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: set service language %s: %w", id, err)
}
return nil
}
// modelToAccount projects a generated model row into the public Account struct.
func modelToAccount(row model.Accounts) Account {
var mergedInto uuid.UUID
if row.MergedInto != nil {
mergedInto = *row.MergedInto
}
var serviceLanguage string
if row.ServiceLanguage != nil {
serviceLanguage = *row.ServiceLanguage
}
var flaggedHighRateAt time.Time
if row.FlaggedHighRateAt != nil {
flaggedHighRateAt = *row.FlaggedHighRateAt
}
return Account{
ID: row.AccountID,
DisplayName: row.DisplayName,
PreferredLanguage: row.PreferredLanguage,
ServiceLanguage: serviceLanguage,
TimeZone: row.TimeZone,
AwayStart: row.AwayStart,
AwayEnd: row.AwayEnd,
@@ -322,6 +511,9 @@ func modelToAccount(row model.Accounts) Account {
BlockFriendRequests: row.BlockFriendRequests,
IsGuest: row.IsGuest,
NotificationsInAppOnly: row.NotificationsInAppOnly,
PaidAccount: row.PaidAccount,
MergedInto: mergedInto,
FlaggedHighRateAt: flaggedHighRateAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
+4 -4
View File
@@ -33,7 +33,7 @@ var (
// ErrInvalidEmail is returned for an unparseable email address.
ErrInvalidEmail = errors.New("account: invalid email address")
// ErrEmailTaken is returned when the email is already confirmed by another
// account; binding it would be a merge, which 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")
// ErrAlreadyConfirmed is returned when the email is already confirmed by the
// requesting account.
@@ -52,8 +52,8 @@ var (
// Mailer and verifies it, binding a confirmed email identity to the requesting
// account. Only the SHA-256 hash of a code is stored (never the plaintext),
// matching the session model. Binding an email already confirmed by a different
// account is refused (ErrEmailTaken) — merging two accounts is Stage 11 — and
// using an email as a login is Stage 6, which reuses this mechanism.
// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
// and using an email as a login reuses this mechanism.
type EmailService struct {
store *Store
mailer Mailer
@@ -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,
// 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
// 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.
+145
View File
@@ -0,0 +1,145 @@
package account
import (
"context"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// ErrIdentityTaken is returned when a platform identity being linked already
// belongs to another account; the caller turns it into a merge.
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
// RequestLinkCode issues and mails a confirm-code for email to accountID,
// replacing any prior pending code. Unlike RequestCode it never refuses up front
// (taken or already-confirmed): possession of the address is the authorization for
// a later link or merge, and the merge is only revealed once the code is verified,
// so a probe cannot learn whether an address is registered.
func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error {
addr, err := normalizeEmail(email)
if err != nil {
return err
}
code, hash, err := generateCode()
if err != nil {
return err
}
if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
return err
}
subject := "Your Scrabble confirmation code"
body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
return s.mailer.Send(ctx, addr, subject, body)
}
// ConfirmLink verifies code for (accountID, email) and reports the address's
// current owner. When the address is free it binds a confirmed email identity to
// accountID and returns (accountID, true, nil). When accountID already owns it,
// it returns (accountID, true, nil) unchanged. When another account owns it, it
// returns (owner, false, nil) without consuming the code, so the explicit merge
// step can re-verify the same live code. It returns the usual confirm-code errors
// (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch).
func (s *EmailService) ConfirmLink(ctx context.Context, accountID uuid.UUID, email, code string) (uuid.UUID, bool, error) {
addr, err := normalizeEmail(email)
if err != nil {
return uuid.Nil, false, err
}
conf, err := s.verifyPendingCode(ctx, accountID, addr, code)
if err != nil {
return uuid.Nil, false, err
}
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
if err != nil {
return uuid.Nil, false, err
}
if ok {
if owner == accountID {
return accountID, true, nil
}
return owner, false, nil
}
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
return uuid.Nil, false, err
}
return accountID, true, nil
}
// verifyPendingCode loads and checks the pending confirm-code for (accountID,
// addr), counting a wrong attempt. It returns the confirmation on success.
func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUID, addr, code string) (emailConfirmation, error) {
conf, err := s.store.latestPendingConfirmation(ctx, accountID, addr)
if err != nil {
return emailConfirmation{}, err
}
if s.now().After(conf.expiresAt) {
return emailConfirmation{}, ErrCodeExpired
}
if conf.attempts >= emailCodeMaxAttempts {
return emailConfirmation{}, ErrTooManyAttempts
}
if hashCode(code) != conf.codeHash {
if err := s.store.bumpConfirmationAttempts(ctx, conf.id); err != nil {
return emailConfirmation{}, err
}
return emailConfirmation{}, ErrCodeMismatch
}
return conf, nil
}
// AccountIDByIdentity returns the account owning (kind, externalID) and true, or
// (uuid.Nil, false) when the identity is free. It backs the platform-identity link
// flow.
func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string) (uuid.UUID, bool, error) {
acc, err := s.findByIdentity(ctx, kind, externalID)
if errors.Is(err, ErrNotFound) {
return uuid.Nil, false, nil
}
if err != nil {
return uuid.Nil, false, err
}
return acc.ID, true, nil
}
// AttachIdentity links a new (kind, externalID) identity to an existing account.
// A unique-constraint violation means the identity was taken meanwhile, surfaced
// as ErrIdentityTaken. It is used to attach a platform identity (e.g. Telegram)
// to the current account during linking.
func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, externalID string, confirmed bool) error {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("account: new identity id: %w", err)
}
ins := table.Identities.INSERT(
table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind,
table.Identities.ExternalID, table.Identities.Confirmed,
).VALUES(id, accountID, kind, externalID, confirmed)
if _, err := ins.ExecContext(ctx, s.db); err != nil {
if isUniqueViolation(err) {
return ErrIdentityTaken
}
return fmt.Errorf("account: attach identity (%s, %s): %w", kind, externalID, err)
}
return nil
}
// ClearGuest removes the is_guest flag from accountID, promoting an ephemeral guest
// to a durable account once it gains its first identity. It is a no-op
// for an already-durable account.
func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt).
SET(postgres.Bool(false), postgres.TimestampzT(time.Now().UTC())).
WHERE(
table.Accounts.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Accounts.IsGuest.EQ(postgres.Bool(true))),
)
if _, err := upd.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: clear guest %s: %w", accountID, err)
}
return nil
}
+53
View File
@@ -0,0 +1,53 @@
package account
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/metric/noop"
)
// meterName scopes the account domain's OpenTelemetry instruments.
const meterName = "scrabble/backend/account"
// kindGuest labels guest accounts in accounts_created_total. Guests carry no
// identity, so they have no identity Kind; this is the metric label for them.
const kindGuest = "guest"
// accountMetrics holds the account domain's operational instruments. It defaults
// to no-ops (see defaultAccountMetrics); SetMetrics installs the real meter during
// startup wiring.
type accountMetrics struct {
created metric.Int64Counter
}
// defaultAccountMetrics returns instruments backed by a no-op meter.
func defaultAccountMetrics() *accountMetrics {
return newAccountMetrics(noop.NewMeterProvider().Meter(meterName))
}
// newAccountMetrics builds the instruments on meter, falling back to a no-op
// counter on the (rare) construction error.
func newAccountMetrics(meter metric.Meter) *accountMetrics {
c, err := meter.Int64Counter("accounts_created_total",
metric.WithDescription("New accounts created, labelled by kind (telegram/email/guest); robots are not counted."))
if err != nil {
c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("accounts_created_total")
}
return &accountMetrics{created: c}
}
// SetMetrics installs the meter the account store records to. It must be called
// during startup wiring; the default is a no-op meter.
func (s *Store) SetMetrics(meter metric.Meter) {
if meter == nil {
return
}
s.metrics = newAccountMetrics(meter)
}
// recordCreated counts one newly created account of the given kind.
func (m *accountMetrics) recordCreated(ctx context.Context, kind string) {
m.created.Add(ctx, 1, metric.WithAttributes(attribute.String("kind", kind)))
}
+54 -4
View File
@@ -4,9 +4,11 @@ import (
"context"
"errors"
"fmt"
"math/rand/v2"
"regexp"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/go-jet/jet/v2/postgres"
@@ -21,14 +23,20 @@ import (
// is unbounded; auto-provisioned platform names bypass this editor validation).
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).
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
// by a single space. No leading or trailing separator and no two adjacent separators,
// except "<dot|underscore> <space>". So "Name_P. Last" is valid, "Name P._Last" is not.
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*$`)
// by a single space. No leading separator and no two adjacent separators (except
// "<dot|underscore> <space>"); a single trailing "." is allowed, so
// "Name_P. Last" and "Anna B." are valid, "Name P._Last" is not.
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$`)
// ErrInvalidProfile is returned when a profile update carries an unacceptable
// field (an unknown language, an invalid timezone, or an over-long display name).
@@ -107,9 +115,51 @@ func ValidateDisplayName(raw string) (string, error) {
if !displayNameRe.MatchString(name) {
return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile)
}
specials := 0
for _, r := range name {
if r != ' ' && !unicode.IsLetter(r) {
specials++
}
}
if specials > maxDisplayNameSpecials {
return "", fmt.Errorf("%w: display name has more than %d special characters", ErrInvalidProfile, maxDisplayNameSpecials)
}
return name, nil
}
// sanitizeDisplayName best-effort cleans a platform-supplied name (e.g. a Telegram
// first name) to the editable display-name format: it keeps the maximal runs of
// Unicode letters and joins them with a single space, dropping every other rune
// (emoji, digits, punctuation), then caps the result to maxDisplayName runes. The
// result therefore always satisfies ValidateDisplayName, or is empty when the input
// carries no letters — in which case the caller substitutes placeholderDisplayName.
// Mirroring the profile editor's rule means a connector-provisioned name is editable
// later without first failing validation.
func sanitizeDisplayName(raw string) string {
fields := strings.FieldsFunc(raw, func(r rune) bool { return !unicode.IsLetter(r) })
if len(fields) == 0 {
return ""
}
name := strings.Join(fields, " ")
if utf8.RuneCountInString(name) > maxDisplayName {
name = strings.TrimRight(string([]rune(name)[:maxDisplayName]), " ")
}
return name
}
// placeholderDisplayName builds a fallback display name for a platform account whose
// supplied name had no usable letters: "Player-NNNNN" for lang "en" (the default) or
// "Игрок-NNNNN" for "ru", with five random digits. The generated name intentionally
// carries digits and a hyphen, so it lies outside the editable format and the player
// is expected to rename it; provisioned names bypass that editor validation.
func placeholderDisplayName(lang string) string {
prefix := "Player"
if lang == "ru" {
prefix = "Игрок"
}
return fmt.Sprintf("%s-%05d", prefix, rand.IntN(100000))
}
// validateAwayWindow checks that the daily away window's duration, wrapping across
// midnight, does not exceed maxAwayWindow. A zero-length window (start == end) means
// "no away time" and is allowed.
+1 -1
View File
@@ -12,7 +12,7 @@ import (
// TestUpdateProfileValidation checks that bad fields are rejected before any
// database access, so a nil-backed Store is enough to exercise the guards. It also
// confirms UpdateProfile wires the 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.
func TestUpdateProfileValidation(t *testing.T) {
s := &Store{}
+29 -2
View File
@@ -1,6 +1,7 @@
package account
import (
"regexp"
"strings"
"testing"
"unicode/utf8"
@@ -8,7 +9,8 @@ import (
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
// create-time account seed: supported-language detection (bare and region-tagged),
// the first-name / username display-name precedence, and trimming.
// the first-name / username display-name precedence, and the sanitization that
// strips disallowed characters (emoji, digits, punctuation) to the editable format.
func TestTelegramSeed(t *testing.T) {
cases := map[string]struct {
languageCode, username, firstName string
@@ -21,8 +23,11 @@ func TestTelegramSeed(t *testing.T) {
"empty language": {"", "neo", "Neo", "", "Neo"},
"first name wins": {"en", "handle", "Real Name", "en", "Real Name"},
"username fallback": {"en", "handle", "", "en", "handle"},
"both empty": {"en", "", "", "en", ""},
"trimmed": {" RU ", " ", " Anna ", "ru", "Anna"},
"emoji stripped": {"en", "user", "🎮Kaya🎮", "en", "Kaya"},
"punct to space": {"en", "user", "John❤Doe", "en", "John Doe"},
"digits dropped": {"ru", "user", "Маша123", "ru", "Маша"},
"garbage to username": {"en", "good", "123!@#", "en", "good"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
@@ -37,6 +42,28 @@ func TestTelegramSeed(t *testing.T) {
}
}
// TestTelegramSeedPlaceholder checks that a name with no usable letters falls back to
// a generated placeholder in the seeded language ("Player-NNNNN" / "Игрок-NNNNN").
func TestTelegramSeedPlaceholder(t *testing.T) {
cases := map[string]struct {
languageCode, username, firstName string
wantRe string
}{
"en empty": {"en", "", "", `^Player-\d{5}$`},
"ru empty": {"ru", "", "", `^Игрок-\d{5}$`},
"default en": {"fr", "", "", `^Player-\d{5}$`},
"both garbage": {"ru", "123", "!!!", `^Игрок-\d{5}$`},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got := telegramSeed(tc.languageCode, tc.username, tc.firstName).displayName
if !regexp.MustCompile(tc.wantRe).MatchString(got) {
t.Errorf("displayName = %q, want match %s", got, tc.wantRe)
}
})
}
}
// TestTelegramSeedTruncatesLongName checks an over-long Telegram name is capped to
// maxDisplayName runes (counted in runes, not bytes).
func TestTelegramSeedTruncatesLongName(t *testing.T) {
+87
View File
@@ -0,0 +1,87 @@
package account
import (
"context"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"go.uber.org/zap"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// ReapAbandonedGuests deletes guest accounts created before olderThan that are
// not seated in any game. It returns the number deleted.
//
// Scope is deliberately "no game seat at all", not merely "no active game": a
// finished game belongs to the other players' history, and game_players carries no
// ON DELETE CASCADE to accounts (docs/ARCHITECTURE.md §4), so a guest with any seat
// is retained (and a delete would be blocked by that foreign key regardless). The
// dependent rows of a reaped guest — sessions, identities, account_stats — fall
// away through their own ON DELETE CASCADE foreign keys. Account age is the
// abandonment signal because sessions are revoke-only with no maintained
// last_seen_at, so a lingering session never expires on its own.
func (s *Store) ReapAbandonedGuests(ctx context.Context, olderThan time.Time) (int64, error) {
stmt := table.Accounts.DELETE().WHERE(
table.Accounts.IsGuest.EQ(postgres.Bool(true)).
AND(table.Accounts.CreatedAt.LT(postgres.TimestampzT(olderThan))).
AND(postgres.NOT(postgres.EXISTS(
postgres.SELECT(table.GamePlayers.AccountID).
FROM(table.GamePlayers).
WHERE(table.GamePlayers.AccountID.EQ(table.Accounts.AccountID)),
))),
)
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return 0, fmt.Errorf("account: reap guests: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("account: reap guests rows affected: %w", err)
}
return n, nil
}
// GuestReaper periodically deletes abandoned guest accounts via
// Store.ReapAbandonedGuests. It mirrors the game turn-timeout sweeper and the
// matchmaker reaper: one background goroutine, started once from main.
type GuestReaper struct {
store *Store
retention time.Duration
clock func() time.Time
log *zap.Logger
}
// NewGuestReaper constructs a reaper deleting guests whose account age exceeds
// retention. log may be nil.
func NewGuestReaper(store *Store, retention time.Duration, log *zap.Logger) *GuestReaper {
if log == nil {
log = zap.NewNop()
}
return &GuestReaper{
store: store,
retention: retention,
clock: func() time.Time { return time.Now().UTC() },
log: log,
}
}
// Run reaps abandoned guests on each tick until ctx is cancelled.
func (r *GuestReaper) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
n, err := r.store.ReapAbandonedGuests(ctx, r.clock().Add(-r.retention))
if err != nil {
r.log.Warn("guest reap failed", zap.Error(err))
} else if n > 0 {
r.log.Info("reaped abandoned guests", zap.Int64("count", n))
}
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
)
// 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})$`)
// parseOffsetZone parses a "±HH:MM" offset into a fixed-offset location, reporting
+149
View File
@@ -0,0 +1,149 @@
package account
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// UserListItem is the admin user-list projection: a small subset of the account plus
// whether it is a robot (derived from its identities), so the console can label the kind
// without a per-row identity query.
type UserListItem struct {
ID uuid.UUID
DisplayName string
PreferredLanguage string
IsGuest bool
IsRobot bool
// FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown
// as a badge in the console list.
FlaggedHighRateAt time.Time
CreatedAt time.Time
}
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' =
// one char) matched case-insensitively against the display name / any identity's external
// id. An empty mask means no filter on that field.
type UserFilter struct {
Robots bool
NameMask string
ExternalIDMask string
}
// robotExists is the correlated subquery testing whether account a is a robot.
const robotExists = `EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot')`
// IsRobot reports whether the account is a robot pool member (it carries a robot
// identity). The admin console uses it to label a game's robot seats.
func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error) {
var ok bool
err := s.db.QueryRowContext(ctx,
`SELECT EXISTS (SELECT 1 FROM backend.identities WHERE account_id = $1 AND kind = 'robot')`,
accountID).Scan(&ok)
if err != nil {
return false, fmt.Errorf("account: is-robot %s: %w", accountID, err)
}
return ok, nil
}
// userListWhere builds the shared WHERE clause and its positional args (from $1).
func userListWhere(f UserFilter) (string, []any) {
args := []any{f.Robots}
where := robotExists + ` = $1`
if name := LikePattern(f.NameMask); name != "" {
args = append(args, name)
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
}
if ext := LikePattern(f.ExternalIDMask); ext != "" {
args = append(args, ext)
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args))
}
return where, args
}
// ListUsers returns the filtered admin user list, newest first, paginated.
func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) {
where, args := userListWhere(f)
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot
FROM backend.accounts a WHERE ` + where +
fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("account: list users: %w", err)
}
defer rows.Close()
var out []UserListItem
for rows.Next() {
var it UserListItem
var flagged sql.NullTime
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot); err != nil {
return nil, fmt.Errorf("account: scan user: %w", err)
}
if flagged.Valid {
it.FlaggedHighRateAt = flagged.Time
}
out = append(out, it)
}
return out, rows.Err()
}
// FlaggedAccount is one row of the console's high-rate review queue.
type FlaggedAccount struct {
ID uuid.UUID
DisplayName string
FlaggedHighRateAt time.Time
}
// flaggedListCap bounds the console's flagged-account list; the operator clears
// flags as they are reviewed, so the queue stays short in practice.
const flaggedListCap = 200
// ListFlaggedHighRate returns the accounts carrying the high-rate flag, most
// recently flagged first.
func (s *Store) ListFlaggedHighRate(ctx context.Context) ([]FlaggedAccount, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT account_id, display_name, flagged_high_rate_at
FROM backend.accounts WHERE flagged_high_rate_at IS NOT NULL
ORDER BY flagged_high_rate_at DESC LIMIT $1`, flaggedListCap)
if err != nil {
return nil, fmt.Errorf("account: list flagged: %w", err)
}
defer rows.Close()
var out []FlaggedAccount
for rows.Next() {
var fa FlaggedAccount
if err := rows.Scan(&fa.ID, &fa.DisplayName, &fa.FlaggedHighRateAt); err != nil {
return nil, fmt.Errorf("account: scan flagged: %w", err)
}
out = append(out, fa)
}
return out, rows.Err()
}
// CountUsers counts the filtered admin user list, for pagination.
func (s *Store) CountUsers(ctx context.Context, f UserFilter) (int, error) {
where, args := userListWhere(f)
var n int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM backend.accounts a WHERE `+where, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("account: count users: %w", err)
}
return n, nil
}
// LikePattern converts a glob mask ('*' any run, '?' one char) to an ILIKE pattern,
// escaping the SQL wildcards already in the input first. An empty/blank mask returns "".
func LikePattern(mask string) string {
mask = strings.TrimSpace(mask)
if mask == "" {
return ""
}
escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(mask)
escaped = strings.ReplaceAll(escaped, "*", "%")
return strings.ReplaceAll(escaped, "?", "_")
}
+6 -1
View File
@@ -21,10 +21,15 @@ func TestValidateDisplayName(t *testing.T) {
"adjacent specials": {"Name P._Last", "", false},
"two spaces": {"Name Last", "", false},
"leading special": {"_Name", "", false},
"trailing special": {"Name.", "", false},
"trailing underscore": {"Name_", "", false},
"trailing dot ok": {"Anna B.", "Anna B.", true},
"double trailing dot": {"Name..", "", false},
"digit rejected": {"Name2", "", false},
"blank": {" ", "", false},
"too long": {strings.Repeat("a", 33), "", false},
"five specials ok": {"a.a.a.a.a.a", "a.a.a.a.a.a", true}, // 5 dots
"six specials": {"a.a.a.a.a.a.a", "", false}, // 6 dots
"initials spaces ok": {"J. R. R. Tolkien", "J. R. R. Tolkien", true}, // 3 dots; spaces don't count
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
+497
View File
@@ -0,0 +1,497 @@
// Package accountmerge retires a secondary account into a primary one in a single
// transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints
// the secondary's identities, transfers its games/chat/complaints/invitations,
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
// (accounts.merged_into). It is the data core of account linking & merge
// (ARCHITECTURE.md §4); session revocation and any session switch are orchestrated
// one layer up (the link service), since the in-memory session cache lives there.
package accountmerge
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// statusActive mirrors game.StatusActive; the active-shared-game guard reads it
// without taking a dependency on the game package.
const statusActive = "active"
// Friendship statuses, highest precedence first, mirroring internal/social.
const (
friendAccepted = "accepted"
friendPending = "pending"
friendDeclined = "declined"
)
// ErrActiveGameConflict is returned when the primary and secondary accounts share
// an active game: merging would seat one player against themselves, so the caller
// must wait for the game to finish.
var ErrActiveGameConflict = errors.New("accountmerge: primary and secondary share an active game")
// ErrSameAccount is returned when primary and secondary are the same account.
var ErrSameAccount = errors.New("accountmerge: primary and secondary are the same account")
// Merger merges accounts over a Postgres handle.
type Merger struct {
db *sql.DB
now func() time.Time
}
// NewMerger constructs a Merger over db.
func NewMerger(db *sql.DB) *Merger {
return &Merger{db: db, now: func() time.Time { return time.Now().UTC() }}
}
// Merge retires secondary into primary atomically. The secondary is kept as a
// tombstone (merged_into=primary) so the no-cascade foreign keys of any shared
// finished game stay valid; its seat in such a game is left untouched. The merge
// is refused with ErrActiveGameConflict when the two share an active game.
func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error {
if primary == secondary {
return ErrSameAccount
}
now := m.now()
return withTx(ctx, m.db, func(tx *sql.Tx) error {
if err := guardActiveSharedGame(ctx, tx, primary, secondary); err != nil {
return err
}
if err := mergeStats(ctx, tx, primary, secondary, now); err != nil {
return err
}
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
return err
}
if err := reassignColumn(ctx, tx, table.Identities, table.Identities.AccountID, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: identities: %w", err)
}
if err := transferGamePlayers(ctx, tx, primary, secondary); err != nil {
return err
}
if err := reassignColumn(ctx, tx, table.ChatMessages, table.ChatMessages.SenderID, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: chat: %w", err)
}
if err := reassignColumn(ctx, tx, table.Complaints, table.Complaints.ComplainantID, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: complaints: %w", err)
}
if err := mergeFriendships(ctx, tx, primary, secondary); err != nil {
return err
}
if err := mergeBlocks(ctx, tx, primary, secondary); err != nil {
return err
}
if err := mergeInvitations(ctx, tx, primary, secondary); err != nil {
return err
}
if err := deleteEphemerals(ctx, tx, secondary); err != nil {
return err
}
return tombstone(ctx, tx, primary, secondary, now)
})
}
// guardActiveSharedGame returns ErrActiveGameConflict when primary and secondary
// are both seated in the same active game.
func guardActiveSharedGame(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
pri, err := activeGameIDs(ctx, tx, primary)
if err != nil {
return err
}
if len(pri) == 0 {
return nil
}
sec, err := activeGameIDs(ctx, tx, secondary)
if err != nil {
return err
}
have := make(map[uuid.UUID]struct{}, len(pri))
for _, id := range pri {
have[id] = struct{}{}
}
for _, id := range sec {
if _, ok := have[id]; ok {
return ErrActiveGameConflict
}
}
return nil
}
// activeGameIDs lists the active games accountID is seated in.
func activeGameIDs(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) ([]uuid.UUID, error) {
stmt := postgres.SELECT(table.GamePlayers.GameID).
FROM(table.GamePlayers.INNER_JOIN(table.Games, table.Games.GameID.EQ(table.GamePlayers.GameID))).
WHERE(
table.GamePlayers.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Games.Status.EQ(postgres.String(statusActive))),
)
var rows []model.GamePlayers
if err := stmt.QueryContext(ctx, tx, &rows); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("accountmerge: active games %s: %w", accountID, err)
}
out := make([]uuid.UUID, 0, len(rows))
for _, r := range rows {
out = append(out, r.GameID)
}
return out, nil
}
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws
// summed, max points kept) and deletes the secondary row.
func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
var sec model.AccountStats
err := postgres.SELECT(table.AccountStats.AllColumns).
FROM(table.AccountStats).
WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(secondary))).
QueryContext(ctx, tx, &sec)
if errors.Is(err, qrm.ErrNoRows) {
return nil
}
if err != nil {
return fmt.Errorf("accountmerge: load secondary stats: %w", err)
}
ensure := table.AccountStats.INSERT(table.AccountStats.AccountID).
VALUES(primary).ON_CONFLICT(table.AccountStats.AccountID).DO_NOTHING()
if _, err := ensure.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: ensure primary stats: %w", err)
}
var pri model.AccountStats
if err := postgres.SELECT(table.AccountStats.AllColumns).
FROM(table.AccountStats).
WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary))).
FOR(postgres.UPDATE()).
QueryContext(ctx, tx, &pri); err != nil {
return fmt.Errorf("accountmerge: lock primary stats: %w", err)
}
upd := table.AccountStats.UPDATE(
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
).SET(
postgres.Int(int64(pri.Wins+sec.Wins)),
postgres.Int(int64(pri.Losses+sec.Losses)),
postgres.Int(int64(pri.Draws+sec.Draws)),
postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))),
postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))),
postgres.TimestampzT(now),
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: update primary stats: %w", err)
}
del := table.AccountStats.DELETE().WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(secondary)))
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: delete secondary stats: %w", err)
}
return nil
}
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
// all other profile fields stay the primary's.
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
var sec model.Accounts
if err := postgres.SELECT(table.Accounts.AllColumns).
FROM(table.Accounts).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary))).
QueryContext(ctx, tx, &sec); err != nil {
return fmt.Errorf("accountmerge: load secondary account: %w", err)
}
upd := table.Accounts.UPDATE(
table.Accounts.HintBalance, table.Accounts.PaidAccount, table.Accounts.UpdatedAt,
).SET(
table.Accounts.HintBalance.ADD(postgres.Int(int64(sec.HintBalance))),
table.Accounts.PaidAccount.OR(postgres.Bool(sec.PaidAccount)),
postgres.TimestampzT(now),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(primary)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: update primary account: %w", err)
}
return nil
}
// transferGamePlayers moves secondary's seats to primary, except in a game primary
// already sits in (a shared finished game — active is barred by the guard), where
// the secondary seat is left as the tombstone so the no-cascade FK stays valid.
func transferGamePlayers(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
var prows []model.GamePlayers
if err := postgres.SELECT(table.GamePlayers.GameID).
FROM(table.GamePlayers).
WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(primary))).
QueryContext(ctx, tx, &prows); err != nil {
if !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountmerge: primary seats: %w", err)
}
}
cond := table.GamePlayers.AccountID.EQ(postgres.UUID(secondary))
if len(prows) > 0 {
ids := make([]postgres.Expression, len(prows))
for i, r := range prows {
ids[i] = postgres.UUID(r.GameID)
}
cond = cond.AND(table.GamePlayers.GameID.NOT_IN(ids...))
}
upd := table.GamePlayers.UPDATE(table.GamePlayers.AccountID).SET(postgres.UUID(primary)).WHERE(cond)
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: transfer seats: %w", err)
}
return nil
}
// reassignColumn blanket-reassigns a no-collision account column from secondary to
// primary (identities, chat sender, complaint complainant).
func reassignColumn(ctx context.Context, tx *sql.Tx, tbl postgres.Table, col postgres.ColumnString, primary, secondary uuid.UUID) error {
upd := tbl.UPDATE(col).SET(postgres.UUID(primary)).
WHERE(col.EQ(postgres.UUID(secondary)))
_, err := upd.ExecContext(ctx, tx)
return err
}
// friendRank ranks a friendship status for dedupe precedence (higher wins).
func friendRank(status string) int {
switch status {
case friendAccepted:
return 3
case friendPending:
return 2
case friendDeclined:
return 1
default:
return 0
}
}
// mergeFriendships repoints secondary's friendships to primary, dropping the direct
// primary-secondary edge (it would become a self-edge) and de-duplicating a shared
// counterparty by keeping the higher-precedence status (accepted > pending >
// declined). Each account has at most one edge per unordered pair, so the per-other
// decision is unambiguous.
func mergeFriendships(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
if err := deletePair(ctx, tx, table.Friendships.DELETE(),
table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: drop self-friendship: %w", err)
}
priByOther := map[uuid.UUID]string{}
var prows []model.Friendships
if err := selectEdges(ctx, tx, table.Friendships, table.Friendships.AllColumns, table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, &prows); err != nil {
return fmt.Errorf("accountmerge: primary friendships: %w", err)
}
for _, r := range prows {
priByOther[otherOf(r.RequesterID, r.AddresseeID, primary)] = r.Status
}
var srows []model.Friendships
if err := selectEdges(ctx, tx, table.Friendships, table.Friendships.AllColumns, table.Friendships.RequesterID, table.Friendships.AddresseeID, secondary, &srows); err != nil {
return fmt.Errorf("accountmerge: secondary friendships: %w", err)
}
for _, r := range srows {
other := otherOf(r.RequesterID, r.AddresseeID, secondary)
if priStatus, ok := priByOther[other]; ok {
if friendRank(r.Status) <= friendRank(priStatus) {
if err := deleteEdge(ctx, tx, table.Friendships.DELETE(),
table.Friendships.RequesterID, table.Friendships.AddresseeID, r.RequesterID, r.AddresseeID); err != nil {
return fmt.Errorf("accountmerge: drop dominated friendship: %w", err)
}
continue
}
if err := deletePair(ctx, tx, table.Friendships.DELETE(),
table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, other); err != nil {
return fmt.Errorf("accountmerge: drop superseded friendship: %w", err)
}
}
if err := repointEdge(ctx, tx, table.Friendships, table.Friendships.RequesterID, table.Friendships.AddresseeID,
r.RequesterID, r.AddresseeID, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: repoint friendship: %w", err)
}
}
return nil
}
// mergeBlocks repoints secondary's blocks to primary, dropping the direct
// primary-secondary block (a self-block) and de-duplicating a counterparty already
// blocked by primary in either direction (a block is undirected for suppression).
func mergeBlocks(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
if err := deletePair(ctx, tx, table.Blocks.DELETE(),
table.Blocks.BlockerID, table.Blocks.BlockedID, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: drop self-block: %w", err)
}
priOthers := map[uuid.UUID]struct{}{}
var prows []model.Blocks
if err := selectEdges(ctx, tx, table.Blocks, table.Blocks.AllColumns, table.Blocks.BlockerID, table.Blocks.BlockedID, primary, &prows); err != nil {
return fmt.Errorf("accountmerge: primary blocks: %w", err)
}
for _, r := range prows {
priOthers[otherOf(r.BlockerID, r.BlockedID, primary)] = struct{}{}
}
var srows []model.Blocks
if err := selectEdges(ctx, tx, table.Blocks, table.Blocks.AllColumns, table.Blocks.BlockerID, table.Blocks.BlockedID, secondary, &srows); err != nil {
return fmt.Errorf("accountmerge: secondary blocks: %w", err)
}
for _, r := range srows {
if _, ok := priOthers[otherOf(r.BlockerID, r.BlockedID, secondary)]; ok {
if err := deleteEdge(ctx, tx, table.Blocks.DELETE(),
table.Blocks.BlockerID, table.Blocks.BlockedID, r.BlockerID, r.BlockedID); err != nil {
return fmt.Errorf("accountmerge: drop dup block: %w", err)
}
continue
}
if err := repointEdge(ctx, tx, table.Blocks, table.Blocks.BlockerID, table.Blocks.BlockedID,
r.BlockerID, r.BlockedID, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: repoint block: %w", err)
}
}
return nil
}
// mergeInvitations deletes secondary's pending invitations as inviter (cascading to
// their invitees) and repoints its invitee rows to primary, dropping a row where
// primary is already an invitee of the same invitation.
func mergeInvitations(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
delInv := table.GameInvitations.DELETE().
WHERE(table.GameInvitations.InviterID.EQ(postgres.UUID(secondary)))
if _, err := delInv.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: delete secondary invitations: %w", err)
}
priInv := map[uuid.UUID]struct{}{}
var prows []model.GameInvitationInvitees
if err := postgres.SELECT(table.GameInvitationInvitees.InvitationID).
FROM(table.GameInvitationInvitees).
WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(primary))).
QueryContext(ctx, tx, &prows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountmerge: primary invitees: %w", err)
}
for _, r := range prows {
priInv[r.InvitationID] = struct{}{}
}
var srows []model.GameInvitationInvitees
if err := postgres.SELECT(table.GameInvitationInvitees.InvitationID).
FROM(table.GameInvitationInvitees).
WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(secondary))).
QueryContext(ctx, tx, &srows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountmerge: secondary invitees: %w", err)
}
for _, r := range srows {
where := table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(r.InvitationID)).
AND(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(secondary)))
if _, dup := priInv[r.InvitationID]; dup {
if _, err := table.GameInvitationInvitees.DELETE().WHERE(where).ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: drop dup invitee: %w", err)
}
continue
}
upd := table.GameInvitationInvitees.UPDATE(table.GameInvitationInvitees.AccountID).
SET(postgres.UUID(primary)).WHERE(where)
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: repoint invitee: %w", err)
}
}
return nil
}
// deleteEphemerals drops the secondary's pending email confirmations and friend
// codes (short-lived, single-use; not worth carrying over).
func deleteEphemerals(ctx context.Context, tx *sql.Tx, secondary uuid.UUID) error {
if _, err := table.EmailConfirmations.DELETE().
WHERE(table.EmailConfirmations.AccountID.EQ(postgres.UUID(secondary))).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: delete confirmations: %w", err)
}
if _, err := table.FriendCodes.DELETE().
WHERE(table.FriendCodes.AccountID.EQ(postgres.UUID(secondary))).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: delete friend codes: %w", err)
}
return nil
}
// tombstone marks secondary retired, pointing at primary for audit.
func tombstone(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
upd := table.Accounts.UPDATE(table.Accounts.MergedInto, table.Accounts.MergedAt, table.Accounts.UpdatedAt).
SET(postgres.UUID(primary), postgres.TimestampzT(now), postgres.TimestampzT(now)).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: tombstone secondary: %w", err)
}
return nil
}
// otherOf returns the endpoint of a two-account edge that is not self.
func otherOf(a, b, self uuid.UUID) uuid.UUID {
if a == self {
return b
}
return a
}
// selectEdges loads the rows of a symmetric two-column edge table touching account.
func selectEdges[T any](ctx context.Context, tx *sql.Tx, tbl postgres.Table, cols postgres.Projection, left, right postgres.ColumnString, account uuid.UUID, dest *[]T) error {
err := postgres.SELECT(cols).
FROM(tbl).
WHERE(left.EQ(postgres.UUID(account)).OR(right.EQ(postgres.UUID(account)))).
QueryContext(ctx, tx, dest)
if errors.Is(err, qrm.ErrNoRows) {
return nil
}
return err
}
// deletePair deletes the directed-or-reverse edge between a and b.
func deletePair(ctx context.Context, tx *sql.Tx, del postgres.DeleteStatement, left, right postgres.ColumnString, a, b uuid.UUID) error {
cond := left.EQ(postgres.UUID(a)).AND(right.EQ(postgres.UUID(b))).
OR(left.EQ(postgres.UUID(b)).AND(right.EQ(postgres.UUID(a))))
_, err := del.WHERE(cond).ExecContext(ctx, tx)
return err
}
// deleteEdge deletes the single edge identified by its (left, right) primary key.
func deleteEdge(ctx context.Context, tx *sql.Tx, del postgres.DeleteStatement, left, right postgres.ColumnString, l, r uuid.UUID) error {
cond := left.EQ(postgres.UUID(l)).AND(right.EQ(postgres.UUID(r)))
_, err := del.WHERE(cond).ExecContext(ctx, tx)
return err
}
// repointEdge replaces the secondary endpoint of edge (l, r) with primary, keeping
// the edge's direction.
func repointEdge(ctx context.Context, tx *sql.Tx, tbl postgres.Table, left, right postgres.ColumnString, l, r, primary, secondary uuid.UUID) error {
var col postgres.ColumnString
var where postgres.BoolExpression
if l == secondary {
col, where = left, left.EQ(postgres.UUID(secondary)).AND(right.EQ(postgres.UUID(r)))
} else {
col, where = right, left.EQ(postgres.UUID(l)).AND(right.EQ(postgres.UUID(secondary)))
}
_, err := tbl.UPDATE(col).SET(postgres.UUID(primary)).WHERE(where).ExecContext(ctx, tx)
return err
}
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("accountmerge: begin tx: %w", err)
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("accountmerge: commit tx: %w", err)
}
return nil
}
@@ -0,0 +1,118 @@
/* Admin console stylesheet. Deliberately small and dependency-free: the console
is an internal operator tool served under /_gm, not a public surface. */
:root {
--bg: #11151c;
--panel: #1b2230;
--panel-hi: #232c3d;
--ink: #e6ebf2;
--ink-dim: #9aa7ba;
--line: #2c3850;
--accent: #5aa9ff;
--danger: #ff6b6b;
--ok: #4ecb8d;
--warn: #f1c453;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 15px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.topbar {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.6rem 1.2rem;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
.topbar .brand { font-weight: 700; letter-spacing: 0.04em; }
.topbar .mainnav { display: flex; gap: 1rem; flex: 1; flex-wrap: wrap; }
.topbar .mainnav a.active { color: var(--ink); border-bottom: 2px solid var(--accent); }
.content { padding: 1.5rem; max-width: 1100px; margin: 0 auto; }
h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
.lede { color: var(--ink-dim); margin-top: 0; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin: 1.2rem 0; }
.card {
display: block;
padding: 1rem 1.2rem;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
color: var(--ink);
}
.card:hover { background: var(--panel-hi); text-decoration: none; }
.card h2 { font-size: 1.05rem; margin: 0 0 0.3rem; color: var(--accent); }
.card .bignum { font-size: 1.8rem; margin: 0; color: var(--ink); font-variant-numeric: tabular-nums; }
.panel {
padding: 0.9rem 1.1rem;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
margin-bottom: 1rem;
}
.panel h2 { font-size: 1rem; margin: 0 0 0.6rem; color: var(--ink); }
.kv { list-style: none; margin: 0; padding: 0; }
.kv li { padding: 0.15rem 0; color: var(--ink-dim); }
.kv li b { color: var(--ink); font-weight: 600; }
.note { color: var(--ink-dim); font-style: italic; margin: 0.2rem 0; }
.ok { color: var(--ok); }
.bad { color: var(--danger); }
.warn { color: var(--warn); }
.list { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin-bottom: 1rem; }
.list th, .list td { text-align: left; padding: 0.35rem 0.6rem; border-bottom: 1px solid var(--line); }
.list th { color: var(--ink-dim); font-weight: 600; }
.list tr:hover td { background: var(--panel-hi); }
.list td.num { text-align: right; font-variant-numeric: tabular-nums; }
.pager { display: flex; gap: 1rem; align-items: center; color: var(--ink-dim); }
.subnav { color: var(--ink-dim); margin: -0.2rem 0 1rem; font-size: 0.9rem; }
.subnav a.active { color: var(--ink); }
.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.4rem; }
.form .export { margin-left: auto; align-self: center; color: var(--accent); white-space: nowrap; }
.form.col { flex-direction: column; align-items: stretch; max-width: 540px; }
.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
.form input, .form select, .form textarea {
background: var(--bg);
color: var(--ink);
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.35rem 0.5rem;
font: inherit;
}
.form textarea { min-height: 4rem; resize: vertical; }
button {
background: var(--accent);
color: #06121f;
border: 0;
border-radius: 6px;
padding: 0.4rem 0.9rem;
font: inherit;
font-weight: 600;
cursor: pointer;
}
button:hover { filter: brightness(1.1); }
button.danger { background: var(--danger); color: #1a0606; }
code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
.actions form { margin: 0; }
.pill { padding: 0.05rem 0.4rem; border: 1px solid var(--line); border-radius: 999px; font-size: 0.8rem; }
/* Move-timing chart: a server-rendered, script-free inline SVG line chart. */
.chart { width: 100%; height: auto; max-width: 680px; margin-top: 0.4rem; }
.chart .axis { stroke: var(--line); stroke-width: 1; }
.chart .grid { stroke: var(--line); stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.6; }
.chart .lbl { fill: var(--ink-dim); font-size: 11px; }
.chart .ln { fill: none; stroke-width: 1.5; }
.chart .ln-min { stroke: var(--ok); }
.chart .ln-avg { stroke: var(--accent); }
.chart .ln-max { stroke: var(--danger); }
.lg { font-weight: 600; }
.lg-min { color: var(--ok); }
.lg-avg { color: var(--accent); }
.lg-max { color: var(--danger); }
+108
View File
@@ -0,0 +1,108 @@
package adminconsole
import (
"fmt"
"html/template"
"strings"
"time"
)
// ChartPoint is one move-number sample of the move-duration chart: the min, mean and
// max think time (seconds) the account took on its Ordinal-th move across its games.
type ChartPoint struct {
Ordinal int
Min float64
Max float64
Avg float64
}
// FormatDuration renders a think-time in seconds as a compact human string
// ("45s", "3m", "1h5m"), for the user-list columns and the chart's Y labels.
func FormatDuration(secs float64) string {
d := time.Duration(secs * float64(time.Second))
switch {
case d < time.Minute:
return fmt.Sprintf("%ds", int(d.Seconds()+0.5))
case d < time.Hour:
return fmt.Sprintf("%dm", int(d.Minutes()+0.5))
default:
h := int(d.Hours())
if m := int(d.Minutes()) - h*60; m > 0 {
return fmt.Sprintf("%dh%dm", h, m)
}
return fmt.Sprintf("%dh", h)
}
}
// MoveDurationChart renders the per-move-number think-time chart as a self-contained,
// script-free inline SVG with three series (min, mean, max). The coordinates and
// labels are all derived from numeric data, so the result is safe template.HTML.
// An empty series renders nothing.
func MoveDurationChart(points []ChartPoint) template.HTML {
if len(points) == 0 {
return ""
}
const (
w, h = 640, 240
padL = 46
padR = 12
padT = 10
padB = 28
)
maxOrd := points[len(points)-1].Ordinal
if maxOrd < 1 {
maxOrd = 1
}
var maxY float64
for _, p := range points {
maxY = max(maxY, p.Max)
}
if maxY <= 0 {
maxY = 1
}
xOf := func(ord int) float64 {
if maxOrd == 1 {
return padL
}
return padL + (float64(ord-1)/float64(maxOrd-1))*(w-padL-padR)
}
yOf := func(v float64) float64 { return padT + (1-v/maxY)*(h-padT-padB) }
line := func(get func(ChartPoint) float64) string {
pts := make([]string, len(points))
for i, p := range points {
pts[i] = fmt.Sprintf("%.1f,%.1f", xOf(p.Ordinal), yOf(get(p)))
}
return strings.Join(pts, " ")
}
var b strings.Builder
fmt.Fprintf(&b, `<svg viewBox="0 0 %d %d" class="chart" role="img" aria-label="Move duration by move number">`, w, h)
fmt.Fprintf(&b, `<line x1="%d" y1="%d" x2="%d" y2="%.1f" class="axis"/>`, padL, padT, padL, float64(h-padB))
fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" class="axis"/>`, padL, float64(h-padB), w-padR, float64(h-padB))
for _, frac := range []float64{0, 0.5, 1} {
v := maxY * frac
y := yOf(v)
fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" class="grid"/>`, padL, y, w-padR, y)
fmt.Fprintf(&b, `<text x="%d" y="%.1f" class="lbl" text-anchor="end">%s</text>`, padL-5, y+3, FormatDuration(v))
}
for _, ord := range xTicks(maxOrd) {
fmt.Fprintf(&b, `<text x="%.1f" y="%d" class="lbl" text-anchor="middle">%d</text>`, xOf(ord), h-padB+15, ord)
}
fmt.Fprintf(&b, `<polyline points="%s" class="ln ln-max"/>`, line(func(p ChartPoint) float64 { return p.Max }))
fmt.Fprintf(&b, `<polyline points="%s" class="ln ln-avg"/>`, line(func(p ChartPoint) float64 { return p.Avg }))
fmt.Fprintf(&b, `<polyline points="%s" class="ln ln-min"/>`, line(func(p ChartPoint) float64 { return p.Min }))
b.WriteString(`</svg>`)
return template.HTML(b.String())
}
// xTicks returns up to three distinct ordinal labels for the chart's X axis.
func xTicks(maxOrd int) []int {
if maxOrd <= 2 {
out := make([]int, 0, maxOrd)
for i := 1; i <= maxOrd; i++ {
out = append(out, i)
}
return out
}
return []int{1, (maxOrd + 1) / 2, maxOrd}
}
@@ -0,0 +1,51 @@
package adminconsole
import (
"strings"
"testing"
)
func TestFormatDuration(t *testing.T) {
cases := map[float64]string{
0: "0s", 30: "30s", 59: "59s", 60: "1m", 150: "3m", 3600: "1h", 3660: "1h1m", 7800: "2h10m",
}
for secs, want := range cases {
if got := FormatDuration(secs); got != want {
t.Errorf("FormatDuration(%v) = %q, want %q", secs, got, want)
}
}
}
func TestMoveDurationChartEmpty(t *testing.T) {
if got := MoveDurationChart(nil); got != "" {
t.Errorf("empty chart = %q, want empty", got)
}
}
func TestMoveDurationChart(t *testing.T) {
pts := []ChartPoint{{Ordinal: 1, Min: 5, Max: 20, Avg: 10}, {Ordinal: 2, Min: 8, Max: 40, Avg: 18}, {Ordinal: 3, Min: 12, Max: 90, Avg: 30}}
svg := string(MoveDurationChart(pts))
for _, want := range []string{"<svg", "ln-min", "ln-avg", "ln-max", "</svg>"} {
if !strings.Contains(svg, want) {
t.Errorf("chart missing %q\n%s", want, svg)
}
}
if n := strings.Count(svg, "<polyline"); n != 3 {
t.Errorf("polylines = %d, want 3", n)
}
}
func TestXTicks(t *testing.T) {
cases := map[int][]int{1: {1}, 2: {1, 2}, 3: {1, 2, 3}, 10: {1, 5, 10}}
for maxOrd, want := range cases {
got := xTicks(maxOrd)
if len(got) != len(want) {
t.Fatalf("xTicks(%d) = %v, want %v", maxOrd, got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("xTicks(%d) = %v, want %v", maxOrd, got, want)
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
// Package adminconsole renders the backend's server-side admin console: a small,
// dependency-free set of Go html/template pages plus one embedded stylesheet,
// served under /_gm. It owns the rendering and the page view models only; the gin
// handlers (internal/server) fetch the domain data, populate the view models and
// gate the surface — the gateway puts HTTP Basic-Auth in front of /_gm and a
// same-origin check guards the POST actions (docs/ARCHITECTURE.md §12). It mirrors
// the shape of galaxy-game's adminconsole package, minus the per-operator CSRF
// token and operator name (this console tracks no operator identity).
package adminconsole
+101
View File
@@ -0,0 +1,101 @@
package adminconsole
import (
"bytes"
"embed"
"fmt"
"html/template"
"io"
"io/fs"
"path"
"strings"
)
//go:embed templates
var templatesFS embed.FS
//go:embed assets
var assetsFS embed.FS
// Renderer holds the parsed admin console templates. It composes one template set
// per content page, each combining the shared layout (the page chrome and the
// "layout" entry template) with that page's "content" block, so rendering a page
// is a single ExecuteTemplate call against "layout".
type Renderer struct {
pages map[string]*template.Template
}
// PageData is the view model passed to every admin console page. Title is the
// document title; ActiveNav marks the highlighted navigation entry; Data carries
// the page-specific payload (one of the *View types in views.go).
type PageData struct {
Title string
ActiveNav string
Data any
}
// NewRenderer parses the embedded layout and every content page under
// templates/pages. It fails when a template cannot be parsed.
func NewRenderer() (*Renderer, error) {
base, err := template.New("layout").ParseFS(templatesFS, "templates/layout.gohtml")
if err != nil {
return nil, fmt.Errorf("parse admin console layout: %w", err)
}
pageFiles, err := fs.Glob(templatesFS, "templates/pages/*.gohtml")
if err != nil {
return nil, fmt.Errorf("enumerate admin console pages: %w", err)
}
if len(pageFiles) == 0 {
return nil, fmt.Errorf("admin console: no page templates found under templates/pages")
}
pages := make(map[string]*template.Template, len(pageFiles))
for _, file := range pageFiles {
name := strings.TrimSuffix(path.Base(file), ".gohtml")
clone, err := base.Clone()
if err != nil {
return nil, fmt.Errorf("clone admin console layout for %q: %w", name, err)
}
if _, err := clone.ParseFS(templatesFS, file); err != nil {
return nil, fmt.Errorf("parse admin console page %q: %w", name, err)
}
pages[name] = clone
}
return &Renderer{pages: pages}, nil
}
// MustNewRenderer is like NewRenderer but panics on error. The templates are
// embedded at build time, so a parse failure is a programmer error.
func MustNewRenderer() *Renderer {
renderer, err := NewRenderer()
if err != nil {
panic(err)
}
return renderer
}
// Render writes the named page, wrapped in the shared layout, to w using data. It
// renders into an intermediate buffer first, so a mid-render failure never emits
// a partial document. It returns an error for an unknown page or a failed render.
func (r *Renderer) Render(w io.Writer, page string, data PageData) error {
tmpl, ok := r.pages[page]
if !ok {
return fmt.Errorf("admin console: unknown page %q", page)
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
return fmt.Errorf("render admin console page %q: %w", page, err)
}
_, err := buf.WriteTo(w)
return err
}
// Assets returns the embedded static asset tree rooted at the assets directory,
// suitable for serving under /_gm/assets/.
func Assets() (fs.FS, error) {
return fs.Sub(assetsFS, "assets")
}
@@ -0,0 +1,76 @@
package adminconsole
import (
"bytes"
"io/fs"
"strings"
"testing"
)
// TestRendererRendersEveryPage parses the embedded templates and renders each
// page with a representative view, asserting the page executes, carries the
// shared layout chrome and shows a distinctive value.
func TestRendererRendersEveryPage(t *testing.T) {
r, err := NewRenderer()
if err != nil {
t.Fatalf("new renderer: %v", err)
}
cases := []struct {
page string
data any
want string
}{
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
{"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"},
{"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: "scrabble_en"}, "Resolve"},
{"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"},
{"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"},
}
for _, tc := range cases {
t.Run(tc.page, func(t *testing.T) {
var buf bytes.Buffer
if err := r.Render(&buf, tc.page, PageData{Title: tc.page, Data: tc.data}); err != nil {
t.Fatalf("render %s: %v", tc.page, err)
}
out := buf.String()
if !strings.Contains(out, tc.want) {
t.Errorf("render %s: missing %q in output", tc.page, tc.want)
}
if !strings.Contains(out, "Scrabble · admin") {
t.Errorf("render %s: missing layout chrome", tc.page)
}
})
}
}
// TestRendererUnknownPage reports an error for a page that does not exist.
func TestRendererUnknownPage(t *testing.T) {
r := MustNewRenderer()
if err := r.Render(&bytes.Buffer{}, "nope", PageData{}); err == nil {
t.Fatal("expected an error rendering an unknown page")
}
}
// TestAssets confirms the stylesheet is embedded and reachable under the assets
// root.
func TestAssets(t *testing.T) {
fsys, err := Assets()
if err != nil {
t.Fatalf("assets: %v", err)
}
if _, err := fs.Stat(fsys, "console.css"); err != nil {
t.Errorf("console.css not embedded: %v", err)
}
}
@@ -0,0 +1,31 @@
{{define "layout" -}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{{.Title}} · Scrabble admin</title>
<link rel="stylesheet" href="/_gm/assets/console.css">
</head>
<body>
<header class="topbar">
<span class="brand">Scrabble · admin</span>
<nav class="mainnav">
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a>
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
<a href="/_gm/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/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
<a href="/_gm/grafana/">Grafana ↗</a>
</nav>
</header>
<main class="content">
{{template "content" .}}
</main>
</body>
</html>
{{- end}}
@@ -0,0 +1,15 @@
{{define "content" -}}
<h1>Broadcast</h1>
{{with .Data}}
<section class="panel"><h2>Post to the game channel</h2>
{{if .ConnectorEnabled}}
<form class="form col" method="post" action="/_gm/broadcast">
<label>Message <textarea name="text" required></textarea></label>
<label>Bot language <select name="language"><option value="en">en</option><option value="ru">ru</option></select></label>
<div><button type="submit">Post to channel</button></div>
</form>
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
</section>
<p class="note">To message a single user, open their <a href="/_gm/users">user page</a>.</p>
{{end}}
{{- end}}
@@ -0,0 +1,32 @@
{{define "content" -}}
{{with .Data}}
<h1>Complaint: {{.Word}}</h1>
<nav class="subnav"><a href="/_gm/complaints">&laquo; complaints</a></nav>
<section class="panel"><h2>Details</h2>
<ul class="kv">
<li><b>Word</b> <code>{{.Word}}</code></li>
<li><b>Variant</b> {{.Variant}}</li>
<li><b>Dictionary</b> {{.DictVersion}}</li>
<li><b>Lookup at filing</b> {{if .WasValid}}<span class="ok">valid</span>{{else}}<span class="bad">invalid</span>{{end}}</li>
<li><b>Filer note</b> {{if .Note}}{{.Note}}{{else}}<span class="note">none</span>{{end}}</li>
<li><b>Game</b> <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a></li>
<li><b>Filed</b> {{.CreatedAt}}</li>
<li><b>Status</b> {{.Status}}</li>
{{if .Resolved}}<li><b>Disposition</b> {{.Disposition}}</li><li><b>Resolution note</b> {{.ResolutionNote}}</li><li><b>Resolved</b> {{.ResolvedAt}}</li>{{end}}
</ul>
</section>
<section class="panel"><h2>{{if .Resolved}}Re-resolve{{else}}Resolve{{end}}</h2>
<form class="form col" method="post" action="/_gm/complaints/{{.ID}}/resolve">
<label>Disposition
<select name="disposition">
<option value="reject">reject — dictionary is correct</option>
<option value="accept_add">accept — add word to the dictionary</option>
<option value="accept_remove">accept — remove word from the dictionary</option>
</select>
</label>
<label>Note <textarea name="note"></textarea></label>
<div><button type="submit">Resolve</button></div>
</form>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,30 @@
{{define "content" -}}
<h1>Complaints</h1>
{{with .Data}}
<nav class="subnav">
<a href="/_gm/complaints?status=open"{{if eq .Status "open"}} class="active"{{end}}>open</a> ·
<a href="/_gm/complaints?status=resolved"{{if eq .Status "resolved"}} class="active"{{end}}>resolved</a> ·
<a href="/_gm/complaints"{{if eq .Status ""}} class="active"{{end}}>all</a>
</nav>
<table class="list">
<thead><tr><th>Word</th><th>Variant</th><th>Was valid</th><th>Status</th><th>Disposition</th><th>Filed</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td><a href="/_gm/complaints/{{.ID}}">{{.Word}}</a></td>
<td>{{.Variant}}</td>
<td>{{if .WasValid}}<span class="ok">valid</span>{{else}}<span class="bad">invalid</span>{{end}}</td>
<td>{{.Status}}</td>
<td>{{.Disposition}}</td>
<td>{{.CreatedAt}}</td>
</tr>
{{else}}<tr><td colspan="6"><span class="note">no complaints</span></td></tr>{{end}}
</tbody>
</table>
<nav class="pager">
{{if .Pager.HasPrev}}<a href="/_gm/complaints?status={{.Status}}&amp;page={{.Pager.PrevPage}}">&laquo; prev</a>{{end}}
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
{{if .Pager.HasNext}}<a href="/_gm/complaints?status={{.Status}}&amp;page={{.Pager.NextPage}}">next &raquo;</a>{{end}}
</nav>
{{end}}
{{- end}}
@@ -0,0 +1,24 @@
{{define "content" -}}
<h1>Dashboard</h1>
<p class="lede">Operator console for users, games, complaints and dictionaries.</p>
{{with .Data}}
<div class="cards">
<a class="card" href="/_gm/users"><h2>Users</h2><p class="bignum">{{.Accounts}}</p></a>
<a class="card" href="/_gm/games"><h2>Games</h2><p class="bignum">{{.Games}}</p></a>
<a class="card" href="/_gm/games?status=active"><h2>Active games</h2><p class="bignum">{{.ActiveGames}}</p></a>
<a class="card" href="/_gm/complaints?status=open"><h2>Open complaints</h2><p class="bignum">{{.OpenComplaints}}</p></a>
<a class="card" href="/_gm/dictionary"><h2>Pending dict changes</h2><p class="bignum">{{.PendingChanges}}</p></a>
</div>
<section class="panel">
<h2>Dictionaries</h2>
<table class="list">
<thead><tr><th>Variant</th><th>Latest</th><th>Resident versions</th></tr></thead>
<tbody>
{{range .Variants}}
<tr><td>{{.Variant}}</td><td>{{.Latest}}</td><td>{{range .Versions}}<span class="pill">{{.}}</span> {{end}}</td></tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,43 @@
{{define "content" -}}
<h1>Dictionary</h1>
{{with .Data}}
<section class="panel"><h2>Resident versions</h2>
<table class="list">
<thead><tr><th>Variant</th><th>Latest</th><th>Resident</th></tr></thead>
<tbody>
{{range .Variants}}
<tr><td>{{.Variant}}</td><td>{{.Latest}}</td><td>{{range .Versions}}<span class="pill">{{.}}</span> {{end}}</td></tr>
{{end}}
</tbody>
</table>
</section>
<section class="panel"><h2>Hot-reload a version</h2>
<p class="note">Drop the rebuilt DAWG set into BACKEND_DICT_DIR/&lt;version&gt;/ first, then load it here.</p>
<form class="form" method="post" action="/_gm/dictionary/reload">
<label>Version <input type="text" name="version" placeholder="v2" required></label>
<div><button type="submit">Reload</button></div>
</form>
</section>
<section class="panel"><h2>Pending dictionary changes</h2>
<table class="list">
<thead><tr><th>Variant</th><th>Action</th><th>Word</th><th>Resolved</th></tr></thead>
<tbody>
{{range .Changes}}
<tr><td>{{.Variant}}</td><td>{{.Action}}</td><td><code>{{.Word}}</code></td><td>{{.ResolvedAt}}</td></tr>
{{else}}<tr><td colspan="4"><span class="note">no pending changes</span></td></tr>{{end}}
</tbody>
</table>
<form class="form" method="post" action="/_gm/dictionary/changes/apply">
<label>Mark applied for variant
<select name="variant">
<option value="scrabble_en">scrabble_en</option>
<option value="scrabble_ru">scrabble_ru</option>
<option value="erudit_ru">erudit_ru</option>
</select>
</label>
<label>In version <input type="text" name="version" placeholder="v2" required></label>
<div><button type="submit">Mark applied</button></div>
</form>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,30 @@
{{define "content" -}}
{{with .Data}}
<h1>Game {{.ID}}</h1>
<nav class="subnav"><a href="/_gm/games">&laquo; games</a> · <a href="/_gm/messages?game={{.ID}}">messages</a></nav>
<section class="panel"><h2>Summary</h2>
<ul class="kv">
<li><b>Variant</b> {{.Variant}}</li>
<li><b>Dictionary</b> {{.DictVersion}}</li>
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
<li><b>Players</b> {{.Players}}</li>
<li><b>To move</b> seat {{.ToMove}}</li>
<li><b>Moves</b> {{.MoveCount}}</li>
<li><b>Created</b> {{.CreatedAt}}</li>
<li><b>Updated</b> {{.UpdatedAt}}</li>
{{if .FinishedAt}}<li><b>Finished</b> {{.FinishedAt}}</li>{{end}}
</ul>
</section>
<section class="panel"><h2>Seats</h2>
<table class="list">
<thead><tr><th>Seat</th><th>Player</th><th>Score</th><th>Hints used</th><th>Winner</th><th>Robot</th></tr></thead>
<tbody>
{{range .Seats}}
<tr><td>{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Score}}</td><td>{{.HintsUsed}}</td><td>{{if .Winner}}<span class="ok">winner</span>{{end}}</td><td>{{if .IsRobot}}🤖 {{.RobotIntent}}{{if .NextMove}}<br><small>next move {{.NextMove}}</small>{{end}}{{end}}</td></tr>
{{end}}
</tbody>
</table>
{{if .HasRobot}}<p><small>Play-to-win is decided once per game from the bag seed; robots play to win in ~{{.RobotTargetPct}}% of games.</small></p>{{end}}
</section>
{{end}}
{{- end}}
@@ -0,0 +1,23 @@
{{define "content" -}}
<h1>Games</h1>
{{with .Data}}
<nav class="subnav">
<a href="/_gm/games"{{if eq .Status ""}} class="active"{{end}}>all</a> ·
<a href="/_gm/games?status=active"{{if eq .Status "active"}} class="active"{{end}}>active</a> ·
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
</nav>
<table class="list">
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
<tbody>
{{range .Items}}
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
</tbody>
</table>
<nav class="pager">
{{if .Pager.HasPrev}}<a href="/_gm/games?status={{.Status}}&amp;page={{.Pager.PrevPage}}">&laquo; prev</a>{{end}}
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
{{if .Pager.HasNext}}<a href="/_gm/games?status={{.Status}}&amp;page={{.Pager.NextPage}}">next &raquo;</a>{{end}}
</nav>
{{end}}
{{- end}}
@@ -0,0 +1,7 @@
{{define "content" -}}
{{with .Data}}
<h1>{{.Heading}}</h1>
<p>{{.Body}}</p>
<p><a href="{{.Back}}">&laquo; back</a></p>
{{end}}
{{- end}}
@@ -0,0 +1,38 @@
{{define "content" -}}
<h1>Messages</h1>
{{with .Data}}
<form class="form" method="get" action="/_gm/messages">
{{if .GameID}}<input type="hidden" name="game" value="{{.GameID}}">{{end}}
{{if .UserID}}<input type="hidden" name="user" value="{{.UserID}}">{{end}}
<input name="name" value="{{.NameMask}}" placeholder="sender name mask (* ?)">
<input name="ext" value="{{.ExtMask}}" placeholder="sender external id mask (* ?)">
<button type="submit">Filter</button>
<a class="export" href="/_gm/messages.csv?{{.FilterQuery}}">Export CSV ↓</a>
</form>
{{if or .GameID .UserID}}
<p class="note">Filtered{{if .GameID}} to game <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a>{{end}}{{if .UserID}} from <a href="/_gm/users/{{.UserID}}">sender</a>{{end}} · <a href="/_gm/messages">clear</a></p>
{{end}}
<table class="list">
<thead><tr><th>Time</th><th>Source</th><th>Sender</th><th>IP</th><th>Message</th><th>Game</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td>{{.CreatedAt}}</td>
<td>{{.Source}}</td>
<td><a href="/_gm/users/{{.SenderID}}">{{.SenderName}}</a></td>
<td>{{.IP}}</td>
<td>{{.Body}}</td>
<td><a href="/_gm/games/{{.GameID}}">game</a></td>
</tr>
{{else}}
<tr><td colspan="6"><span class="note">no messages</span></td></tr>
{{end}}
</tbody>
</table>
<nav class="pager">
{{if .Pager.HasPrev}}<a href="/_gm/messages?{{.FilterQuery}}&amp;page={{.Pager.PrevPage}}">&laquo; prev</a>{{end}}
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
{{if .Pager.HasNext}}<a href="/_gm/messages?{{.FilterQuery}}&amp;page={{.Pager.NextPage}}">next &raquo;</a>{{end}}
</nav>
{{end}}
{{- end}}
@@ -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}}
@@ -0,0 +1,75 @@
{{define "content" -}}
{{with .Data}}
<h1>{{.DisplayName}}</h1>
<nav class="subnav"><a href="/_gm/users">&laquo; users</a> · <a href="/_gm/messages?user={{.ID}}">messages</a></nav>
<div class="cards">
<section class="panel"><h2>Account</h2>
<ul class="kv">
<li><b>ID</b> {{.ID}}</li>
<li><b>Language</b> {{.Language}}</li>
<li><b>Timezone</b> {{.TimeZone}}</li>
<li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li>
<li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li>
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
<li><b>Hint wallet</b> {{.HintBalance}}</li>
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
{{if .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}}
<li><b>Created</b> {{.CreatedAt}}</li>
</ul>
{{if .FlaggedHighRateAt}}
<form class="form" method="post" action="/_gm/users/{{.ID}}/clear-high-rate-flag">
<button type="submit">Clear high-rate flag</button>
</form>
{{end}}
</section>
<section class="panel"><h2>Statistics</h2>
{{if .HasStats}}
<ul class="kv">
<li><b>Wins</b> {{.Stats.Wins}}</li>
<li><b>Losses</b> {{.Stats.Losses}}</li>
<li><b>Draws</b> {{.Stats.Draws}}</li>
<li><b>Best game</b> {{.Stats.MaxGamePoints}}</li>
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
</ul>
{{else}}<p class="note">no statistics</p>{{end}}
</section>
</div>
{{if .MoveChart}}
<section class="panel"><h2>Move timing</h2>
<p class="note">Think time per move number across all games — <span class="lg lg-min">min</span> · <span class="lg lg-avg">mean</span> · <span class="lg lg-max">max</span>.</p>
{{.MoveChart}}
</section>
{{end}}
<section class="panel"><h2>Identities</h2>
<table class="list">
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
<tbody>
{{range .Identities}}
<tr><td>{{.Kind}}</td><td><code>{{.ExternalID}}</code></td><td>{{if .Confirmed}}<span class="ok">yes</span>{{else}}<span class="warn">no</span>{{end}}</td><td>{{.CreatedAt}}</td></tr>
{{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}}
</tbody>
</table>
</section>
{{if .TelegramID}}
<section class="panel"><h2>Send Telegram message</h2>
{{if .ConnectorEnabled}}
<form class="form col" method="post" action="/_gm/users/{{.ID}}/message">
<label>Message <textarea name="text" required></textarea></label>
<label>Bot language <select name="language"><option value="en">en</option><option value="ru">ru</option></select></label>
<div><button type="submit">Send to user</button></div>
</form>
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
</section>
{{end}}
<section class="panel"><h2>Games</h2>
<table class="list">
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
<tbody>
{{range .Games}}
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
</tbody>
</table>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,37 @@
{{define "content" -}}
<h1>Users</h1>
{{with .Data}}
<nav class="subnav">
<a href="/_gm/users"{{if not .Robots}} class="active"{{end}}>People</a> ·
<a href="/_gm/users?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a>
</nav>
<form class="form" method="get" action="/_gm/users">
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)">
<button type="submit">Filter</button>
</form>
<table class="list">
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th><th title="per-move think time across all games">Move min</th><th>avg</th><th>max</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</td>
<td>{{.Kind}}</td>
<td>{{.Language}}</td>
<td>{{.CreatedAt}}</td>
{{if .HasMoveStats}}<td>{{.MoveMin}}</td><td>{{.MoveAvg}}</td><td>{{.MoveMax}}</td>{{else}}<td colspan="3"><span class="note">—</span></td>{{end}}
</tr>
{{else}}
<tr><td colspan="8"><span class="note">no users</span></td></tr>
{{end}}
</tbody>
</table>
<nav class="pager">
{{if .Pager.HasPrev}}<a href="/_gm/users?{{.FilterQuery}}&amp;page={{.Pager.PrevPage}}">&laquo; prev</a>{{end}}
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
{{if .Pager.HasNext}}<a href="/_gm/users?{{.FilterQuery}}&amp;page={{.Pager.NextPage}}">next &raquo;</a>{{end}}
</nav>
{{end}}
{{- end}}
+289
View File
@@ -0,0 +1,289 @@
package adminconsole
import "html/template"
// The *View types are the display models the gin handlers fill and the templates
// render. Time values are pre-formatted to strings by the handlers so the
// templates stay logic-free.
// Pager is the shared list pagination state.
type Pager struct {
Page int
PageSize int
Total int
HasPrev bool
HasNext bool
PrevPage int
NextPage int
}
// NewPager builds the pagination state for a 1-based page of pageSize over total
// items.
func NewPager(page, pageSize, total int) Pager {
if page < 1 {
page = 1
}
p := Pager{Page: page, PageSize: pageSize, Total: total, PrevPage: page - 1, NextPage: page + 1}
p.HasPrev = page > 1
p.HasNext = page*pageSize < total
return p
}
// VariantVersions lists the dictionary versions resident for one variant.
type VariantVersions struct {
Variant string
Latest string
Versions []string
}
// DashboardView is the landing-page summary.
type DashboardView struct {
Accounts int
Games int
ActiveGames int
OpenComplaints int
PendingChanges int
Variants []VariantVersions
}
// UsersView is the paginated account list.
type UsersView struct {
Items []UserRow
Pager Pager
// Robots is the active people/robots toggle; NameMask/ExternalIDMask are the current
// glob filters; FilterQuery is those encoded for pager/toggle links.
Robots bool
NameMask string
ExternalIDMask string
FilterQuery string
}
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
// pre-formatted move-duration summary (empty when it has no timed move);
// FlaggedHighRate marks the soft high-rate badge.
type UserRow struct {
ID string
DisplayName string
Kind string
Language string
Guest bool
FlaggedHighRate bool
CreatedAt string
HasMoveStats bool
MoveMin string
MoveAvg string
MoveMax string
}
// MessagesView is the paginated chat-message moderation list. NameMask/ExtMask are the
// current sender glob filters; GameID/UserID pin the list to one game / sender (set from a
// game or user card); FilterQuery is the active filters encoded for the pager links.
type MessagesView struct {
Items []MessageRow
Pager Pager
NameMask string
ExtMask string
GameID string
UserID string
FilterQuery string
}
// MessageRow is one chat message in the moderation list: its sender (linked to the user
// card), source, IP, body, game (linked to the game card) and time.
type MessageRow struct {
ID string
SenderID string
SenderName string
Source string
IP string
Body string
GameID string
CreatedAt string
}
// UserDetailView is one account with its stats, identities and recent games.
type UserDetailView struct {
ID string
DisplayName string
Language string
TimeZone string
Guest bool
NotificationsInAppOnly bool
PaidAccount bool
// MergedInto is the primary account id when this account has been retired by a
// merge, or empty for a live account.
MergedInto string
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
// empty for an unflagged account; the card shows it with the Clear action.
FlaggedHighRateAt string
HintBalance int
CreatedAt string
HasStats bool
Stats StatsRow
Identities []IdentityRow
Games []GameRow
TelegramID string
ConnectorEnabled bool
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
// time (min/mean/max), empty when the account has no timed move.
MoveChart template.HTML
}
// StatsRow is an account's lifetime statistics.
type StatsRow struct {
Wins int
Losses int
Draws int
MaxGamePoints int
MaxWordPoints int
}
// IdentityRow is one platform/email identity of an account.
type IdentityRow struct {
Kind string
ExternalID string
Confirmed bool
CreatedAt string
}
// GameRow is one game row in a list.
type GameRow struct {
ID string
Variant string
Status string
Players int
UpdatedAt string
}
// GamesView is the paginated games list, optionally filtered by status.
type GamesView struct {
Items []GameRow
Status string
Pager Pager
}
// GameDetailView is one game with its seats.
type GameDetailView struct {
ID string
Variant string
DictVersion string
Status string
Players int
ToMove int
EndReason string
MoveCount int
CreatedAt string
UpdatedAt string
FinishedAt string
Seats []SeatRow
// HasRobot is true when any seat is a robot, gating the robot-target caption;
// RobotTargetPct is the configured global play-to-win rate, in percent.
HasRobot bool
RobotTargetPct int
}
// SeatRow is one seat of a game. For a robot seat (IsRobot) RobotIntent is the game's
// deterministic play-to-win decision ("play to win"/"play to lose"), and NextMove is the
// scheduled next-move ETA shown only while it is that robot's turn in an active game.
type SeatRow struct {
Seat int
DisplayName string
AccountID string
Score int
HintsUsed int
Winner bool
IsRobot bool
RobotIntent string
NextMove string
}
// ComplaintsView is the paginated complaint review queue.
type ComplaintsView struct {
Items []ComplaintRow
Status string
Pager Pager
}
// ComplaintRow is one complaint row in the queue.
type ComplaintRow struct {
ID string
Word string
Variant string
WasValid bool
Status string
Disposition string
CreatedAt string
}
// ComplaintDetailView is one complaint with its resolution state and form.
type ComplaintDetailView struct {
ID string
Word string
Variant string
DictVersion string
WasValid bool
Note string
Status string
Disposition string
ResolutionNote string
CreatedAt string
ResolvedAt string
GameID string
Resolved bool
}
// DictionaryView lists the resident versions per variant and the pending
// wordlist changes from accepted complaints.
type DictionaryView struct {
Variants []VariantVersions
Changes []DictChangeRow
}
// DictChangeRow is one pending wordlist edit.
type DictChangeRow struct {
Variant string
Word string
Action string
ResolvedAt string
}
// BroadcastView is the operator-broadcast form page.
type BroadcastView struct {
ConnectorEnabled bool
}
// ThrottledView is the rate-limit observability page: the recent gateway-reported
// throttle episodes (in-memory, reset on restart) and the accounts currently
// carrying the high-rate flag. FlagThreshold and FlagWindow caption the active
// auto-flag tuning.
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.
type MessageView struct {
Heading string
Body string
Back string
}
+45
View File
@@ -12,6 +12,7 @@ import (
"scrabble/backend/internal/game"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/telemetry"
)
@@ -35,9 +36,21 @@ type Config struct {
Lobby lobby.Config
// Robot configures the robot opponent driver (scan cadence).
Robot robot.Config
// RateWatch tunes the conservative high-rate auto-flag applied to the
// gateway's rate-limiter rejection reports.
RateWatch ratewatch.Config
// SMTP configures the email relay used for confirm-codes. An empty Host
// selects the development log mailer (the code is logged, not sent).
SMTP account.SMTPConfig
// ConnectorAddr is the gRPC address of the Telegram platform connector
// side-service, used by the admin console to send operator broadcasts. Empty
// disables broadcasts (the admin broadcast actions report "not configured").
ConnectorAddr string
// GuestReapInterval is the cadence of the abandoned-guest reaper sweep.
GuestReapInterval time.Duration
// GuestRetention is the account age past which an unused guest (no game seat)
// is eligible for deletion by the reaper.
GuestRetention time.Duration
}
// Defaults applied when the corresponding environment variable is unset.
@@ -45,6 +58,8 @@ const (
defaultHTTPAddr = ":8080"
defaultGRPCAddr = ":9090"
defaultLogLevel = "info"
defaultGuestReapInterval = time.Hour
defaultGuestRetention = 30 * 24 * time.Hour
)
// Load reads the configuration from the environment, applies defaults for unset
@@ -94,6 +109,23 @@ func Load() (Config, error) {
return Config{}, err
}
rw := ratewatch.DefaultConfig()
if rw.FlagThreshold, err = envInt("BACKEND_HIGHRATE_FLAG_THRESHOLD", rw.FlagThreshold); err != nil {
return Config{}, err
}
if rw.FlagWindow, err = envDuration("BACKEND_HIGHRATE_FLAG_WINDOW", rw.FlagWindow); err != nil {
return Config{}, err
}
guestReapInterval, err := envDuration("BACKEND_GUEST_REAP_INTERVAL", defaultGuestReapInterval)
if err != nil {
return Config{}, err
}
guestRetention, err := envDuration("BACKEND_GUEST_RETENTION", defaultGuestRetention)
if err != nil {
return Config{}, err
}
smtp := account.SMTPConfig{
Host: os.Getenv("BACKEND_SMTP_HOST"),
Port: envOr("BACKEND_SMTP_PORT", "587"),
@@ -111,7 +143,11 @@ func Load() (Config, error) {
Game: gm,
Lobby: lb,
Robot: rb,
RateWatch: rw,
SMTP: smtp,
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
GuestReapInterval: guestReapInterval,
GuestRetention: guestRetention,
}
if err := c.validate(); err != nil {
return Config{}, err
@@ -147,6 +183,15 @@ func (c Config) validate() error {
if err := c.Robot.Validate(); err != nil {
return fmt.Errorf("config: %w", err)
}
if err := c.RateWatch.Validate(); err != nil {
return fmt.Errorf("config: %w", err)
}
if c.GuestReapInterval <= 0 {
return fmt.Errorf("config: BACKEND_GUEST_REAP_INTERVAL must be positive")
}
if c.GuestRetention <= 0 {
return fmt.Errorf("config: BACKEND_GUEST_RETENTION must be positive")
}
return nil
}
+45 -3
View File
@@ -151,12 +151,54 @@ func TestLoadRejectsMalformedDuration(t *testing.T) {
}
}
// TestLoadRejectsUnsupportedExporter verifies that an exporter outside the MVP
// set is rejected.
// TestLoadRejectsUnsupportedExporter verifies that an exporter outside the
// supported set is rejected.
func TestLoadRejectsUnsupportedExporter(t *testing.T) {
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "otlp")
t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "prometheus")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for an unsupported exporter, got nil")
}
}
// TestLoadAcceptsOTLPExporter verifies that the otlp exporter is now accepted
// (the collector is stood up with the deploy; the default stays none).
func TestLoadAcceptsOTLPExporter(t *testing.T) {
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
t.Setenv("BACKEND_DICT_DIR", "/dict")
t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "otlp")
t.Setenv("BACKEND_OTEL_METRICS_EXPORTER", "otlp")
if _, err := Load(); err != nil {
t.Fatalf("Load with otlp exporters: %v", err)
}
}
// TestLoadGuestReaperDefaultsAndOverride covers the guest-reaper knobs: defaults
// when unset, an override, and rejection of a non-positive value.
func TestLoadGuestReaperDefaultsAndOverride(t *testing.T) {
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
t.Setenv("BACKEND_DICT_DIR", "/dict")
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.GuestReapInterval != defaultGuestReapInterval {
t.Errorf("GuestReapInterval = %s, want %s", c.GuestReapInterval, defaultGuestReapInterval)
}
if c.GuestRetention != defaultGuestRetention {
t.Errorf("GuestRetention = %s, want %s", c.GuestRetention, defaultGuestRetention)
}
t.Setenv("BACKEND_GUEST_RETENTION", "168h")
if c, err = Load(); err != nil {
t.Fatalf("Load (override): %v", err)
} else if c.GuestRetention != 168*time.Hour {
t.Errorf("GuestRetention = %s, want 168h", c.GuestRetention)
}
t.Setenv("BACKEND_GUEST_REAP_INTERVAL", "0s")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for a non-positive reap interval, got nil")
}
}
+60
View File
@@ -0,0 +1,60 @@
// Package connector is the backend's gRPC client for the Telegram platform
// connector side-service. The admin console uses it to send operator broadcasts:
// a direct message to one user, or a post to a game channel. Each broadcast
// selects the delivering bot by language (an operator choice, since the connector
// hosts one bot per service language). The connector lives on the trusted internal
// network, so the connection uses insecure (plaintext) transport credentials
// (docs/ARCHITECTURE.md §12). It mirrors gateway/internal/connector, narrowed to
// the two broadcast methods the admin surface needs.
package connector
import (
"context"
"fmt"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
telegramv1 "scrabble/pkg/proto/telegram/v1"
)
// Client wraps the connector's Telegram gRPC service.
type Client struct {
conn *grpc.ClientConn
c telegramv1.TelegramClient
}
// New dials the connector gRPC endpoint at addr.
func New(addr string) (*Client, error) {
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, fmt.Errorf("connector: dial %s: %w", addr, err)
}
return &Client{conn: conn, c: telegramv1.NewTelegramClient(conn)}, nil
}
// Close releases the gRPC connection.
func (c *Client) Close() error { return c.conn.Close() }
// SendToUser sends an operator text message to one user, addressed by their
// platform external_id, through the bot for the given language. delivered reports
// whether the connector actually sent it (false when the user has not started that
// bot).
func (c *Client) SendToUser(ctx context.Context, externalID, text, language string) (bool, error) {
resp, err := c.c.SendToUser(ctx, &telegramv1.SendToUserRequest{ExternalId: externalID, Text: text, Language: language})
if err != nil {
return false, err
}
return resp.GetDelivered(), nil
}
// SendToGameChannel posts an operator text message to the game channel of the bot
// for the given language. delivered reports whether the connector sent it (false
// when that bot has no channel configured).
func (c *Client) SendToGameChannel(ctx context.Context, text, language string) (bool, error) {
resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text, Language: language})
if err != nil {
return false, err
}
return resp.GetDelivered(), nil
}
+150
View File
@@ -0,0 +1,150 @@
package engine
import (
"fmt"
"strings"
)
// AlphabetEntry is one letter of a variant's alphabet: its alphabet-index byte, the
// concrete character and its tile point value. It is the dictionary-independent display
// table the edge sends to the client, produced from the variant's solver
// ruleset (its alphabet and value table) and so pinned by the solver version, not by any
// dictionary.
type AlphabetEntry struct {
// Index is the alphabet-index byte the wire uses for this letter (0..Size-1).
Index byte
// Letter is the concrete character, in the case the solver ruleset emits (lower).
Letter string
// Value is the tile's point score.
Value int
}
// BlankIndex is the wire sentinel for a blank tile inside an alphabet-index sequence (a
// rack or an exchange list). It is out of range of every offered variant's alphabet (the
// largest has 33 letters), so it never collides with a real letter index. A placed blank
// instead travels as an ordinary tile carrying its designated letter's index alongside a
// separate blank flag. The constant is untyped so it serves both byte (FlatBuffers ubyte)
// and int (the gateway/backend JSON edge) call sites.
const BlankIndex = 0xFF
// variantCodec is the cached per-variant alphabet data backing the wire helpers: the
// ordered display table and a case-insensitive letter→index lookup. Both are derived once
// from the solver ruleset (see variantCodecs).
type variantCodec struct {
table []AlphabetEntry
letterToIndex map[string]byte
}
// variantCodecs holds one codec per offered variant, built once at package load from each
// ruleset's alphabet and value table. The rulesets are needed only here (not per request),
// so the hot path never rebuilds them.
var variantCodecs = buildVariantCodecs()
func buildVariantCodecs() map[Variant]*variantCodec {
m := make(map[Variant]*variantCodec, len(Variants()))
for _, v := range Variants() {
rs, ok := v.ruleset()
if !ok {
continue
}
size := rs.Alphabet.Size()
table := make([]AlphabetEntry, size)
lut := make(map[string]byte, size)
for i := range size {
ch, err := rs.Alphabet.Character(byte(i))
if err != nil {
// An offered variant's alphabet never yields a bad index; skip defensively.
continue
}
table[i] = AlphabetEntry{Index: byte(i), Letter: ch, Value: rs.Values[i]}
lut[strings.ToLower(ch)] = byte(i)
}
m[v] = &variantCodec{table: table, letterToIndex: lut}
}
return m
}
// AlphabetTable returns a copy of variant's full alphabet as an ordered (index, letter,
// value) table, or ErrUnknownVariant. Entry i has Index i, so the slice doubles as an
// index→(letter, value) lookup. It needs no dictionary — the data comes from the solver
// ruleset alone — so it is safe to build for any offered variant and is the same table the
// client caches for display while live play exchanges bare indices.
func AlphabetTable(v Variant) ([]AlphabetEntry, error) {
c, ok := variantCodecs[v]
if !ok {
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
}
out := make([]AlphabetEntry, len(c.table))
copy(out, c.table)
return out, nil
}
// LetterForIndex maps one alphabet index to its concrete letter for variant. It is the
// wire-decode primitive for a placed tile (a blank carries its designated letter's index).
// An out-of-range index is an illegal play.
func LetterForIndex(v Variant, idx int) (string, error) {
c, ok := variantCodecs[v]
if !ok {
return "", fmt.Errorf("%w: %d", ErrUnknownVariant, v)
}
if idx < 0 || idx >= len(c.table) {
return "", fmt.Errorf("%w: alphabet index %d for %s", ErrIllegalPlay, idx, v)
}
return c.table[idx].Letter, nil
}
// EncodeRack maps a decoded rack (the Game.Hand form: concrete letters with "?" for an
// undesignated blank) to wire alphabet indices, using BlankIndex for each blank. It backs
// the per-player state view, whose rack the client renders via the cached table.
func EncodeRack(v Variant, letters []string) ([]int, error) {
c, ok := variantCodecs[v]
if !ok {
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
}
out := make([]int, len(letters))
for i, l := range letters {
if l == blankLetter {
out[i] = BlankIndex
continue
}
idx, ok := c.letterToIndex[strings.ToLower(l)]
if !ok {
return nil, fmt.Errorf("%w: rack letter %q for %s", ErrTilesNotOnRack, l, v)
}
out[i] = int(idx)
}
return out, nil
}
// DecodeTiles maps a wire rack/exchange index list back to the decoded letter form ("?"
// for a blank, BlankIndex), for handing to the existing letter-based exchange path.
func DecodeTiles(v Variant, idx []int) ([]string, error) {
out := make([]string, len(idx))
for i, x := range idx {
if x == BlankIndex {
out[i] = blankLetter
continue
}
l, err := LetterForIndex(v, x)
if err != nil {
return nil, fmt.Errorf("%w (exchange)", err)
}
out[i] = l
}
return out, nil
}
// DecodeWord maps a sequence of alphabet indices to a concrete word (word-check carries no
// blanks). The client constrains input to the variant's alphabet, so every index is a real
// letter.
func DecodeWord(v Variant, idx []int) (string, error) {
var sb strings.Builder
for _, x := range idx {
l, err := LetterForIndex(v, x)
if err != nil {
return "", fmt.Errorf("%w (word check)", err)
}
sb.WriteString(l)
}
return sb.String(), nil
}
+110
View File
@@ -0,0 +1,110 @@
package engine
import (
"errors"
"slices"
"testing"
)
// TestAlphabetTableEnglish pins the English table against the solver ruleset: 26 letters,
// contiguous indices, the concrete lower-case characters the solver emits and the standard
// tile values. This is the real parity check the UI no longer carries.
func TestAlphabetTableEnglish(t *testing.T) {
tab, err := AlphabetTable(VariantEnglish)
if err != nil {
t.Fatalf("AlphabetTable(scrabble_en): %v", err)
}
if len(tab) != 26 {
t.Fatalf("size = %d, want 26", len(tab))
}
for i, e := range tab {
if int(e.Index) != i {
t.Errorf("entry %d has Index %d, want %d (index must equal position)", i, e.Index, i)
}
}
// a=index0/value1, q=index16/value10, z=index25/value10.
if tab[0].Letter != "a" || tab[0].Value != 1 {
t.Errorf("entry 0 = %q/%d, want a/1", tab[0].Letter, tab[0].Value)
}
if tab[16].Letter != "q" || tab[16].Value != 10 {
t.Errorf("entry 16 = %q/%d, want q/10", tab[16].Letter, tab[16].Value)
}
if tab[25].Letter != "z" || tab[25].Value != 10 {
t.Errorf("entry 25 = %q/%d, want z/10", tab[25].Letter, tab[25].Value)
}
}
// TestAlphabetTableRussianVariants pins both Russian variants: they share the 33-letter
// alphabet but differ in tile values — most visibly ё (index 6), worth 3 in Russian
// Scrabble and 0 in Эрудит.
func TestAlphabetTableRussianVariants(t *testing.T) {
ru, err := AlphabetTable(VariantRussianScrabble)
if err != nil {
t.Fatalf("AlphabetTable(scrabble_ru): %v", err)
}
er, err := AlphabetTable(VariantErudit)
if err != nil {
t.Fatalf("AlphabetTable(erudit_ru): %v", err)
}
if len(ru) != 33 || len(er) != 33 {
t.Fatalf("sizes = %d/%d, want 33/33", len(ru), len(er))
}
if ru[0].Letter != "а" || ru[0].Value != 1 {
t.Errorf("scrabble_ru entry 0 = %q/%d, want а/1", ru[0].Letter, ru[0].Value)
}
if ru[6].Letter != "ё" || ru[6].Value != 3 {
t.Errorf("scrabble_ru ё (entry 6) = %q/%d, want ё/3", ru[6].Letter, ru[6].Value)
}
if er[6].Letter != "ё" || er[6].Value != 0 {
t.Errorf("erudit_ru ё (entry 6) = %q/%d, want ё/0", er[6].Letter, er[6].Value)
}
if ru[32].Letter != "я" || er[32].Letter != "я" {
t.Errorf("last letter = %q/%q, want я/я", ru[32].Letter, er[32].Letter)
}
}
// TestAlphabetTableUnknownVariant rejects a variant outside the catalogue.
func TestAlphabetTableUnknownVariant(t *testing.T) {
if _, err := AlphabetTable(Variant(99)); !errors.Is(err, ErrUnknownVariant) {
t.Fatalf("got %v, want ErrUnknownVariant", err)
}
}
// TestRackCodecRoundTrip pins the rack/exchange index codec the edge uses: EncodeRack maps
// concrete letters (with "?" for a blank) to indices (BlankIndex for the blank) and
// DecodeTiles inverts it. EncodeRack is case-insensitive so it accepts the lower-case
// Hand form and an upper-case letter alike.
func TestRackCodecRoundTrip(t *testing.T) {
letters := []string{"c", "a", "t", "?"}
idx, err := EncodeRack(VariantEnglish, letters)
if err != nil {
t.Fatalf("EncodeRack: %v", err)
}
if want := []int{2, 0, 19, BlankIndex}; !slices.Equal(idx, want) {
t.Fatalf("EncodeRack = %v, want %v", idx, want)
}
back, err := DecodeTiles(VariantEnglish, idx)
if err != nil {
t.Fatalf("DecodeTiles: %v", err)
}
if !slices.Equal(back, letters) {
t.Fatalf("DecodeTiles = %v, want %v", back, letters)
}
if up, err := EncodeRack(VariantEnglish, []string{"C"}); err != nil || !slices.Equal(up, []int{2}) {
t.Errorf("EncodeRack upper-case = %v,%v; want [2],nil", up, err)
}
}
// TestDecodeWordAndBounds covers the word-check decode and the out-of-range guard.
func TestDecodeWordAndBounds(t *testing.T) {
w, err := DecodeWord(VariantEnglish, []int{2, 0, 19})
if err != nil || w != "cat" {
t.Fatalf("DecodeWord = %q,%v; want cat,nil", w, err)
}
if _, err := LetterForIndex(VariantEnglish, 26); !errors.Is(err, ErrIllegalPlay) {
t.Errorf("out-of-range index: got %v, want ErrIllegalPlay", err)
}
if _, err := DecodeWord(VariantEnglish, []int{BlankIndex}); !errors.Is(err, ErrIllegalPlay) {
t.Errorf("blank in word: got %v, want ErrIllegalPlay", err)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package engine
import (
"math/rand"
"scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
)
// blankTile marks a blank tile in a hand or in the bag, matching the
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"slices"
"testing"
"scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
)
// allTiles returns the full multiset of tiles a bag is filled from, in ruleset
+3 -3
View File
@@ -3,9 +3,9 @@ package engine
import (
"fmt"
"scrabble-solver/board"
"scrabble-solver/rules"
"scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// ActionKind classifies a turn in the move log.
+1 -1
View File
@@ -3,7 +3,7 @@ package engine
import (
"testing"
"scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// blankCellFlag is the bit board cells set for a blank tile (board.go encoding).
+1 -1
View File
@@ -3,7 +3,7 @@ package engine
import (
"fmt"
"scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// blankLetter is how a blank tile is written in the decoded, domain-facing API:
+4 -4
View File
@@ -168,10 +168,10 @@ func TestRegistryLookup(t *testing.T) {
word string
want bool
}{
{"english hit", VariantEnglish, "cat", true},
{"english miss", VariantEnglish, "zzzz", false},
{"russian hit", VariantRussianScrabble, "кот", true},
{"erudit hit", VariantErudit, "кот", true},
{"scrabble_en hit", VariantEnglish, "cat", true},
{"scrabble_en miss", VariantEnglish, "zzzz", false},
{"scrabble_ru hit", VariantRussianScrabble, "кот", true},
{"erudit_ru hit", VariantErudit, "кот", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
+14 -4
View File
@@ -17,7 +17,7 @@ import (
"errors"
"fmt"
"scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
)
// Variant identifies a Scrabble variant the backend offers. Each maps to a
@@ -38,15 +38,25 @@ const (
func (v Variant) String() string {
switch v {
case VariantEnglish:
return "english"
return "scrabble_en"
case VariantRussianScrabble:
return "russian_scrabble"
return "scrabble_ru"
case VariantErudit:
return "erudit"
return "erudit_ru"
}
return "unknown"
}
// Language returns the variant's interface/bot language tag: "en" for English Scrabble, "ru" for
// the Russian variants (Russian Scrabble and Erudite). It routes a game's out-of-app push to the
// matching per-language Telegram bot — by the game, not the recipient's last-login bot.
func (v Variant) Language() string {
if v == VariantEnglish {
return "en"
}
return "ru"
}
// ruleset returns the scrabble-solver ruleset backing the variant and true, or
// (nil, false) for an unrecognised variant.
func (v Variant) ruleset() (*rules.Ruleset, bool) {
+21 -9
View File
@@ -3,10 +3,10 @@ package engine
import (
"fmt"
"scrabble-solver/board"
"scrabble-solver/rack"
"scrabble-solver/rules"
"scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// scorelessLimit is the number of consecutive scoreless turns (passes and
@@ -248,17 +248,29 @@ func (g *Game) Exchange(tiles []byte) (MoveRecord, error) {
// winning regardless of score. A missed-turn timeout reuses Resign in the game
// domain, so it inherits this win/loss.
func (g *Game) Resign() (MoveRecord, error) {
return g.ResignSeat(g.toMove)
}
// ResignSeat resigns a specific seat regardless of whose turn it is, so a player
// may forfeit on the opponent's turn. The resigning seat always loses (winner()
// skips resigned seats). The turn cursor only advances when the seat that resigned
// was the one to move; resigning an off-turn seat leaves the current player's turn
// intact. It returns ErrGameOver on a finished game or for an out-of-range or
// already-resigned seat.
func (g *Game) ResignSeat(seat int) (MoveRecord, error) {
if g.over {
return MoveRecord{}, ErrGameOver
}
player := g.toMove
g.resigned[player] = true
g.disposeHand(player)
rec := MoveRecord{Player: player, Action: ActionResign, Total: g.scores[player]}
if seat < 0 || seat >= len(g.hands) || g.resigned[seat] {
return MoveRecord{}, ErrGameOver
}
g.resigned[seat] = true
g.disposeHand(seat)
rec := MoveRecord{Player: seat, Action: ActionResign, Total: g.scores[seat]}
g.log = append(g.log, rec)
if g.activeCount() <= 1 {
g.finish(EndResign)
} else {
} else if seat == g.toMove {
g.advance()
}
return rec, nil
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"errors"
"testing"
"scrabble-solver/board"
"scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// newEnglishGame starts a two-player English game with the given seed.
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"runtime"
"testing"
"scrabble-solver/rules"
"scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// testVersion labels the single dictionary version the tests register.
+55 -1
View File
@@ -2,13 +2,14 @@ package engine
import (
"fmt"
"os"
"path/filepath"
"sort"
"sync"
dawg "github.com/iliadenisov/dafsa"
"scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// dictFiles maps each variant to its committed DAWG filename, as built by
@@ -63,6 +64,36 @@ func Open(dir, version string, variants ...Variant) (*Registry, error) {
return r, nil
}
// OpenWithVersions builds a Registry by loading the boot version from the flat
// dir (every variant, as Open) and then every additional version held in an
// immediate subdirectory of dir: a subdirectory named V contributes, under
// version V, the variants whose committed DAWG it carries. This is the
// restart-side of the admin dictionary reload — a version reloaded into dir/<V>/
// at runtime is resident again after a restart. A subdirectory named like the
// boot version is skipped (the flat dir already is the boot version). A partially
// loaded registry is closed before any error is returned.
func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
r, err := Open(dir, bootVersion)
if err != nil {
return nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
_ = r.Close()
return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err)
}
for _, e := range entries {
if !e.IsDir() || e.Name() == bootVersion {
continue
}
if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); err != nil {
_ = r.Close()
return nil, err
}
}
return r, nil
}
// Load reads the committed DAWG of variant from dir, builds a solver over it and
// registers it under version. Reloading the same (variant, version) replaces the
// previous entry, closing its finder. The most recently loaded version of a
@@ -91,6 +122,29 @@ func (r *Registry) Load(v Variant, version, dir string) error {
return nil
}
// LoadAvailable loads, under version, every variant whose committed DAWG is
// present in dir, skipping a variant whose file is absent. It backs the admin
// dictionary reload (a version subdirectory may carry only the variants that were
// rebuilt) and OpenWithVersions' boot-time scan. It returns the variants it
// loaded, in catalogue order, or the first load error.
func (r *Registry) LoadAvailable(dir, version string) ([]Variant, error) {
var loaded []Variant
for _, v := range Variants() {
path := filepath.Join(dir, dictFiles[v])
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
continue
}
return loaded, fmt.Errorf("engine: stat %s dictionary %q in %s: %w", v, version, dir, err)
}
if err := r.Load(v, version, dir); err != nil {
return loaded, err
}
loaded = append(loaded, v)
}
return loaded, nil
}
// Solver returns the solver for the (variant, version) pair, or ErrUnknownVariant
// when the variant is absent and ErrUnknownVersion when only the version is.
func (r *Registry) Solver(v Variant, version string) (*scrabble.Solver, error) {
+3 -3
View File
@@ -4,8 +4,8 @@ import (
"errors"
"testing"
"scrabble-solver/board"
"scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// TestRegistryOpensEveryVariant checks that Open loads all three variants at the
@@ -60,7 +60,7 @@ func TestRegistryValidatesKnownWords(t *testing.T) {
func TestRegistryUnknownLookups(t *testing.T) {
reg, err := Open(testDictDir(), testVersion, VariantEnglish)
if err != nil {
t.Fatalf("open english-only registry: %v", err)
t.Fatalf("open scrabble_en-only registry: %v", err)
}
defer reg.Close()
+119
View File
@@ -0,0 +1,119 @@
package engine
import (
"errors"
"io"
"os"
"path/filepath"
"testing"
)
// copyDawg copies the committed DAWG for v from srcDir into dstDir (creating
// dstDir). It is the fixture builder for the dictionary-reload tests, which need
// real DAWG files laid out in temporary version directories.
func copyDawg(t *testing.T, srcDir, dstDir string, v Variant) {
t.Helper()
if err := os.MkdirAll(dstDir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dstDir, err)
}
name := dictFiles[v]
src, err := os.Open(filepath.Join(srcDir, name))
if err != nil {
t.Fatalf("open source dawg %s: %v", name, err)
}
defer func() { _ = src.Close() }()
dst, err := os.Create(filepath.Join(dstDir, name))
if err != nil {
t.Fatalf("create dest dawg %s: %v", name, err)
}
defer func() { _ = dst.Close() }()
if _, err := io.Copy(dst, src); err != nil {
t.Fatalf("copy dawg %s: %v", name, err)
}
}
// TestLoadAvailableLoadsPresentSkipsAbsent verifies LoadAvailable registers only
// the variants whose DAWG is present in the directory, under the given version.
func TestLoadAvailableLoadsPresentSkipsAbsent(t *testing.T) {
dir := t.TempDir()
copyDawg(t, testDictDir(), dir, VariantEnglish) // only English present
reg := NewRegistry()
defer func() { _ = reg.Close() }()
loaded, err := reg.LoadAvailable(dir, "v2")
if err != nil {
t.Fatalf("load available: %v", err)
}
if len(loaded) != 1 || loaded[0] != VariantEnglish {
t.Fatalf("loaded = %v, want [scrabble_en]", loaded)
}
if _, err := reg.Solver(VariantEnglish, "v2"); err != nil {
t.Errorf("scrabble_en v2 solver: %v", err)
}
if _, err := reg.Solver(VariantRussianScrabble, "v2"); !errors.Is(err, ErrUnknownVariant) {
t.Errorf("scrabble_ru v2 should be absent: got %v", err)
}
}
// TestOpenWithVersionsScansSubdirs verifies the boot helper loads the flat boot
// version plus every version subdirectory, the subdir version becoming the
// variant's latest while the boot version stays resident.
func TestOpenWithVersionsScansSubdirs(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() { // flat boot version: all three variants
copyDawg(t, testDictDir(), dir, v)
}
copyDawg(t, testDictDir(), filepath.Join(dir, "v2"), VariantEnglish) // v2 subdir: English only
reg, err := OpenWithVersions(dir, "v1")
if err != nil {
t.Fatalf("open with versions: %v", err)
}
defer func() { _ = reg.Close() }()
for _, v := range Variants() {
if _, err := reg.Solver(v, "v1"); err != nil {
t.Errorf("boot solver %s/v1: %v", v, err)
}
}
if got := reg.Versions(VariantEnglish); len(got) != 2 {
t.Errorf("scrabble_en versions = %v, want two", got)
}
latest, _, err := reg.Latest(VariantEnglish)
if err != nil {
t.Fatalf("latest scrabble_en: %v", err)
}
if latest != "v2" {
t.Errorf("latest scrabble_en = %q, want v2", latest)
}
if got := reg.Versions(VariantRussianScrabble); len(got) != 1 {
t.Errorf("scrabble_ru versions = %v, want one (no v2 file)", got)
}
}
// TestReloadRegistersNewVersion verifies Load adds a second version to a variant
// already resident, moves the latest pointer and keeps the earlier version.
func TestReloadRegistersNewVersion(t *testing.T) {
reg, err := Open(testDictDir(), "v1", VariantEnglish)
if err != nil {
t.Fatalf("open: %v", err)
}
defer func() { _ = reg.Close() }()
if err := reg.Load(VariantEnglish, "v2", testDictDir()); err != nil {
t.Fatalf("reload v2: %v", err)
}
if got := reg.Versions(VariantEnglish); len(got) != 2 {
t.Fatalf("versions = %v, want two", got)
}
latest, _, err := reg.Latest(VariantEnglish)
if err != nil {
t.Fatalf("latest: %v", err)
}
if latest != "v2" {
t.Errorf("latest = %q, want v2", latest)
}
if _, err := reg.Solver(VariantEnglish, "v1"); err != nil {
t.Errorf("v1 still resident: %v", err)
}
}
+33
View File
@@ -69,6 +69,39 @@ func TestResignTrailingPlayerLoses(t *testing.T) {
}
}
// TestResignSeatOffTurn covers a forfeit on the opponent's turn: after player 0
// moves it is player 1's turn, yet player 0 resigns its own seat — the resigner
// loses, the opponent wins, and the game ends.
func TestResignSeatOffTurn(t *testing.T) {
g := openingGame(t)
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
if _, err := g.SubmitPlay(hint.Dir, hint.Tiles); err != nil { // player 0 moves
t.Fatalf("player 0 play: %v", err)
}
if g.ToMove() != 1 {
t.Fatalf("after player 0's move, toMove = %d, want 1", g.ToMove())
}
// Player 0 resigns although it is player 1's turn.
rec, err := g.ResignSeat(0)
if err != nil {
t.Fatalf("player 0 off-turn resign: %v", err)
}
if rec.Player != 0 || rec.Action != ActionResign {
t.Errorf("resign record = seat %d action %v, want seat 0 resign", rec.Player, rec.Action)
}
if !g.Over() || g.Reason() != EndResign {
t.Fatalf("game over=%v reason=%v, want over with resign", g.Over(), g.Reason())
}
if res := g.Result(); res.Winner != 1 {
t.Errorf("winner = %d, want 1 (the non-resigner)", res.Winner)
}
}
// TestResignOnFinishedGame rejects a second transition.
func TestResignOnFinishedGame(t *testing.T) {
g := newEnglishGame(t, 1)
+19
View File
@@ -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)
}
}
}
+116
View File
@@ -0,0 +1,116 @@
package game
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
)
// A move's "duration" is the think time from the previous move's commit (the moment
// the turn started) to this move's commit. Only play/pass/exchange moves count;
// timeouts and resignations are not think time. The very first move of a game has no
// previous move, so its baseline is the game's creation time. The figures are derived
// from the move journal (game_moves.created_at), so no schema change is needed.
//
// timedMovesCTE is the shared subquery yielding (account, game, ordinal, seconds) for
// every timed move; the two reports aggregate it differently.
const timedMovesCTE = `
SELECT gp.account_id AS aid,
m.game_id AS gid,
ROW_NUMBER() OVER (PARTITION BY m.game_id ORDER BY m.seq) AS ord,
EXTRACT(EPOCH FROM (m.created_at - COALESCE(prev.created_at, g.created_at))) AS secs
FROM backend.game_moves m
JOIN backend.games g ON g.game_id = m.game_id
LEFT JOIN backend.game_moves prev ON prev.game_id = m.game_id AND prev.seq = m.seq - 1
JOIN backend.game_players gp ON gp.game_id = m.game_id AND gp.seat = m.seat
WHERE m.action IN ('play', 'pass', 'exchange')`
// MoveDurationStat is the min, max and mean per-move think time (in seconds) for an
// account across all its games, with the number of timed moves counted.
type MoveDurationStat struct {
MinSecs float64
MaxSecs float64
AvgSecs float64
Moves int
}
// MoveDurationStats returns the move-duration summary for each of accountIDs that has
// at least one timed move; accounts with none are absent from the map. It powers the
// admin user-list columns. The scan over the journal is acceptable for the low-traffic
// console; per-human analysis is the authoritative use (the live metric aggregates all
// seats including robots).
func (s *Store) MoveDurationStats(ctx context.Context, accountIDs []uuid.UUID) (map[uuid.UUID]MoveDurationStat, error) {
if len(accountIDs) == 0 {
return map[uuid.UUID]MoveDurationStat{}, nil
}
q := `WITH d AS (` + timedMovesCTE + `)
SELECT aid, MIN(secs), MAX(secs), AVG(secs), COUNT(*) FROM d WHERE aid = ANY($1::uuid[]) GROUP BY aid`
rows, err := s.db.QueryContext(ctx, q, uuidArrayLiteral(accountIDs))
if err != nil {
return nil, fmt.Errorf("game: move-duration stats: %w", err)
}
defer rows.Close()
out := make(map[uuid.UUID]MoveDurationStat, len(accountIDs))
for rows.Next() {
var id uuid.UUID
var st MoveDurationStat
if err := rows.Scan(&id, &st.MinSecs, &st.MaxSecs, &st.AvgSecs, &st.Moves); err != nil {
return nil, fmt.Errorf("game: scan move-duration stat: %w", err)
}
out[id] = st
}
return out, rows.Err()
}
// OrdinalDuration is the min/max/mean think time (seconds) at an account's k-th move
// (Ordinal) across all its games.
type OrdinalDuration struct {
Ordinal int
MinSecs float64
MaxSecs float64
AvgSecs float64
}
// MoveDurationByOrdinal returns the account's per-move-number think-time summary,
// ordered by move number, for the admin user-detail chart. The ordinal counts the
// account's own moves within each game (its 1st, 2nd, … move).
func (s *Store) MoveDurationByOrdinal(ctx context.Context, accountID uuid.UUID) ([]OrdinalDuration, error) {
q := `WITH d AS (` + timedMovesCTE + ` AND gp.account_id = $1)
SELECT ord, MIN(secs), MAX(secs), AVG(secs) FROM d GROUP BY ord ORDER BY ord`
rows, err := s.db.QueryContext(ctx, q, accountID)
if err != nil {
return nil, fmt.Errorf("game: move-duration by ordinal: %w", err)
}
defer rows.Close()
var out []OrdinalDuration
for rows.Next() {
var od OrdinalDuration
if err := rows.Scan(&od.Ordinal, &od.MinSecs, &od.MaxSecs, &od.AvgSecs); err != nil {
return nil, fmt.Errorf("game: scan ordinal duration: %w", err)
}
out = append(out, od)
}
return out, rows.Err()
}
// uuidArrayLiteral renders ids as a Postgres array literal ("{u1,u2,…}") for an
// ANY($1::uuid[]) parameter. UUIDs are fixed-format, so the literal is injection-safe.
func uuidArrayLiteral(ids []uuid.UUID) string {
ss := make([]string, len(ids))
for i, id := range ids {
ss[i] = id.String()
}
return "{" + strings.Join(ss, ",") + "}"
}
// MoveDurationStats exposes the store report to the admin console handlers.
func (svc *Service) MoveDurationStats(ctx context.Context, accountIDs []uuid.UUID) (map[uuid.UUID]MoveDurationStat, error) {
return svc.store.MoveDurationStats(ctx, accountIDs)
}
// MoveDurationByOrdinal exposes the per-move-number report to the admin console.
func (svc *Service) MoveDurationByOrdinal(ctx context.Context, accountID uuid.UUID) ([]OrdinalDuration, error) {
return svc.store.MoveDurationByOrdinal(ctx, accountID)
}
+18 -3
View File
@@ -63,6 +63,7 @@ type gameCache struct {
type cachedGame struct {
game *engine.Game
variant string
lastAccess time.Time
}
@@ -82,11 +83,12 @@ func (c *gameCache) get(id uuid.UUID) (*engine.Game, bool) {
return e.game, true
}
// put stores g as the live game for id.
func (c *gameCache) put(id uuid.UUID, g *engine.Game) {
// put stores g as the live game for id. variant labels the entry so the active-
// games gauge can report counts by variant without inspecting engine internals.
func (c *gameCache) put(id uuid.UUID, g *engine.Game, variant string) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[id] = &cachedGame{game: g, lastAccess: c.now()}
c.entries[id] = &cachedGame{game: g, variant: variant, lastAccess: c.now()}
}
// remove drops id from the cache (used on a finished game and after a failed
@@ -119,3 +121,16 @@ func (c *gameCache) size() int {
defer c.mu.Unlock()
return len(c.entries)
}
// countByVariant tallies the resident games by their variant label. It backs the
// game_cache_active observable gauge; the resident set is the bounded number of
// live (active) games, so the scan under the lock is cheap.
func (c *gameCache) countByVariant() map[string]int {
c.mu.Lock()
defer c.mu.Unlock()
out := make(map[string]int, len(c.entries))
for _, e := range c.entries {
out[e.variant]++
}
return out
}
+163
View File
@@ -0,0 +1,163 @@
package game
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"slices"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
// DraftTile is one tile a player has laid on the board but not yet submitted.
type DraftTile struct {
Row int `json:"row"`
Col int `json:"col"`
Letter string `json:"letter"`
Blank bool `json:"blank"`
}
// Draft is a player's persisted client-side composition for a game: the
// preferred rack tile order and the board tiles laid but not yet submitted. The server
// keeps it so a reload or a second device resumes the same arrangement.
type Draft struct {
RackOrder string
BoardTiles []DraftTile
}
// GetDraft returns the player's draft for a game, or a zero Draft when none is stored.
func (svc *Service) GetDraft(ctx context.Context, gameID, accountID uuid.UUID) (Draft, error) {
return svc.store.getDraft(ctx, gameID, accountID)
}
// SaveDraft upserts the player's draft; the account must be seated in the game.
func (svc *Service) SaveDraft(ctx context.Context, gameID, accountID uuid.UUID, d Draft) error {
seats, _, _, err := svc.Participants(ctx, gameID)
if err != nil {
return err
}
if !slices.Contains(seats, accountID) {
return ErrNotAPlayer
}
return svc.store.saveDraft(ctx, gameID, accountID, d)
}
// getDraft reads one draft row, returning a zero Draft when absent.
func (s *Store) getDraft(ctx context.Context, gameID, accountID uuid.UUID) (Draft, error) {
var rackOrder string
var boardJSON []byte
err := s.db.QueryRowContext(ctx,
`SELECT rack_order, board_tiles FROM backend.game_drafts WHERE game_id = $1 AND account_id = $2`,
gameID, accountID).Scan(&rackOrder, &boardJSON)
if errors.Is(err, sql.ErrNoRows) {
return Draft{}, nil
}
if err != nil {
return Draft{}, fmt.Errorf("game: get draft %s: %w", gameID, err)
}
d := Draft{RackOrder: rackOrder}
if len(boardJSON) > 0 {
if err := json.Unmarshal(boardJSON, &d.BoardTiles); err != nil {
return Draft{}, fmt.Errorf("game: decode draft tiles: %w", err)
}
}
return d, nil
}
// saveDraft upserts the player's draft.
func (s *Store) saveDraft(ctx context.Context, gameID, accountID uuid.UUID, d Draft) error {
tiles := d.BoardTiles
if tiles == nil {
tiles = []DraftTile{}
}
boardJSON, err := json.Marshal(tiles)
if err != nil {
return fmt.Errorf("game: encode draft tiles: %w", err)
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO backend.game_drafts (game_id, account_id, rack_order, board_tiles, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (game_id, account_id)
DO UPDATE SET rack_order = $3, board_tiles = $4, updated_at = now()`,
gameID, accountID, d.RackOrder, boardJSON); err != nil {
return fmt.Errorf("game: save draft: %w", err)
}
return nil
}
// clearDraft drops a player's draft row (their composition is consumed or discarded).
func (s *Store) clearDraft(ctx context.Context, gameID, accountID uuid.UUID) error {
if _, err := s.db.ExecContext(ctx,
`DELETE FROM backend.game_drafts WHERE game_id = $1 AND account_id = $2`,
gameID, accountID); err != nil {
return fmt.Errorf("game: clear draft: %w", err)
}
return nil
}
// resetConflictingBoardDrafts clears the board_tiles of every OTHER player's draft that has
// a tile on one of the just-committed cells, since that draft can no longer be placed; the
// rack order is kept.
func (s *Store) resetConflictingBoardDrafts(ctx context.Context, gameID, actorID uuid.UUID, cells []DraftTile) error {
if len(cells) == 0 {
return nil
}
occupied := make(map[[2]int]bool, len(cells))
for _, c := range cells {
occupied[[2]int{c.Row, c.Col}] = true
}
rows, err := s.db.QueryContext(ctx,
`SELECT account_id, board_tiles FROM backend.game_drafts
WHERE game_id = $1 AND account_id <> $2 AND board_tiles <> '[]'::jsonb`,
gameID, actorID)
if err != nil {
return fmt.Errorf("game: scan drafts for conflict: %w", err)
}
var toClear []uuid.UUID
func() {
defer func() { _ = rows.Close() }()
for rows.Next() {
var acc uuid.UUID
var boardJSON []byte
if err = rows.Scan(&acc, &boardJSON); err != nil {
return
}
var tiles []DraftTile
if json.Unmarshal(boardJSON, &tiles) != nil {
continue // skip a malformed draft
}
for _, t := range tiles {
if occupied[[2]int{t.Row, t.Col}] {
toClear = append(toClear, acc)
break
}
}
}
err = rows.Err()
}()
if err != nil {
return fmt.Errorf("game: read drafts for conflict: %w", err)
}
for _, acc := range toClear {
if _, err := s.db.ExecContext(ctx,
`UPDATE backend.game_drafts SET board_tiles = '[]'::jsonb, updated_at = now()
WHERE game_id = $1 AND account_id = $2`,
gameID, acc); err != nil {
return fmt.Errorf("game: clear conflicting draft: %w", err)
}
}
return nil
}
// draftTilesFrom projects a play's committed tiles into draft cells, for the conflict scan.
func draftTilesFrom(rec engine.MoveRecord) []DraftTile {
out := make([]DraftTile, 0, len(rec.Tiles))
for _, t := range rec.Tiles {
out = append(out, DraftTile{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
}
return out
}
+112
View File
@@ -0,0 +1,112 @@
package game
import (
"context"
"slices"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/notify"
fb "scrabble/pkg/fbs/scrabblefb"
)
// recordingPublisher captures every published intent for assertions.
type recordingPublisher struct{ intents []notify.Intent }
func (p *recordingPublisher) Publish(in ...notify.Intent) { p.intents = append(p.intents, in...) }
// TestEmitMoveNotifiesActor checks a committed move sends opponent_moved to every
// seat — including the actor's own account, so the mover's other devices refresh —
// and your_turn only to the next mover.
func TestEmitMoveNotifiesActor(t *testing.T) {
actor, opp := uuid.New(), uuid.New()
pub := &recordingPublisher{}
svc := &Service{pub: pub}
g := Game{
ID: uuid.New(),
Status: StatusActive,
ToMove: 1,
TurnStartedAt: time.Now(),
TurnTimeout: time.Hour,
Seats: []Seat{{Seat: 0, AccountID: actor, Score: 19}, {Seat: 1, AccountID: opp, Score: 13}},
}
svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Words: []string{"HELLO"}, Score: 10, Total: 19}, 80)
kinds := map[uuid.UUID][]string{}
var yourTurn notify.Intent
for _, in := range pub.intents {
kinds[in.UserID] = append(kinds[in.UserID], in.Kind)
if in.UserID == opp && in.Kind == notify.KindYourTurn {
yourTurn = in
}
}
if !slices.Contains(kinds[actor], notify.KindOpponentMoved) {
t.Errorf("actor should get opponent_moved, got %v", kinds[actor])
}
if !slices.Contains(kinds[opp], notify.KindOpponentMoved) {
t.Errorf("opponent should get opponent_moved, got %v", kinds[opp])
}
if !slices.Contains(kinds[opp], notify.KindYourTurn) {
t.Errorf("next mover should get your_turn, got %v", kinds[opp])
}
if slices.Contains(kinds[actor], notify.KindYourTurn) {
t.Errorf("actor is not next to move, should not get your_turn")
}
// The your_turn push is enriched: the last move's action and word, and a recipient-first
// score line (the next mover, seat 1, first). The opponent name needs the account store and
// is left empty by this store-less unit (covered at the render layer).
yt := fb.GetRootAsYourTurnEvent(yourTurn.Payload, 0)
if got := string(yt.LastAction()); got != "play" {
t.Errorf("your_turn last_action = %q, want play", got)
}
if got := string(yt.LastWord()); got != "HELLO" {
t.Errorf("your_turn last_word = %q, want HELLO", got)
}
if got := string(yt.ScoreLine()); got != "13:19" { // seat 1 (recipient) first, then seat 0
t.Errorf("your_turn score_line = %q, want 13:19", got)
}
// 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)
}
}
+79
View File
@@ -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
}
+1 -1
View File
@@ -40,7 +40,7 @@ func TestWriteGCG(t *testing.T) {
"#character-encoding UTF-8",
"#player1 p1 Alice",
"#player2 p2 Bob",
"#lexicon english/v1",
"#lexicon scrabble_en/v1",
"#title game 00000000-0000-7000-8000-000000000001",
">p1: CATSER? 8H CAT +10 10",
">p2: AS?E I8 .s +2 2",
+1 -1
View File
@@ -94,7 +94,7 @@ func TestGameCacheEviction(t *testing.T) {
cur := time.Unix(1_700_000_000, 0)
cache := newGameCache(time.Hour, func() time.Time { return cur })
id := uuid.New()
cache.put(id, nil)
cache.put(id, nil, "scrabble_en")
if _, ok := cache.get(id); !ok {
t.Fatal("game must be resident after put")
}
+135
View File
@@ -0,0 +1,135 @@
package game
import (
"context"
"time"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/metric/noop"
"go.uber.org/zap"
"scrabble/backend/internal/engine"
)
// meterName scopes the game domain's OpenTelemetry instruments.
const meterName = "scrabble/backend/game"
// gameMetrics holds the game domain's operational instruments. Every game-scoped
// measurement carries a "variant" attribute (scrabble_en/scrabble_ru/erudit_ru). The
// instruments default to no-ops (see defaultGameMetrics), so recording is always
// safe; SetMetrics installs the real meter during startup wiring.
type gameMetrics struct {
replay metric.Float64Histogram
validate metric.Float64Histogram
moveDur metric.Float64Histogram
started metric.Int64Counter
abandoned metric.Int64Counter
}
// defaultGameMetrics returns instruments backed by a no-op meter, recording
// nothing until SetMetrics installs a real one.
func defaultGameMetrics() *gameMetrics {
return newGameMetrics(noop.NewMeterProvider().Meter(meterName))
}
// newGameMetrics builds the instruments on meter, falling back to no-op
// instruments on the (rare) construction error so the game domain never fails to
// start over telemetry.
func newGameMetrics(meter metric.Meter) *gameMetrics {
return &gameMetrics{
replay: histogram(meter, "game_replay_duration", "Seconds to rebuild a live game from its journal on a cache miss."),
validate: histogram(meter, "game_move_validate_duration", "Seconds to validate and score a tentative play (EvaluatePlay)."),
moveDur: histogram(meter, "game_move_duration", "Seconds a seat spent on a committed move (play/pass/exchange), by variant and phase. Aggregates all seats including robots; per-human analysis lives in the admin console."),
started: counter(meter, "games_started_total", "Games created and started."),
abandoned: counter(meter, "games_abandoned_total", "Player seats dropped by the turn-timeout sweeper."),
}
}
// SetMetrics installs the meter the game domain records to and registers the
// observable gauge reporting the live games resident in the cache by variant. It
// must be called during startup wiring; the default is a no-op meter.
func (svc *Service) SetMetrics(meter metric.Meter) {
if meter == nil {
return
}
svc.metrics = newGameMetrics(meter)
if _, err := meter.Int64ObservableGauge("game_cache_active",
metric.WithDescription("Live games currently resident in the in-memory cache, by variant."),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
for variant, n := range svc.cache.countByVariant() {
o.Observe(int64(n), metric.WithAttributes(attribute.String("variant", variant)))
}
return nil
}),
); err != nil {
svc.log.Warn("game: register cache gauge", zap.Error(err))
}
}
// recordReplay records the duration of a cache-miss journal replay for variant.
func (m *gameMetrics) recordReplay(ctx context.Context, v engine.Variant, start time.Time) {
m.replay.Record(ctx, time.Since(start).Seconds(), variantAttr(v))
}
// recordValidate records the duration of one play validation for variant.
func (m *gameMetrics) recordValidate(ctx context.Context, v engine.Variant, start time.Time) {
m.validate.Record(ctx, time.Since(start).Seconds(), variantAttr(v))
}
// recordMoveDuration records how long a seat spent on a committed move, attributed by
// variant and the game phase derived from moveCount. A non-positive duration (a clock
// skew or a move with no recorded turn start) is dropped.
func (m *gameMetrics) recordMoveDuration(ctx context.Context, v engine.Variant, moveCount int, d time.Duration) {
if d <= 0 {
return
}
m.moveDur.Record(ctx, d.Seconds(),
metric.WithAttributes(attribute.String("variant", v.String()), attribute.String("phase", phaseOf(moveCount))))
}
// phaseOf buckets a move ordinal into the game phase used as a metric attribute. The
// thresholds reflect a typical ~28-move game (docs/ARCHITECTURE.md §7).
func phaseOf(moveCount int) string {
switch {
case moveCount <= 8:
return "opening"
case moveCount <= 20:
return "middle"
default:
return "endgame"
}
}
// recordStarted counts one started game of variant.
func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant) {
m.started.Add(ctx, 1, variantAttr(v))
}
// recordAbandoned counts one seat dropped by the turn-timeout sweeper in a game of
// variant.
func (m *gameMetrics) recordAbandoned(ctx context.Context, v engine.Variant) {
m.abandoned.Add(ctx, 1, variantAttr(v))
}
// variantAttr is the shared "variant" attribute option, usable for both Record and
// Add measurements.
func variantAttr(v engine.Variant) metric.MeasurementOption {
return metric.WithAttributes(attribute.String("variant", v.String()))
}
func histogram(m metric.Meter, name, desc string) metric.Float64Histogram {
h, err := m.Float64Histogram(name, metric.WithUnit("s"), metric.WithDescription(desc))
if err != nil {
h, _ = noop.NewMeterProvider().Meter(meterName).Float64Histogram(name)
}
return h
}
func counter(m metric.Meter, name, desc string) metric.Int64Counter {
c, err := m.Int64Counter(name, metric.WithDescription(desc))
if err != nil {
c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter(name)
}
return c
}
+110
View File
@@ -0,0 +1,110 @@
package game
import (
"context"
"testing"
"time"
"go.opentelemetry.io/otel/attribute"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
"scrabble/backend/internal/engine"
)
// TestGameMetrics records each game instrument through a manual reader and asserts
// the counters carry the right "variant" attribute and the histograms observe.
func TestGameMetrics(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newGameMetrics(meter)
m.recordStarted(ctx, engine.VariantEnglish)
m.recordStarted(ctx, engine.VariantEnglish)
m.recordStarted(ctx, engine.VariantRussianScrabble)
m.recordAbandoned(ctx, engine.VariantErudit)
m.recordReplay(ctx, engine.VariantEnglish, time.Now().Add(-time.Millisecond))
m.recordValidate(ctx, engine.VariantRussianScrabble, time.Now().Add(-time.Millisecond))
m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 5*time.Second)
m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 0) // non-positive: dropped
var rm metricdata.ResourceMetrics
if err := reader.Collect(ctx, &rm); err != nil {
t.Fatalf("collect: %v", err)
}
started := counterByAttr(t, rm, "games_started_total", "variant")
if started["scrabble_en"] != 2 || started["scrabble_ru"] != 1 {
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_ru"] != 1 {
t.Errorf("games_abandoned_total = %v, want erudit_ru:1", abandoned)
}
if c := histogramCount(t, rm, "game_replay_duration"); c != 1 {
t.Errorf("game_replay_duration observations = %d, want 1", c)
}
if c := histogramCount(t, rm, "game_move_validate_duration"); c != 1 {
t.Errorf("game_move_validate_duration observations = %d, want 1", c)
}
if c := histogramCount(t, rm, "game_move_duration"); c != 1 {
t.Errorf("game_move_duration observations = %d, want 1 (zero-duration dropped)", c)
}
}
// TestPhaseOf checks the move-ordinal to phase bucketing.
func TestPhaseOf(t *testing.T) {
cases := map[int]string{1: "opening", 8: "opening", 9: "middle", 20: "middle", 21: "endgame", 50: "endgame"}
for mc, want := range cases {
if got := phaseOf(mc); got != want {
t.Errorf("phaseOf(%d) = %q, want %q", mc, got, want)
}
}
}
// counterByAttr sums the int64 counter named name, grouped by the value of the
// attribute key attr.
func counterByAttr(t *testing.T, rm metricdata.ResourceMetrics, name, attr string) map[string]int64 {
t.Helper()
out := map[string]int64{}
for _, sm := range rm.ScopeMetrics {
for _, md := range sm.Metrics {
if md.Name != name {
continue
}
sum, ok := md.Data.(metricdata.Sum[int64])
if !ok {
t.Fatalf("%s is not an int64 sum", name)
}
for _, dp := range sum.DataPoints {
v, _ := dp.Attributes.Value(attribute.Key(attr))
out[v.AsString()] += dp.Value
}
}
}
return out
}
// histogramCount returns the total observation count of the float64 histogram
// named name.
func histogramCount(t *testing.T, rm metricdata.ResourceMetrics, name string) uint64 {
t.Helper()
for _, sm := range rm.ScopeMetrics {
for _, md := range sm.Metrics {
if md.Name != name {
continue
}
h, ok := md.Data.(metricdata.Histogram[float64])
if !ok {
t.Fatalf("%s is not a float64 histogram", name)
}
var n uint64
for _, dp := range h.DataPoints {
n += dp.Count
}
return n
}
}
t.Fatalf("%s not found", name)
return 0
}
+337 -18
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
@@ -33,6 +34,7 @@ type Service struct {
clock func() time.Time
rng func() int64
pub notify.Publisher
metrics *gameMetrics
log *zap.Logger
}
@@ -51,6 +53,7 @@ func NewService(store *Store, accounts *account.Store, registry *engine.Registry
clock: clock,
rng: randomSeed,
pub: notify.Nop{},
metrics: defaultGameMetrics(),
log: log,
}
}
@@ -135,7 +138,8 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
return Game{}, err
}
svc.cache.put(id, g)
svc.cache.put(id, g, params.Variant.String())
svc.metrics.recordStarted(ctx, params.Variant)
return svc.store.GetGame(ctx, id)
}
@@ -168,11 +172,77 @@ func (svc *Service) Exchange(ctx context.Context, gameID, accountID uuid.UUID, t
}
// Resign ends the game on the player's turn; the remaining player wins.
// Resign forfeits the game for the acting account. Unlike a play/exchange/pass it is
// allowed on the opponent's turn (a resignation is not a turn-scoped move), so it does
// not go through transition's turn check: it resigns the actor's own seat, whoever is to
// move. The resigning seat always loses (docs/ARCHITECTURE.md §7).
func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (MoveResult, error) {
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
rec, err := g.Resign()
return rec, nil, err
})
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return MoveResult{}, err
}
seat, ok := pre.seatOf(accountID)
if !ok {
return MoveResult{}, ErrNotAPlayer
}
if pre.Status != StatusActive {
return MoveResult{}, ErrFinished
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return MoveResult{}, err
}
if g.Over() {
return MoveResult{}, ErrFinished
}
rackBefore := g.Hand(seat)
rec, err := g.ResignSeat(seat)
if err != nil {
return MoveResult{}, err
}
post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, nil, pre.Seats)
if err != nil {
return MoveResult{}, err
}
svc.afterCommitDrafts(ctx, gameID, accountID, rec)
// A resignation carries no think time (it can happen on the opponent's turn), so it
// is intentionally excluded from the move-duration metric.
return MoveResult{Move: rec, Game: post}, nil
}
// 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
// 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) {
return svc.store.GetGameVariant(ctx, gameID)
}
// GameLanguage returns the game's language tag ("en"/"ru"), derived from its variant, so a
// game push routes out-of-app to the game's own bot rather than the recipient's last-login bot.
func (svc *Service) GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error) {
v, err := svc.GameVariant(ctx, gameID)
if err != nil {
return "", err
}
return v.Language(), nil
}
// RobotSchedule returns a game's bag seed and turn-start time, for the admin console's
// robot-schedule panel (the deterministic play-to-win intent and next-move ETA).
func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed int64, turnStartedAt time.Time, err error) {
return svc.store.RobotSchedule(ctx, gameID)
}
// 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
// taken a turn.
func (svc *Service) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
return svc.store.LastMoveAt(ctx, gameID, accountID)
}
// transition validates the actor and turn, applies op under the per-game lock and
@@ -216,7 +286,26 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
if err != nil {
return MoveResult{}, err
}
return MoveResult{Move: rec, Game: post}, nil
svc.afterCommitDrafts(ctx, gameID, accountID, rec)
// 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.
svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt))
return MoveResult{Move: rec, Game: post, Rack: g.Hand(seat), BagLen: g.BagLen()}, nil
}
// 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
// draft, which is then reset. Best-effort — the move is already committed, so a draft
// cleanup failure is logged rather than failing the move.
func (svc *Service) afterCommitDrafts(ctx context.Context, gameID, accountID uuid.UUID, rec engine.MoveRecord) {
if err := svc.store.clearDraft(ctx, gameID, accountID); err != nil {
svc.log.Warn("clear actor draft", zap.Error(err))
}
if rec.Action == engine.ActionPlay {
if err := svc.store.resetConflictingBoardDrafts(ctx, gameID, accountID, draftTilesFrom(rec)); err != nil {
svc.log.Warn("reset conflicting board drafts", zap.Error(err))
}
}
}
// commit persists a just-applied transition: the journal row, the post-move turn
@@ -272,30 +361,113 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
if err != nil {
return Game{}, err
}
svc.emitMove(post, rec)
svc.emitMove(ctx, post, rec, g.BagLen())
return post, nil
}
// emitMove publishes the live events for a just-committed move: opponent_moved to
// every seat other than the actor, and your_turn to the next mover while the game
// is still active. Delivery is best-effort (notify.Publisher never blocks).
func (svc *Service) emitMove(post Game, rec engine.MoveRecord) {
intents := make([]notify.Intent, 0, len(post.Seats)+1)
// every seat — including the actor's own account, so the mover's other devices (and
// their lobby) refresh too — and your_turn to the next mover while the game is still
// active. opponent_moved is in-app only (the gateway never turns it into an
// 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
// event out to all of the recipient's live streams.
func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveRecord, bagLen int) {
// 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 {
if s.Seat == rec.Player {
continue
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen))
}
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total))
}
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 {
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...)
}
// 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
// no seat matches (the slice is not assumed to be ordered by seat).
func seatAccount(seats []Seat, seat int) (uuid.UUID, bool) {
@@ -350,6 +522,7 @@ func (svc *Service) timeoutGame(ctx context.Context, gameID uuid.UUID, now time.
if _, err := svc.commit(ctx, gameID, g, rec, "timeout", rackBefore, nil, cur.Seats); err != nil {
return false, err
}
svc.metrics.recordAbandoned(ctx, cur.Variant)
return true, nil
}
@@ -373,7 +546,9 @@ func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUI
if err != nil {
return EvalResult{}, err
}
validateStart := time.Now()
rec, err := g.EvaluatePlay(dir, tiles)
svc.metrics.recordValidate(ctx, pre.Variant, validateStart)
if err != nil {
if errors.Is(err, engine.ErrIllegalPlay) {
return EvalResult{Valid: false}, nil
@@ -420,6 +595,68 @@ func (svc *Service) FileComplaint(ctx context.Context, gameID, accountID uuid.UU
})
}
// ListComplaints returns word-check complaints for the admin review queue,
// newest first. status filters by lifecycle state ("" = all); limit is clamped
// to a sane page size and offset is floored at zero.
func (svc *Service) ListComplaints(ctx context.Context, status string, limit, offset int) ([]Complaint, error) {
return svc.store.ListComplaints(ctx, status, clampPageSize(limit), max(0, offset))
}
// GetComplaint loads a single complaint for the admin detail view.
func (svc *Service) GetComplaint(ctx context.Context, id uuid.UUID) (Complaint, error) {
return svc.store.GetComplaint(ctx, id)
}
// CountComplaints returns the number of complaints, optionally restricted to a
// status, for the admin queue pager and the dashboard counts.
func (svc *Service) CountComplaints(ctx context.Context, status string) (int, error) {
return svc.store.CountComplaints(ctx, status)
}
// ResolveComplaint closes a complaint with an operator disposition (reject /
// accept_add / accept_remove) and an optional note. An accepted complaint then
// appears in DictionaryChanges until a rebuilt dictionary is loaded and the
// change is marked applied. It returns ErrInvalidConfig for an unknown
// disposition and ErrNotFound when no complaint matches.
func (svc *Service) ResolveComplaint(ctx context.Context, id uuid.UUID, disposition, note string) (Complaint, error) {
if !validDisposition(disposition) {
return Complaint{}, fmt.Errorf("%w: complaint disposition %q", ErrInvalidConfig, disposition)
}
return svc.store.ResolveComplaint(ctx, id, disposition, note, svc.clock())
}
// DictionaryChanges returns the pending wordlist edits implied by resolved,
// accepted complaints not yet marked applied — the input to the offline DAWG
// rebuild.
func (svc *Service) DictionaryChanges(ctx context.Context) ([]DictionaryChange, error) {
rows, err := svc.store.ListDictionaryChanges(ctx)
if err != nil {
return nil, err
}
out := make([]DictionaryChange, 0, len(rows))
for _, c := range rows {
ch := DictionaryChange{
ComplaintID: c.ID,
Variant: c.Variant,
Word: c.Word,
Add: c.Disposition == DispositionAcceptAdd,
Note: c.Note,
}
if c.ResolvedAt != nil {
ch.ResolvedAt = *c.ResolvedAt
}
out = append(out, ch)
}
return out, nil
}
// MarkChangesApplied records that every pending accepted change for variant has
// been folded into the dictionary version that was just hot-reloaded, removing
// them from DictionaryChanges. It returns the number of changes marked.
func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Variant, version string) (int64, error) {
return svc.store.MarkChangesApplied(ctx, variant.String(), version)
}
// Hint reveals the top-scoring legal play for the requesting player on their
// turn, spending one hint from their per-game allowance and then their profile
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
@@ -550,6 +787,20 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
}, 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
// 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
@@ -583,6 +834,47 @@ func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]
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.
func (svc *Service) GameByID(ctx context.Context, id uuid.UUID) (Game, error) {
return svc.store.GetGame(ctx, id)
}
// ListGames returns games for the admin list, newest-updated first, paginated,
// optionally filtered by status.
func (svc *Service) ListGames(ctx context.Context, status string, limit, offset int) ([]Game, error) {
return svc.store.ListGames(ctx, status, clampPageSize(limit), max(0, offset))
}
// CountGames returns the game count, optionally filtered by status, for the admin
// list pager and dashboard.
func (svc *Service) CountGames(ctx context.Context, status string) (int, error) {
return svc.store.CountGames(ctx, status)
}
// History returns a game's full, dictionary-independent move journal.
func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) {
g, err := svc.store.GetGame(ctx, gameID)
@@ -625,7 +917,7 @@ func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error
return nil, err
}
if !g.Over() {
svc.cache.put(pre.ID, g)
svc.cache.put(pre.ID, g, pre.Variant.String())
}
return g, nil
}
@@ -634,6 +926,7 @@ func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error
// re-applying every journalled move in order. The deterministic bag makes the
// reconstruction exact.
func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error) {
defer svc.metrics.recordReplay(ctx, pre.Variant, time.Now())
seed, err := svc.store.GameSeed(ctx, pre.ID)
if err != nil {
return nil, err
@@ -732,6 +1025,9 @@ func (svc *Service) nonGuestSeats(ctx context.Context, seats []Seat) ([]Seat, er
// seatNames resolves each seat's display name for GCG export.
func (svc *Service) seatNames(ctx context.Context, g Game) []string {
names := make([]string, g.Players)
if svc.accounts == nil {
return names
}
for _, s := range g.Seats {
if acc, err := svc.accounts.GetByID(ctx, s.AccountID); err == nil {
names[s.Seat] = acc.DisplayName
@@ -770,6 +1066,29 @@ func normalizeWord(word string) string {
return strings.ToLower(strings.TrimSpace(word))
}
// validDisposition reports whether d is an accepted complaint disposition.
func validDisposition(d string) bool {
switch d {
case DispositionReject, DispositionAcceptAdd, DispositionAcceptRemove:
return true
default:
return false
}
}
// clampPageSize bounds an admin list page size to [1, 200], defaulting an unset
// (non-positive) request to 50.
func clampPageSize(limit int) int {
switch {
case limit <= 0:
return 50
case limit > 200:
return 200
default:
return limit
}
}
// randomSeed returns an unpredictable bag seed, falling back to the clock if the
// system source fails.
func randomSeed() int64 {
+282
View File
@@ -135,6 +135,24 @@ func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) {
return projectGame(grow, srows)
}
// GetGameVariant reads just a game's variant — a cheap single-column lookup the edge uses
// to map wire alphabet indices to concrete letters without loading the whole
// game and its seats.
func (s *Store) GetGameVariant(ctx context.Context, id uuid.UUID) (engine.Variant, error) {
stmt := postgres.SELECT(table.Games.Variant).
FROM(table.Games).
WHERE(table.Games.GameID.EQ(postgres.UUID(id))).
LIMIT(1)
var row model.Games
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return 0, ErrNotFound
}
return 0, fmt.Errorf("game: get variant %s: %w", id, err)
}
return engine.ParseVariant(row.Variant)
}
// SharedGameExists reports whether accounts a and b are both seated in at least
// one game (active or finished). It backs the social package's "befriend an
// opponent" gate via a self-join on game_players.
@@ -168,6 +186,23 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
if len(grows) == 0 {
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))
for i, g := range grows {
@@ -197,6 +232,83 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
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,
// paginated. status filters by lifecycle ("active"/"finished") when non-empty.
// The seats are not loaded — the list shows summaries; the detail view uses
// GetGame.
func (s *Store) ListGames(ctx context.Context, status string, limit, offset int) ([]Game, error) {
where := postgres.Bool(true)
if status != "" {
where = table.Games.Status.EQ(postgres.String(status))
}
stmt := postgres.SELECT(table.Games.AllColumns).
FROM(table.Games).
WHERE(where).
ORDER_BY(table.Games.UpdatedAt.DESC()).
LIMIT(int64(limit)).
OFFSET(int64(offset))
var rows []model.Games
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("game: list games: %w", err)
}
out := make([]Game, 0, len(rows))
for _, g := range rows {
pg, err := projectGame(g, nil)
if err != nil {
return nil, err
}
out = append(out, pg)
}
return out, nil
}
// CountGames returns the number of games, optionally restricted to a status, for
// admin-list pagination.
func (s *Store) CountGames(ctx context.Context, status string) (int, error) {
where := postgres.Bool(true)
if status != "" {
where = table.Games.Status.EQ(postgres.String(status))
}
stmt := postgres.SELECT(postgres.COUNT(table.Games.GameID).AS("count")).
FROM(table.Games).
WHERE(where)
var dest struct{ Count int64 }
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
return 0, fmt.Errorf("game: count games: %w", err)
}
return int(dest.Count), nil
}
// GetJournal loads the ordered, decoded move journal for a game.
func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, error) {
stmt := postgres.SELECT(table.GameMoves.AllColumns).
@@ -384,6 +496,122 @@ func (s *Store) FileComplaint(ctx context.Context, c Complaint) (Complaint, erro
return projectComplaint(row)
}
// ListComplaints returns complaints for the admin review queue, newest first.
// status filters by lifecycle state when non-empty; limit and offset paginate.
func (s *Store) ListComplaints(ctx context.Context, status string, limit, offset int) ([]Complaint, error) {
where := postgres.Bool(true)
if status != "" {
where = table.Complaints.Status.EQ(postgres.String(status))
}
stmt := postgres.SELECT(table.Complaints.AllColumns).
FROM(table.Complaints).
WHERE(where).
ORDER_BY(table.Complaints.CreatedAt.DESC()).
LIMIT(int64(limit)).
OFFSET(int64(offset))
var rows []model.Complaints
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("game: list complaints: %w", err)
}
return projectComplaints(rows)
}
// GetComplaint loads one complaint by id, or ErrNotFound.
func (s *Store) GetComplaint(ctx context.Context, id uuid.UUID) (Complaint, error) {
stmt := postgres.SELECT(table.Complaints.AllColumns).
FROM(table.Complaints).
WHERE(table.Complaints.ComplaintID.EQ(postgres.UUID(id))).
LIMIT(1)
var row model.Complaints
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Complaint{}, ErrNotFound
}
return Complaint{}, fmt.Errorf("game: get complaint %s: %w", id, err)
}
return projectComplaint(row)
}
// ResolveComplaint closes a complaint with a disposition and note, stamping
// resolved_at, and returns the updated row (ErrNotFound when none matches). It
// leaves applied_in_version untouched.
func (s *Store) ResolveComplaint(ctx context.Context, id uuid.UUID, disposition, note string, now time.Time) (Complaint, error) {
stmt := table.Complaints.UPDATE(
table.Complaints.Status, table.Complaints.Disposition,
table.Complaints.ResolutionNote, table.Complaints.ResolvedAt,
).SET(
postgres.String(StatusComplaintResolved), postgres.String(disposition),
postgres.String(note), postgres.TimestampzT(now),
).WHERE(table.Complaints.ComplaintID.EQ(postgres.UUID(id))).
RETURNING(table.Complaints.AllColumns)
var row model.Complaints
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Complaint{}, ErrNotFound
}
return Complaint{}, fmt.Errorf("game: resolve complaint %s: %w", id, err)
}
return projectComplaint(row)
}
// ListDictionaryChanges returns the resolved, accepted complaints not yet marked
// applied (the pending wordlist edits), ordered by variant then resolution time.
func (s *Store) ListDictionaryChanges(ctx context.Context) ([]Complaint, error) {
stmt := postgres.SELECT(table.Complaints.AllColumns).
FROM(table.Complaints).
WHERE(
table.Complaints.Status.EQ(postgres.String(StatusComplaintResolved)).
AND(table.Complaints.Disposition.IN(
postgres.String(DispositionAcceptAdd), postgres.String(DispositionAcceptRemove),
)).
AND(table.Complaints.AppliedInVersion.EQ(postgres.String(""))),
).
ORDER_BY(table.Complaints.Variant.ASC(), table.Complaints.ResolvedAt.ASC())
var rows []model.Complaints
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("game: list dictionary changes: %w", err)
}
return projectComplaints(rows)
}
// MarkChangesApplied stamps every pending accepted change for variant with
// version (so it drops out of ListDictionaryChanges) and returns the count.
func (s *Store) MarkChangesApplied(ctx context.Context, variant, version string) (int64, error) {
stmt := table.Complaints.UPDATE(table.Complaints.AppliedInVersion).
SET(postgres.String(version)).
WHERE(
table.Complaints.Status.EQ(postgres.String(StatusComplaintResolved)).
AND(table.Complaints.Variant.EQ(postgres.String(variant))).
AND(table.Complaints.Disposition.IN(
postgres.String(DispositionAcceptAdd), postgres.String(DispositionAcceptRemove),
)).
AND(table.Complaints.AppliedInVersion.EQ(postgres.String(""))),
)
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return 0, fmt.Errorf("game: mark changes applied: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
// CountComplaints returns the number of complaints, optionally restricted to a
// status, for the admin queue pager and the dashboard counts.
func (s *Store) CountComplaints(ctx context.Context, status string) (int, error) {
where := postgres.Bool(true)
if status != "" {
where = table.Complaints.Status.EQ(postgres.String(status))
}
stmt := postgres.SELECT(postgres.COUNT(table.Complaints.ComplaintID).AS("count")).
FROM(table.Complaints).
WHERE(where)
var dest struct{ Count int64 }
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
return 0, fmt.Errorf("game: count complaints: %w", err)
}
return int(dest.Count), nil
}
// ActiveGames returns the turn clocks of every in-progress game; the sweeper
// filters them against the per-move deadline and the player's away window.
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
@@ -470,6 +698,43 @@ func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) {
return row.Seed, nil
}
// 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
// nudge cooldown once the player has taken a turn.
func (s *Store) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
var at sql.NullTime
err := s.db.QueryRowContext(ctx,
`SELECT MAX(m.created_at) FROM backend.game_moves m
JOIN backend.game_players p ON p.game_id = m.game_id AND p.seat = m.seat
WHERE m.game_id = $1 AND p.account_id = $2`,
gameID, accountID).Scan(&at)
if err != nil {
return time.Time{}, false, fmt.Errorf("game: last move at %s: %w", gameID, err)
}
if !at.Valid {
return time.Time{}, false, nil
}
return at.Time, true, nil
}
// RobotSchedule returns a game's bag seed and current turn-start time. The admin console
// combines them with the robot strategy to show a robot seat's play-to-win intent and its
// next-move ETA. Both are server-only state, never part of the public game view.
func (s *Store) RobotSchedule(ctx context.Context, id uuid.UUID) (seed int64, turnStartedAt time.Time, err error) {
stmt := postgres.SELECT(table.Games.Seed, table.Games.TurnStartedAt).
FROM(table.Games).
WHERE(table.Games.GameID.EQ(postgres.UUID(id))).
LIMIT(1)
var row model.Games
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return 0, time.Time{}, ErrNotFound
}
return 0, time.Time{}, fmt.Errorf("game: get schedule %s: %w", id, err)
}
return row.Seed, row.TurnStartedAt, nil
}
// projectGame builds a Game from a games row and its ordered seat rows.
func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
variant, err := engine.ParseVariant(g.Variant)
@@ -533,9 +798,26 @@ func projectComplaint(row model.Complaints) (Complaint, error) {
Note: row.Note,
Status: row.Status,
CreatedAt: row.CreatedAt,
Disposition: row.Disposition,
ResolutionNote: row.ResolutionNote,
ResolvedAt: row.ResolvedAt,
AppliedInVersion: row.AppliedInVersion,
}, nil
}
// projectComplaints projects a slice of complaint rows, preserving order.
func projectComplaints(rows []model.Complaints) ([]Complaint, error) {
out := make([]Complaint, 0, len(rows))
for _, r := range rows {
c, err := projectComplaint(r)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
+45 -5
View File
@@ -15,9 +15,23 @@ const (
StatusFinished = "finished"
)
// ComplaintStatus values; Stage 10 owns the resolution lifecycle, Stage 3 only
// ever writes StatusComplaintOpen.
const StatusComplaintOpen = "open"
// Complaint lifecycle values. A complaint is filed StatusComplaintOpen
// and closed StatusComplaintResolved by the admin review queue with a
// Disposition. The CHECK constraints live in migration 00008.
const (
StatusComplaintOpen = "open"
StatusComplaintResolved = "resolved"
)
// Complaint dispositions chosen at resolution. DispositionReject keeps the
// dictionary as-is; DispositionAcceptAdd / DispositionAcceptRemove mark the word
// for addition to / removal from the variant's wordlist and feed the offline
// dictionary-rebuild pipeline (see DictionaryChange).
const (
DispositionReject = "reject"
DispositionAcceptAdd = "accept_add"
DispositionAcceptRemove = "accept_remove"
)
// Sentinel errors returned by the service. Engine errors (engine.ErrIllegalPlay,
// engine.ErrTilesNotOnRack, engine.ErrGameOver, …) propagate unwrapped from the
@@ -110,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
// 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 {
Move engine.MoveRecord
Game Game
Rack []string
BagLen int
}
// HintResult is a revealed hint and the requesting player's remaining hint
@@ -179,7 +197,9 @@ type RobotTurn struct {
Seed int64
}
// Complaint is a word-check complaint awaiting admin review (Stage 10).
// Complaint is a word-check complaint in the admin review queue. It is filed
// against a game's pinned (Variant, DictVersion) with the lookup result at filing
// time (WasValid); the resolution fields stay empty until an operator resolves it.
type Complaint struct {
ID uuid.UUID
ComplainantID uuid.UUID
@@ -191,4 +211,24 @@ type Complaint struct {
Note string
Status string
CreatedAt time.Time
// Resolution fields, set when Status == StatusComplaintResolved.
Disposition string // "" while open; otherwise a Disposition* value
ResolutionNote string // operator note recorded at resolution
ResolvedAt *time.Time // nil while open
AppliedInVersion string // dict version an accepted change was folded into ("" = pending)
}
// DictionaryChange is the wordlist edit implied by one resolved, accepted
// complaint: Add reports whether Word should be added (DispositionAcceptAdd) or
// removed (DispositionAcceptRemove) for Variant. The admin console lists the
// pending changes as the input to the offline DAWG rebuild; once a rebuilt
// dictionary version is hot-reloaded they are marked applied.
type DictionaryChange struct {
ComplaintID uuid.UUID
Variant engine.Variant
Word string
Add bool
ResolvedAt time.Time
Note string
}
+158 -3
View File
@@ -6,10 +6,13 @@ import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// 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
// 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) {
ctx := context.Background()
store := account.NewStore(testDB)
@@ -108,7 +111,7 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool {
// TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact
// seeds the new account's language and display name from the launch fields,
// defaults the in-app-only flag on, and never overwrites an existing account on a
// later login (Stage 9 language seeding).
// later login (language seeding).
func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
@@ -155,6 +158,102 @@ func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
}
}
// TestServiceLanguageRoundTrip checks SetServiceLanguage persists the push-routing
// language (the bot a Telegram user last signed in through): a fresh account has
// none, a set value reads back, a later login overwrites it (last-login-wins), and
// an empty value is a no-op. The push-target route coalesces it with the preferred
// language.
func TestServiceLanguageRoundTrip(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
if acc.ServiceLanguage != "" {
t.Errorf("fresh ServiceLanguage = %q, want empty", acc.ServiceLanguage)
}
if err := store.SetServiceLanguage(ctx, acc.ID, "ru"); err != nil {
t.Fatalf("set service language: %v", err)
}
if got, err := store.GetByID(ctx, acc.ID); err != nil {
t.Fatalf("get by id: %v", err)
} else if got.ServiceLanguage != "ru" {
t.Errorf("ServiceLanguage = %q, want ru", got.ServiceLanguage)
}
// A later login through the other bot updates it; a subsequent empty value
// (a non-Telegram login) leaves it unchanged.
if err := store.SetServiceLanguage(ctx, acc.ID, "en"); err != nil {
t.Fatalf("update service language: %v", err)
}
if err := store.SetServiceLanguage(ctx, acc.ID, ""); err != nil {
t.Fatalf("noop service language: %v", err)
}
if got, err := store.GetByID(ctx, acc.ID); err != nil {
t.Fatalf("get by id: %v", err)
} else if got.ServiceLanguage != "en" {
t.Errorf("ServiceLanguage after update+noop = %q, want en", got.ServiceLanguage)
}
}
// TestHighRateFlagRoundTrip covers the soft high-rate marker: a fresh account
// is unflagged, FlagHighRate stamps it exactly once (a second sustained episode
// never moves the timestamp), ClearHighRateFlag reverses it, and a re-flag after
// 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
// uses: it returns the external_id for the matching kind and ErrNotFound otherwise,
// including for a guest that carries no identity.
@@ -182,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.
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
ctx := context.Background()
@@ -214,3 +313,59 @@ func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
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")
}
}
+289
View File
@@ -0,0 +1,289 @@
//go:build integration
package inttest
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/server"
)
// TestComplaintResolutionPipeline drives a complaint from filing through
// resolution into the dictionary-change pipeline and on to "applied".
func TestComplaintResolutionPipeline(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: 1})
if err != nil {
t.Fatalf("create: %v", err)
}
word := "zzzzzz" // a non-word the filer thinks should be valid → an accept_add candidate
filed, err := svc.FileComplaint(ctx, g.ID, seats[0], word, "please add")
if err != nil {
t.Fatalf("file: %v", err)
}
if open, _ := svc.CountComplaints(ctx, game.StatusComplaintOpen); open < 1 {
t.Fatalf("open complaints = %d, want >= 1", open)
}
list, err := svc.ListComplaints(ctx, game.StatusComplaintOpen, 100, 0)
if err != nil || !containsComplaint(list, filed.ID) {
t.Fatalf("open list missing filed complaint (err %v)", err)
}
resolved, err := svc.ResolveComplaint(ctx, filed.ID, game.DispositionAcceptAdd, "agreed")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if resolved.Status != game.StatusComplaintResolved || resolved.Disposition != game.DispositionAcceptAdd || resolved.ResolvedAt == nil {
t.Fatalf("unexpected resolved complaint: %+v", resolved)
}
changes, err := svc.DictionaryChanges(ctx)
if err != nil {
t.Fatalf("changes: %v", err)
}
if !changeFor(changes, word, true) {
t.Fatalf("dictionary changes missing add %q: %+v", word, changes)
}
n, err := svc.MarkChangesApplied(ctx, engine.VariantEnglish, "v2")
if err != nil || n < 1 {
t.Fatalf("mark applied n=%d err=%v", n, err)
}
if after, err := svc.DictionaryChanges(ctx); err != nil || changeFor(after, word, true) {
t.Fatalf("change still pending after apply (err %v): %+v", err, after)
}
}
func containsComplaint(list []game.Complaint, id uuid.UUID) bool {
for _, c := range list {
if c.ID == id {
return true
}
}
return false
}
func changeFor(changes []game.DictionaryChange, word string, add bool) bool {
for _, c := range changes {
if c.Word == word && c.Add == add {
return true
}
}
return false
}
// TestAdminListsAndCounts checks the admin read queries and their COUNT scans.
func TestAdminListsAndCounts(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
svc := newGameService()
accBefore, err := store.CountAccounts(ctx)
if err != nil {
t.Fatalf("count accounts: %v", err)
}
a, b := provisionAccount(t), provisionAccount(t)
accAfter, err := store.CountAccounts(ctx)
if err != nil {
t.Fatalf("count accounts: %v", err)
}
if accAfter < accBefore+2 {
t.Errorf("account count did not grow by 2: %d -> %d", accBefore, accAfter)
}
if page, err := store.ListAccounts(ctx, 1, 0); err != nil || len(page) != 1 {
t.Fatalf("list accounts page size 1 = %d (err %v)", len(page), err)
}
if ids, err := store.Identities(ctx, a); err != nil || len(ids) != 1 || ids[0].Kind != account.KindTelegram {
t.Fatalf("identities for a = %+v (err %v)", ids, err)
}
gBefore, _ := svc.CountGames(ctx, "")
if _, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: []uuid.UUID{a, b}, TurnTimeout: 24 * time.Hour, Seed: 2}); err != nil {
t.Fatalf("create: %v", err)
}
if gAfter, _ := svc.CountGames(ctx, ""); gAfter != gBefore+1 {
t.Errorf("game count %d -> %d, want +1", gBefore, gAfter)
}
if active, err := svc.ListGames(ctx, game.StatusActive, 100, 0); err != nil || len(active) == 0 {
t.Fatalf("list active games = %d (err %v)", len(active), err)
}
}
// TestConsoleServesAndGuardsCSRF drives the /_gm console over HTTP against real
// stores: pages render, and a state-changing POST needs a same-origin header.
func TestConsoleServesAndGuardsCSRF(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: 3})
if err != nil {
t.Fatalf("create: %v", err)
}
filed, err := svc.FileComplaint(ctx, g.ID, seats[0], "qwxz", "review")
if err != nil {
t.Fatalf("file: %v", err)
}
srv := server.New(":0", server.Deps{
Logger: zap.NewNop(),
Accounts: account.NewStore(testDB),
Games: svc,
Registry: testRegistry,
DictDir: dictDir(),
})
h := srv.Handler()
base := "http://admin.test/_gm"
if code, body := consoleDo(h, http.MethodGet, base+"/", "", ""); code != http.StatusOK || !strings.Contains(body, "Dashboard") {
t.Fatalf("dashboard = %d, has Dashboard=%v", code, strings.Contains(body, "Dashboard"))
}
if code, body := consoleDo(h, http.MethodGet, base+"/complaints/"+filed.ID.String(), "", ""); code != http.StatusOK || !strings.Contains(body, "qwxz") {
t.Fatalf("complaint detail = %d, has word=%v", code, strings.Contains(body, "qwxz"))
}
// A resolve POST without a same-origin header is rejected by the CSRF guard.
if code, _ := consoleDo(h, http.MethodPost, base+"/complaints/"+filed.ID.String()+"/resolve", "disposition=reject", ""); code != http.StatusForbidden {
t.Fatalf("resolve without origin = %d, want 403", code)
}
// With a matching Origin it succeeds and persists.
if code, body := consoleDo(h, http.MethodPost, base+"/complaints/"+filed.ID.String()+"/resolve", "disposition=reject&note=ok", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Resolved") {
t.Fatalf("resolve with origin = %d, has Resolved=%v", code, strings.Contains(body, "Resolved"))
}
if got, err := svc.GetComplaint(ctx, filed.ID); err != nil || got.Status != game.StatusComplaintResolved {
t.Fatalf("complaint not resolved: %+v (err %v)", got, err)
}
}
// 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.
func TestConsoleGameDetailRobotSchedule(t *testing.T) {
ctx := context.Background()
svc := newGameService()
robotAcc, err := account.NewStore(testDB).ProvisionRobot(ctx, "robot-admin-"+uuid.NewString(), "Robo Tester")
if err != nil {
t.Fatalf("provision robot: %v", err)
}
human := provisionAccount(t)
// Seat the robot first so it is to move (seat 0), exposing the next-move ETA.
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{robotAcc.ID, human}, TurnTimeout: 24 * time.Hour, Seed: 7,
})
if err != nil {
t.Fatalf("create: %v", err)
}
srv := server.New(":0", server.Deps{
Logger: zap.NewNop(), Accounts: account.NewStore(testDB), Games: svc, Registry: testRegistry, DictDir: dictDir(),
})
code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/games/"+g.ID.String(), "", "")
if code != http.StatusOK {
t.Fatalf("game detail = %d, want 200", code)
}
if !strings.Contains(body, "🤖") {
t.Error("robot seat is not marked in the game detail")
}
if !strings.Contains(body, "play to win") && !strings.Contains(body, "play to lose") {
t.Error("robot play-to-win intent missing")
}
if !strings.Contains(body, "next move") {
t.Error("robot is to move but the next-move ETA is missing")
}
if !strings.Contains(body, "~40%") {
t.Error("robot play-to-win target caption missing")
}
}
// 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
// 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) {
req := httptest.NewRequest(method, target, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if origin != "" {
req.Header.Set("Origin", origin)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code, rec.Body.String()
}
@@ -0,0 +1,81 @@
//go:build integration
package inttest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/game"
)
// TestMoveDurationAnalytics seeds a game with crafted move timestamps and checks the
// admin-console move-duration reports compute the think time (gap to the previous
// move, the first move measured from game creation) correctly, per account and per
// the account's move ordinal.
func TestMoveDurationAnalytics(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
a, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision A: %v", err)
}
b, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision B: %v", err)
}
gid := uuid.New()
t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
if _, err := testDB.ExecContext(ctx,
`INSERT INTO backend.games (game_id, variant, dict_version, seed, players, turn_timeout_secs, created_at)
VALUES ($1,'scrabble_en','v1',1,2,86400,$2)`, gid, t0); err != nil {
t.Fatalf("insert game: %v", err)
}
if _, err := testDB.ExecContext(ctx,
`INSERT INTO backend.game_players (game_id, seat, account_id) VALUES ($1,0,$2),($1,1,$3)`, gid, a.ID, b.ID); err != nil {
t.Fatalf("insert seats: %v", err)
}
// seq, seat, commit time as seconds from t0. Durations: A:60,50 B:120,200.
moves := []struct{ seq, seat, at int }{{0, 0, 60}, {1, 1, 180}, {2, 0, 230}, {3, 1, 430}}
for _, m := range moves {
if _, err := testDB.ExecContext(ctx,
`INSERT INTO backend.game_moves (game_id, seq, seat, action, created_at) VALUES ($1,$2,$3,'play',$4)`,
gid, m.seq, m.seat, t0.Add(time.Duration(m.at)*time.Second)); err != nil {
t.Fatalf("insert move %d: %v", m.seq, err)
}
}
store := game.NewStore(testDB)
stats, err := store.MoveDurationStats(ctx, []uuid.UUID{a.ID, b.ID})
if err != nil {
t.Fatalf("stats: %v", err)
}
if sa := stats[a.ID]; sa.Moves != 2 || sa.MinSecs != 50 || sa.MaxSecs != 60 || sa.AvgSecs != 55 {
t.Errorf("A stats = %+v, want min50 max60 avg55 moves2", sa)
}
if sb := stats[b.ID]; sb.Moves != 2 || sb.MinSecs != 120 || sb.MaxSecs != 200 || sb.AvgSecs != 160 {
t.Errorf("B stats = %+v, want min120 max200 avg160 moves2", sb)
}
byOrd, err := store.MoveDurationByOrdinal(ctx, a.ID)
if err != nil {
t.Fatalf("by ordinal: %v", err)
}
want := []game.OrdinalDuration{
{Ordinal: 1, MinSecs: 60, MaxSecs: 60, AvgSecs: 60},
{Ordinal: 2, MinSecs: 50, MaxSecs: 50, AvgSecs: 50},
}
if len(byOrd) != len(want) {
t.Fatalf("by ordinal = %+v, want %+v", byOrd, want)
}
for i, w := range want {
if byOrd[i] != w {
t.Errorf("ordinal[%d] = %+v, want %+v", i, byOrd[i], w)
}
}
}
+79
View File
@@ -0,0 +1,79 @@
//go:build integration
package inttest
import (
"context"
"testing"
"scrabble/backend/internal/game"
)
// TestDraftPersistAndConflictReset covers draft persistence: a round-trip of the
// 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).
func TestDraftPersistAndConflictReset(t *testing.T) {
ctx := context.Background()
svc, gameID, seats, hint := newDraftGame(t)
// Round-trip seat 0's rack order + a board draft.
d0 := game.Draft{RackOrder: "QANIWE?", BoardTiles: []game.DraftTile{{Row: 1, Col: 1, Letter: "Q"}}}
if err := svc.SaveDraft(ctx, gameID, seats[0], d0); err != nil {
t.Fatalf("save draft 0: %v", err)
}
if got, err := svc.GetDraft(ctx, gameID, seats[0]); err != nil ||
got.RackOrder != "QANIWE?" || len(got.BoardTiles) != 1 || got.BoardTiles[0].Letter != "Q" {
t.Fatalf("get draft 0 = %+v (err %v)", got, err)
}
// Seat 1 drafts a board tile on a cell the opening play will commit.
overlap := hint.Tiles[0]
if err := svc.SaveDraft(ctx, gameID, seats[1], game.Draft{
RackOrder: "ABCDEFG",
BoardTiles: []game.DraftTile{{Row: overlap.Row, Col: overlap.Col, Letter: "X"}},
}); err != nil {
t.Fatalf("save draft 1: %v", err)
}
if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Dir, hint.Tiles); err != nil {
t.Fatalf("seat0 play: %v", err)
}
// Seat 0's own draft is cleared by their move.
if d, _ := svc.GetDraft(ctx, gameID, seats[0]); d.RackOrder != "" || len(d.BoardTiles) != 0 {
t.Errorf("actor draft not cleared: %+v", d)
}
// Seat 1's board draft overlapped the play and is reset; the rack order is kept.
if d, _ := svc.GetDraft(ctx, gameID, seats[1]); len(d.BoardTiles) != 0 || d.RackOrder != "ABCDEFG" {
t.Errorf("conflicting draft not reset (or rack order lost): %+v", d)
}
}
// TestDraftSurvivesNonConflictingMove checks an opponent's board draft is kept when a
// committed play does not touch any of its cells.
func TestDraftSurvivesNonConflictingMove(t *testing.T) {
ctx := context.Background()
svc, gameID, seats, hint := newDraftGame(t)
// Seat 1 drafts a far corner tile the central opening play cannot reach.
if err := svc.SaveDraft(ctx, gameID, seats[1], game.Draft{
BoardTiles: []game.DraftTile{{Row: 0, Col: 0, Letter: "Z"}},
}); err != nil {
t.Fatalf("save draft 1: %v", err)
}
if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Dir, hint.Tiles); err != nil {
t.Fatalf("seat0 play: %v", err)
}
if d, _ := svc.GetDraft(ctx, gameID, seats[1]); len(d.BoardTiles) != 1 || d.BoardTiles[0].Letter != "Z" {
t.Errorf("non-conflicting draft should survive: %+v", d)
}
}
// TestSaveDraftRejectsOutsider checks only a seated player may save a draft.
func TestSaveDraftRejectsOutsider(t *testing.T) {
ctx := context.Background()
svc, gameID, _, _ := newDraftGame(t)
if err := svc.SaveDraft(ctx, gameID, provisionAccount(t), game.Draft{RackOrder: "X"}); err == nil {
t.Fatal("outsider SaveDraft should fail")
}
}
+47 -1
View File
@@ -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.
func TestUpdateProfileOffsetTimezone(t *testing.T) {
ctx := context.Background()
@@ -181,3 +181,49 @@ func TestUpdateProfileOffsetTimezone(t *testing.T) {
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)
}
}
+64 -72
View File
@@ -4,88 +4,17 @@ package inttest
import (
"context"
"database/sql"
"errors"
"sync"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"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
// games the account is seated in (each with its seats), and nothing for an outsider.
func TestListForAccount(t *testing.T) {
@@ -299,6 +228,42 @@ func TestResignWinnerAndStats(t *testing.T) {
}
}
// 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
// seat while it is not its turn — no ErrNotYourTurn, the game ends, and seat 0 loses
// despite leading on score.
func TestResignOnOpponentTurn(t *testing.T) {
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 {
t.Fatal("no opening move")
}
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err != nil { // p0 scores, now p1's turn
t.Fatalf("p0 play: %v", err)
}
res, err := svc.Resign(ctx, g.ID, seats[0]) // p0 resigns OFF turn
if err != nil {
t.Fatalf("off-turn resign = %v, want nil", err)
}
if res.Game.Status != game.StatusFinished || res.Game.EndReason != "resign" {
t.Fatalf("after off-turn resign: %+v", res.Game)
}
if res.Game.Seats[0].IsWinner || !res.Game.Seats[1].IsWinner {
t.Errorf("winner flags wrong (resigner must lose): %+v", res.Game.Seats)
}
}
// TestTimeoutSweep auto-resigns an overdue game and records it as a timeout.
func TestTimeoutSweep(t *testing.T) {
ctx := context.Background()
@@ -312,6 +277,13 @@ func TestTimeoutSweep(t *testing.T) {
}
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
// Disable the to-move account's away window: with the default 00:0007:00
// window the sweeper (correctly) declines to time out a player whose deadline
// fell while they were asleep, which made this test fail whenever CI ran with
// now-1h inside that window (e.g. ~07:00 UTC). An empty window keeps the test
// deterministic regardless of the time of day.
setAway(t, seats[0], "UTC", "00:00", "00:00")
// The sweep is global over the shared pool; assert the target game itself,
// not the count, since other tests leave active games behind.
if n, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
@@ -421,6 +393,26 @@ func TestHintPolicy(t *testing.T) {
}
}
// TestGameVariant covers the edge's lightweight variant lookup: it returns the
// created game's variant and ErrNotFound for an unknown id.
func TestGameVariant(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: 1,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if v, err := svc.GameVariant(ctx, g.ID); err != nil || v != engine.VariantEnglish {
t.Fatalf("GameVariant = %v, %v; want scrabble_en, nil", v, err)
}
if _, err := svc.GameVariant(ctx, uuid.New()); !errors.Is(err, game.ErrNotFound) {
t.Errorf("GameVariant(unknown) = %v, want ErrNotFound", err)
}
}
// TestCheckWordAndComplaint covers the word-check tool and complaint capture.
func TestCheckWordAndComplaint(t *testing.T) {
ctx := context.Background()
@@ -556,7 +548,7 @@ func equalStrings(a, b []string) bool {
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.
func TestExportGCGRefusesActiveGame(t *testing.T) {
ctx := context.Background()
@@ -0,0 +1,76 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// TestGuestReaper verifies the abandoned-guest reaper: it deletes guests with no
// game seat once their account age is past the cutoff, while sparing guests that
// are too young, guests seated in a game (the FK-protected opponent history), and
// durable accounts.
func TestGuestReaper(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
guestA := provisionGuest(t) // guest, no seat → reaped on a future cutoff
guestB := provisionGuest(t) // guest, no seat → reaped on a future cutoff
seated := provisionGuest(t) // guest seated in a game → kept
durable := provisionAccount(t)
// Seat the third guest in a game with a durable opponent (Create needs 2-4).
opp := provisionAccount(t)
if _, err := newGameService().Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{seated, opp}, TurnTimeout: 24 * time.Hour, Seed: 1,
}); err != nil {
t.Fatalf("create game: %v", err)
}
// A cutoff in the past: every account is younger than the window, so the age
// gate spares them all.
if n, err := store.ReapAbandonedGuests(ctx, time.Now().Add(-time.Hour)); err != nil {
t.Fatalf("reap (past cutoff): %v", err)
} else if n != 0 {
t.Fatalf("reap with a past cutoff deleted %d, want 0", n)
}
assertAccount(t, store, guestA, true)
// A cutoff in the future: every account predates it, so the no-seat guests are
// reaped and the seated guest and the durable account survive.
if _, err := store.ReapAbandonedGuests(ctx, time.Now().Add(time.Hour)); err != nil {
t.Fatalf("reap (future cutoff): %v", err)
}
assertAccount(t, store, guestA, false)
assertAccount(t, store, guestB, false)
assertAccount(t, store, seated, true)
assertAccount(t, store, durable, true)
}
// assertAccount checks whether the account with id is present, failing the test
// when its presence differs from want.
func assertAccount(t *testing.T, store *account.Store, id uuid.UUID, want bool) {
t.Helper()
_, err := store.GetByID(context.Background(), id)
switch {
case err == nil:
if !want {
t.Errorf("account %s still exists, want reaped", id)
}
case errors.Is(err, account.ErrNotFound):
if want {
t.Errorf("account %s was reaped, want kept", id)
}
default:
t.Fatalf("get account %s: %v", id, err)
}
}
+167
View File
@@ -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
}
+67
View File
@@ -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
}
+302
View File
@@ -0,0 +1,302 @@
//go:build integration
package inttest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountmerge"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/link"
"scrabble/backend/internal/session"
)
// --- merge test helpers ---
func setStats(t *testing.T, id uuid.UUID, w, l, d, mg, mw int) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`INSERT INTO backend.account_stats (account_id, wins, losses, draws, max_game_points, max_word_points)
VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (account_id) DO UPDATE SET wins=$2, losses=$3, draws=$4, max_game_points=$5, max_word_points=$6`,
id, w, l, d, mg, mw); err != nil {
t.Fatalf("set stats: %v", err)
}
}
func setWallet(t *testing.T, id uuid.UUID, hints int, paid bool) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE backend.accounts SET hint_balance=$2, paid_account=$3 WHERE account_id=$1`, id, hints, paid); err != nil {
t.Fatalf("set wallet: %v", err)
}
}
func bindEmailIdentity(t *testing.T, acc uuid.UUID, email string) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`INSERT INTO backend.identities (identity_id, account_id, kind, external_id, confirmed) VALUES ($1,$2,'email',$3,true)`,
uuid.New(), acc, email); err != nil {
t.Fatalf("bind email identity: %v", err)
}
}
func insertFriendship(t *testing.T, a, b uuid.UUID, status string) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`INSERT INTO backend.friendships (requester_id, addressee_id, status) VALUES ($1,$2,$3)`, a, b, status); err != nil {
t.Fatalf("insert friendship: %v", err)
}
}
func mergedInto(t *testing.T, id uuid.UUID) uuid.UUID {
t.Helper()
var into *uuid.UUID
if err := testDB.QueryRowContext(context.Background(),
`SELECT merged_into FROM backend.accounts WHERE account_id=$1`, id).Scan(&into); err != nil {
t.Fatalf("read merged_into: %v", err)
}
if into == nil {
return uuid.Nil
}
return *into
}
func seatCount(t *testing.T, gameID, accountID uuid.UUID) int {
t.Helper()
var n int
if err := testDB.QueryRowContext(context.Background(),
`SELECT count(*) FROM backend.game_players WHERE game_id=$1 AND account_id=$2`, gameID, accountID).Scan(&n); err != nil {
t.Fatalf("seat count: %v", err)
}
return n
}
func seatGame(t *testing.T, seats []uuid.UUID, timeout time.Duration) uuid.UUID {
t.Helper()
g, err := newGameService().Create(context.Background(), game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: timeout, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("seat game: %v", err)
}
return g.ID
}
func newLinkService(mailer account.Mailer) *link.Service {
store := account.NewStore(testDB)
emails := account.NewEmailService(store, mailer)
sessions := session.NewService(session.NewStore(testDB), session.NewCache())
return link.NewService(emails, store, accountmerge.NewMerger(testDB), sessions)
}
// TestAccountMergeCore folds every account-scoped artifact of a secondary into a
// primary: stats summed, wallet summed, paid flag ORed, identity repointed, a
// non-shared game transferred, a friend carried over, and the secondary tombstoned.
func TestAccountMergeCore(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
merger := accountmerge.NewMerger(testDB)
primary := provisionAccount(t)
secondary := provisionAccount(t)
friend := provisionAccount(t)
setStats(t, primary, 1, 0, 0, 100, 90)
setStats(t, secondary, 3, 1, 2, 400, 80)
setWallet(t, primary, 2, false)
setWallet(t, secondary, 5, true)
email := "merge-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, secondary, email)
insertFriendship(t, secondary, friend, "accepted")
gameID := seatGame(t, []uuid.UUID{secondary, friend}, 24*time.Hour)
if err := merger.Merge(ctx, primary, secondary); err != nil {
t.Fatalf("merge: %v", err)
}
w, l, d, mg, mw, found := readStats(t, primary)
if !found || w != 4 || l != 1 || d != 2 || mg != 400 || mw != 90 {
t.Errorf("primary stats = (%d,%d,%d,%d,%d found=%v), want (4,1,2,400,90 true)", w, l, d, mg, mw, found)
}
if _, _, _, _, _, found := readStats(t, secondary); found {
t.Error("secondary stats row should be deleted after merge")
}
acc, err := store.GetByID(ctx, primary)
if err != nil {
t.Fatalf("get primary: %v", err)
}
if acc.HintBalance != 7 {
t.Errorf("hint balance = %d, want 7", acc.HintBalance)
}
if !acc.PaidAccount {
t.Error("paid_account should be true (ORed from secondary)")
}
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); !ok || owner != primary {
t.Errorf("email owner = %s ok=%v, want primary %s", owner, ok, primary)
}
if seatCount(t, gameID, primary) != 1 || seatCount(t, gameID, secondary) != 0 {
t.Error("non-shared game seat should transfer to primary")
}
if friends, _ := newSocialService().ListFriends(ctx, primary); len(friends) != 1 || friends[0] != friend {
t.Errorf("primary friends = %v, want [%s]", friends, friend)
}
if mergedInto(t, secondary) != primary {
t.Errorf("secondary.merged_into = %s, want primary %s", mergedInto(t, secondary), primary)
}
}
// TestAccountMergeActiveGameConflict refuses a merge when the two share an active
// game (one player cannot be merged against themselves).
func TestAccountMergeActiveGameConflict(t *testing.T) {
ctx := context.Background()
merger := accountmerge.NewMerger(testDB)
primary := provisionAccount(t)
secondary := provisionAccount(t)
seatGame(t, []uuid.UUID{primary, secondary}, 24*time.Hour)
if err := merger.Merge(ctx, primary, secondary); err != accountmerge.ErrActiveGameConflict {
t.Fatalf("merge = %v, want ErrActiveGameConflict", err)
}
if mergedInto(t, secondary) != uuid.Nil {
t.Error("a refused merge must not tombstone the secondary")
}
}
// TestAccountMergeFinishedSharedGameKept allows a merge when the shared game is
// finished and leaves the secondary's seat in place (the tombstone keeps the
// no-cascade foreign key valid).
func TestAccountMergeFinishedSharedGameKept(t *testing.T) {
ctx := context.Background()
merger := accountmerge.NewMerger(testDB)
primary := provisionAccount(t)
secondary := provisionAccount(t)
gameID := seatGame(t, []uuid.UUID{primary, secondary}, 24*time.Hour)
if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET status='finished' WHERE game_id=$1`, gameID); err != nil {
t.Fatalf("finish game: %v", err)
}
if err := merger.Merge(ctx, primary, secondary); err != nil {
t.Fatalf("merge: %v", err)
}
if seatCount(t, gameID, primary) != 1 || seatCount(t, gameID, secondary) != 1 {
t.Error("a shared finished game must keep both seats (secondary as tombstone)")
}
if mergedInto(t, secondary) != primary {
t.Error("secondary should be tombstoned")
}
}
// TestAccountLinkFreeEmail binds a free email and promotes a guest to durable.
func TestAccountLinkFreeEmail(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
links := newLinkService(mailer)
guest := provisionGuest(t)
email := "fresh-" + uuid.NewString() + "@example.com"
if err := links.RequestEmail(ctx, guest, email); err != nil {
t.Fatalf("request: %v", err)
}
res, err := links.ConfirmEmail(ctx, guest, email, sixDigit.FindString(mailer.lastBody))
if err != nil {
t.Fatalf("confirm: %v", err)
}
if !res.Linked || res.MergeRequired {
t.Fatalf("confirm = %+v, want linked", res)
}
acc, _ := store.GetByID(ctx, guest)
if acc.IsGuest {
t.Error("guest flag should clear once an identity is linked")
}
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); !ok || owner != guest {
t.Errorf("email owner = %s, want the promoted guest %s", owner, guest)
}
}
// TestAccountLinkEmailMergeIntoCaller merges the email's owner into the current
// (durable) account: the caller stays primary and keeps its session.
func TestAccountLinkEmailMergeIntoCaller(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
links := newLinkService(mailer)
caller := provisionAccount(t)
other := provisionAccount(t)
email := "owned-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, other, email)
if err := links.RequestEmail(ctx, caller, email); err != nil {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
confirm, err := links.ConfirmEmail(ctx, caller, email, code)
if err != nil {
t.Fatalf("confirm: %v", err)
}
if !confirm.MergeRequired || confirm.SecondaryID != other {
t.Fatalf("confirm = %+v, want merge_required to other %s", confirm, other)
}
merge, err := links.MergeEmail(ctx, caller, email, code)
if err != nil {
t.Fatalf("merge: %v", err)
}
if merge.PrimaryID != caller || merge.SwitchedToken != "" {
t.Fatalf("merge = %+v, want primary caller and no session switch", merge)
}
if mergedInto(t, other) != caller {
t.Error("other should be tombstoned into caller")
}
if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); owner != caller {
t.Errorf("email owner = %s, want caller", owner)
}
}
// TestAccountLinkGuestInversion merges a guest initiator into the durable account
// that owns the email: the durable account wins and a fresh session is minted.
func TestAccountLinkGuestInversion(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
links := newLinkService(mailer)
durable := provisionAccount(t)
email := "durable-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, durable, email)
guest := provisionGuest(t)
if err := links.RequestEmail(ctx, guest, email); err != nil {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := links.ConfirmEmail(ctx, guest, email, code); err != nil {
t.Fatalf("confirm: %v", err)
}
merge, err := links.MergeEmail(ctx, guest, email, code)
if err != nil {
t.Fatalf("merge: %v", err)
}
if merge.PrimaryID != durable {
t.Fatalf("primary = %s, want durable %s", merge.PrimaryID, durable)
}
if merge.SwitchedToken == "" {
t.Error("a guest initiator whose durable counterpart wins must get a switched session token")
}
if mergedInto(t, guest) != durable {
t.Error("the guest should be tombstoned into the durable account")
}
if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); owner != durable {
t.Errorf("email owner = %s, want durable", owner)
}
}
+22 -31
View File
@@ -8,31 +8,12 @@ import (
"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"
)
// 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
// idle) at a chosen instant, independent of wall time.
func setTurnStarted(t *testing.T, id uuid.UUID, at time.Time) {
@@ -82,19 +63,25 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) {
if err := r.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool (idempotent): %v", err)
}
id, err := r.Pick()
id, err := r.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick: %v", err)
}
if !isRobotAccount(t, id) {
t.Errorf("picked account %s is not a robot identity", id)
}
if ru, err := r.Pick(engine.VariantRussianScrabble); err != nil || !isRobotAccount(t, ru) {
t.Errorf("scrabble_ru pick = (%s, %v), want a robot account", ru, err)
}
acc, err := account.NewStore(testDB).GetByID(ctx, id)
if err != nil {
t.Fatalf("get robot account: %v", err)
}
if acc.DisplayName == "" || !acc.BlockChat || !acc.BlockFriendRequests {
t.Errorf("robot profile not set: name=%q chat=%v friends=%v", acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests)
// A robot blocks chat but NOT friend requests: a request to a robot stays pending and
// expires, mirroring a human who ignores it.
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)",
acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests)
}
}
@@ -109,7 +96,7 @@ func TestRobotPlaysAutoMatchToEnd(t *testing.T) {
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
robotID, err := robots.Pick()
robotID, err := robots.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick: %v", err)
}
@@ -201,8 +188,8 @@ func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) {
}
}
// TestRobotProactiveNudge checks the robot nudges the human after the idle
// threshold on the human's turn.
// TestRobotProactiveNudge checks the robot's lengthening proactive-nudge schedule on the
// human's turn: nothing before the ~60-90 min first gap, exactly one once it has elapsed.
func TestRobotProactiveNudge(t *testing.T) {
ctx := context.Background()
svc := newGameService()
@@ -210,7 +197,7 @@ func TestRobotProactiveNudge(t *testing.T) {
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
robotID, err := robots.Pick()
robotID, err := robots.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick: %v", err)
}
@@ -226,14 +213,18 @@ func TestRobotProactiveNudge(t *testing.T) {
t.Fatalf("create: %v", err)
}
// Midnight start, driven 13 hours later (>12h idle) at a daytime hour awake for
// every drift.
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
// A daytime turn start (the robot is awake for every ±3h drift between 07:30 and 12:00). No
// nudge before the 60-min floor of the first gap; exactly one once past its 90-min ceiling.
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
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 {
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)
}
}
+263 -15
View File
@@ -6,6 +6,7 @@ import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
@@ -14,30 +15,66 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/social"
fb "scrabble/pkg/fbs/scrabblefb"
)
// 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())
// capturePublisher records every published intent for assertions on live events.
type capturePublisher struct {
mu sync.Mutex
intents []notify.Intent
}
// 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)
func (c *capturePublisher) Publish(in ...notify.Intent) {
c.mu.Lock()
defer c.mu.Unlock()
c.intents = append(c.intents, in...)
}
g, err := newGameService().Create(context.Background(), game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
})
// notified reports whether a Notification with the given sub-kind was published to user.
func (c *capturePublisher) notified(user uuid.UUID, sub string) bool {
c.mu.Lock()
defer c.mu.Unlock()
for _, in := range c.intents {
if in.UserID == user && in.Kind == notify.KindNotification &&
string(fb.GetRootAsNotificationEvent(in.Payload, 0).Kind()) == sub {
return true
}
}
return false
}
// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as
// pending rather than blocked: robots no longer block friend requests, so the request
// just sits unanswered and later expires — mirroring a human who ignores it.
func TestFriendRequestToRobotStaysPending(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
accs := account.NewStore(testDB)
human := provisionAccount(t)
robot, err := accs.ProvisionRobot(ctx, "robot-friend-"+uuid.NewString(), "Robbie")
if err != nil {
t.Fatalf("provision robot: %v", err)
}
if robot.BlockFriendRequests {
t.Fatal("robot must not block friend requests")
}
// A request is only allowed between players who share a game.
if _, err := newGameService().Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID},
TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
}); err != nil {
t.Fatalf("create game: %v", err)
}
return g.ID, seats
if err := svc.SendFriendRequest(ctx, human, robot.ID); err != nil {
t.Fatalf("request to robot = %v, want nil (accepted as pending)", err)
}
if got, _ := svc.ListIncomingRequests(ctx, robot.ID); len(got) != 1 || got[0] != human {
t.Fatalf("robot incoming = %v, want [human]", got)
}
}
func TestFriendRequestLifecycle(t *testing.T) {
@@ -282,6 +319,20 @@ func TestChatRejectsBadContent(t *testing.T) {
}
}
// TestChatOnlyOnYourTurn checks chat is allowed only on the sender's own turn:
// the player to move can post, the waiting player gets ErrChatNotYourTurn.
func TestChatOnlyOnYourTurn(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move at the opening
if _, err := svc.PostMessage(ctx, gameID, seats[1], "hi", ""); !errors.Is(err, social.ErrChatNotYourTurn) {
t.Fatalf("off-turn chat = %v, want ErrChatNotYourTurn", err)
}
if _, err := svc.PostMessage(ctx, gameID, seats[0], "hi", ""); err != nil {
t.Fatalf("on-turn chat = %v, want nil", err)
}
}
func TestNudgeRulesAndRateLimit(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
@@ -307,3 +358,200 @@ func TestNudgeRulesAndRateLimit(t *testing.T) {
t.Fatalf("nudge after window: %v", err)
}
}
// TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has
// acted (moved or chatted) since their last nudge, even within the hour.
func TestNudgeCooldownResetsOnAction(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gsvc := newGameService()
gameID, seats := newGameWithSeats(t, 2) // seat 0 to move
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { // the waiting player nudges
t.Fatalf("nudge: %v", err)
}
if _, err := svc.Nudge(ctx, gameID, seats[1]); !errors.Is(err, social.ErrNudgeTooSoon) {
t.Fatalf("rapid nudge = %v, want ErrNudgeTooSoon", err)
}
// Seat 1 takes a turn: seat 0 passes (-> seat 1's turn), seat 1 chats then passes.
if _, err := gsvc.Pass(ctx, gameID, seats[0]); err != nil {
t.Fatalf("seat0 pass: %v", err)
}
if _, err := svc.PostMessage(ctx, gameID, seats[1], "thinking", ""); err != nil {
t.Fatalf("seat1 chat: %v", err)
}
if _, err := gsvc.Pass(ctx, gameID, seats[1]); err != nil {
t.Fatalf("seat1 pass: %v", err)
}
// Back on the opponent's turn, the cooldown is reset by the action since the nudge.
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
t.Fatalf("nudge after acting = %v, want allowed (cooldown reset)", err)
}
}
// TestListOutgoingRequests checks the requester-side list that backs the in-game "add to
// 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
// still "sent"); a lazily expired pending one drops (it may be re-sent).
func TestListOutgoingRequests(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
// Pending: outgoing for the requester, not the addressee.
_, s1 := newGameWithSeats(t, 2)
a, b := s1[0], s1[1]
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
t.Fatalf("send: %v", err)
}
if got, _ := svc.ListOutgoingRequests(ctx, a); len(got) != 1 || got[0] != b {
t.Fatalf("outgoing pending = %v, want [b]", got)
}
if got, _ := svc.ListOutgoingRequests(ctx, b); len(got) != 0 {
t.Fatalf("addressee outgoing = %v, want none", got)
}
// Accepted: a friendship, no longer an outgoing request.
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
t.Fatalf("accept: %v", err)
}
if got, _ := svc.ListOutgoingRequests(ctx, a); len(got) != 0 {
t.Fatalf("outgoing after accept = %v, want none", got)
}
// Declined: stays outgoing (reads as sent; cannot re-send).
_, s2 := newGameWithSeats(t, 2)
c, d := s2[0], s2[1]
if err := svc.SendFriendRequest(ctx, c, d); err != nil {
t.Fatalf("send2: %v", err)
}
if err := svc.RespondFriendRequest(ctx, d, c, false); err != nil {
t.Fatalf("decline: %v", err)
}
if got, _ := svc.ListOutgoingRequests(ctx, c); len(got) != 1 || got[0] != d {
t.Fatalf("outgoing after decline = %v, want [d]", got)
}
// Lazily expired pending: omitted (may be re-sent).
_, s3 := newGameWithSeats(t, 2)
e, f := s3[0], s3[1]
if err := svc.SendFriendRequest(ctx, e, f); err != nil {
t.Fatalf("send3: %v", err)
}
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.friendships SET created_at = now() - interval '31 days' WHERE requester_id = $1 AND addressee_id = $2`, e, f); err != nil {
t.Fatalf("backdate: %v", err)
}
if got, _ := svc.ListOutgoingRequests(ctx, e); len(got) != 0 {
t.Fatalf("expired outgoing = %v, want none", got)
}
}
// TestRespondPublishesToRequester checks that answering a request notifies the original
// requester over the live channel: accept -> friend_added, decline ->
// friend_declined, so a game screen watching that opponent re-derives its friend state.
func TestRespondPublishesToRequester(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
_, s1 := newGameWithSeats(t, 2)
a, b := s1[0], s1[1]
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
t.Fatalf("send: %v", err)
}
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
t.Fatalf("accept: %v", err)
}
if !pub.notified(a, notify.NotifyFriendAdded) {
t.Errorf("accept did not notify requester with %q", notify.NotifyFriendAdded)
}
_, s2 := newGameWithSeats(t, 2)
c, d := s2[0], s2[1]
if err := svc.SendFriendRequest(ctx, c, d); err != nil {
t.Fatalf("send2: %v", err)
}
if err := svc.RespondFriendRequest(ctx, d, c, false); err != nil {
t.Fatalf("decline: %v", err)
}
if !pub.notified(c, notify.NotifyFriendDeclined) {
t.Errorf("decline did not notify requester with %q", notify.NotifyFriendDeclined)
}
}
// TestNudgeRoutedByGameLanguage checks a nudge's out-of-app push carries the game's language, so
// it is delivered by the game's bot rather than the recipient's last-login bot.
func TestNudgeRoutedByGameLanguage(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
gameID, seats := newGameWithSeats(t, 2) // an English game; seat 0 is to move
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
t.Fatalf("nudge: %v", err)
}
found := false
for _, in := range pub.intents {
if in.Kind == notify.KindNudge {
found = true
if in.Language != "en" {
t.Errorf("nudge language = %q, want en (the game's language)", in.Language)
}
}
}
if !found {
t.Fatal("no nudge intent published")
}
}
// TestAdminListMessages checks the admin moderation list: real messages only
// (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
func TestAdminListMessages(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move
if _, err := svc.PostMessage(ctx, gameID, seats[0], "good luck", "203.0.113.9"); err != nil {
t.Fatalf("post: %v", err)
}
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { // the waiting player nudges
t.Fatalf("nudge: %v", err)
}
// Pinned to the game: the message is listed; the nudge (kind=nudge) is excluded.
msgs, err := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID}, 50, 0)
if err != nil {
t.Fatalf("admin list: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("game messages = %d, want 1 (nudge excluded)", len(msgs))
}
if m := msgs[0]; m.Body != "good luck" || m.SenderID != seats[0] || m.SenderIP != "203.0.113.9" {
t.Fatalf("message = %+v, want body=good luck sender=seat0 ip=203.0.113.9", m)
}
if msgs[0].Source != "telegram" { // provisionAccount provisions a telegram identity
t.Errorf("source = %q, want telegram", msgs[0].Source)
}
if n, _ := svc.AdminCountMessages(ctx, social.AdminMessageFilter{GameID: gameID}); n != 1 {
t.Errorf("count = %d, want 1", n)
}
// Sender pin: seat 0 has the message; seat 1 has only a nudge.
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{SenderID: seats[0]}, 50, 0); len(got) == 0 {
t.Error("sender=seat0 returned nothing")
}
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, SenderID: seats[1]}, 50, 0); len(got) != 0 {
t.Errorf("sender=seat1 has only a nudge, got %d messages", len(got))
}
// Sender glob masks: the telegram external id matches "tg-*"; bogus masks exclude.
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, ExtMask: "tg-*"}, 50, 0); len(got) != 1 {
t.Errorf("ext mask tg-* = %d, want 1", len(got))
}
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, ExtMask: "zzz-*"}, 50, 0); len(got) != 0 {
t.Errorf("ext mask zzz-* = %d, want 0", len(got))
}
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, NameMask: "zzz-no-such-*"}, 50, 0); len(got) != 0 {
t.Errorf("name mask miss = %d, want 0", len(got))
}
}
-130
View File
@@ -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()
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)
}
}
+86
View File
@@ -0,0 +1,86 @@
//go:build integration
package inttest
import (
"context"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
// TestUserListFilter checks the admin user-list filter: the people/robots split (by a
// robot identity) and the case-insensitive glob masks on display name and external id.
func TestUserListFilter(t *testing.T) {
ctx := context.Background()
st := account.NewStore(testDB)
uniq := uuid.NewString()
human, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman")
if err != nil {
t.Fatalf("provision human: %v", err)
}
robot, err := st.ProvisionRobot(ctx, "robot-uxz-"+uniq, "Zzqxbot")
if err != nil {
t.Fatalf("provision robot: %v", err)
}
guest, err := st.ProvisionGuest(ctx)
if err != nil {
t.Fatalf("provision guest: %v", err)
}
collect := func(f account.UserFilter) map[uuid.UUID]account.UserListItem {
items, err := st.ListUsers(ctx, f, 5000, 0)
if err != nil {
t.Fatalf("list users %+v: %v", f, err)
}
m := make(map[uuid.UUID]account.UserListItem, len(items))
for _, it := range items {
m[it.ID] = it
}
return m
}
people := collect(account.UserFilter{})
if _, ok := people[human.ID]; !ok {
t.Error("human missing from people")
}
if _, ok := people[guest.ID]; !ok {
t.Error("guest missing from people")
}
if _, ok := people[robot.ID]; ok {
t.Error("robot must not appear in people")
}
if it := people[human.ID]; it.IsRobot || it.IsGuest {
t.Errorf("human flags wrong: robot=%v guest=%v (want both false)", it.IsRobot, it.IsGuest)
}
robots := collect(account.UserFilter{Robots: true})
if it, ok := robots[robot.ID]; !ok || !it.IsRobot {
t.Errorf("robot missing from robots or IsRobot=false (ok=%v)", ok)
}
if _, ok := robots[human.ID]; ok {
t.Error("human must not appear in robots")
}
// Name mask (people).
if _, ok := collect(account.UserFilter{NameMask: "Zzqx*"})[human.ID]; !ok {
t.Error("name mask Zzqx* should match the human")
}
if _, ok := collect(account.UserFilter{NameMask: "nomatch*"})[human.ID]; ok {
t.Error("name mask nomatch* should not match the human")
}
// External-id mask (robots).
if _, ok := collect(account.UserFilter{Robots: true, ExternalIDMask: "robot-uxz-*"})[robot.ID]; !ok {
t.Error("external-id mask robot-uxz-* should match the robot")
}
if _, ok := collect(account.UserFilter{Robots: true, ExternalIDMask: "robot-zzz-*"})[robot.ID]; ok {
t.Error("external-id mask robot-zzz-* should not match the robot")
}
// CountUsers agrees that robots exist.
if n, err := st.CountUsers(ctx, account.UserFilter{Robots: true, ExternalIDMask: "robot-uxz-*"}); err != nil || n != 1 {
t.Errorf("count robots robot-uxz-* = (%d, %v), want 1", n, err)
}
}
+163
View File
@@ -0,0 +1,163 @@
// Package link orchestrates account linking & merge (ARCHITECTURE.md §4).
// 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
// platform identity), binds a free identity to the current account, and — when the
// identity already has its own account — merges the two. The current account is the
// merge primary, except when the initiator is a guest and the other account is
// durable, in which case the durable account wins and a fresh session is minted for
// it (the client switches to it).
package link
import (
"context"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountmerge"
"scrabble/backend/internal/session"
)
// Service drives the link/merge flow.
type Service struct {
emails *account.EmailService
accounts *account.Store
merger *accountmerge.Merger
sessions *session.Service
}
// NewService constructs a Service over its collaborators.
func NewService(emails *account.EmailService, accounts *account.Store, merger *accountmerge.Merger, sessions *session.Service) *Service {
return &Service{emails: emails, accounts: accounts, merger: merger, sessions: sessions}
}
// ConfirmResult reports the outcome of a confirm step. Exactly one of Linked or
// MergeRequired is set; SecondaryID is the account to be retired when a merge is
// required (the caller renders an irreversible-merge confirmation from it).
type ConfirmResult struct {
Linked bool
MergeRequired bool
SecondaryID uuid.UUID
}
// MergeResult reports a completed merge. PrimaryID is the surviving account.
// SwitchedToken is a fresh session token for the primary when the active account
// changed (a guest initiator whose durable counterpart won); empty otherwise, in
// which case the caller keeps its current session.
type MergeResult struct {
PrimaryID uuid.UUID
SwitchedToken string
}
// RequestEmail mails a confirm-code for email to the caller (always sent).
func (s *Service) RequestEmail(ctx context.Context, accountID uuid.UUID, email string) error {
return s.emails.RequestLinkCode(ctx, accountID, email)
}
// ConfirmEmail verifies the code and either binds the free address to the caller
// (Linked) or reports that the address belongs to another account (MergeRequired).
func (s *Service) ConfirmEmail(ctx context.Context, accountID uuid.UUID, email, code string) (ConfirmResult, error) {
owner, linked, err := s.emails.ConfirmLink(ctx, accountID, email, code)
if err != nil {
return ConfirmResult{}, err
}
if linked {
if err := s.accounts.ClearGuest(ctx, accountID); err != nil {
return ConfirmResult{}, err
}
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
}
// MergeEmail re-verifies the code and merges the address's account into the
// caller's (subject to the guest-primary rule).
func (s *Service) MergeEmail(ctx context.Context, callerID uuid.UUID, email, code string) (MergeResult, error) {
owner, linked, err := s.emails.ConfirmLink(ctx, callerID, email, code)
if err != nil {
return MergeResult{}, err
}
if linked {
// Raced to free/self between confirm and merge: it is now simply linked.
if err := s.accounts.ClearGuest(ctx, callerID); err != nil {
return MergeResult{}, err
}
return MergeResult{PrimaryID: callerID}, nil
}
return s.merge(ctx, callerID, owner)
}
// ConfirmTelegram attaches a gateway-validated Telegram identity to the caller
// (Linked) or reports that it belongs to another account (MergeRequired).
func (s *Service) ConfirmTelegram(ctx context.Context, callerID uuid.UUID, externalID string) (ConfirmResult, error) {
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindTelegram, externalID)
if err != nil {
return ConfirmResult{}, err
}
if !ok {
if err := s.attachTelegram(ctx, callerID, externalID); err != nil {
return ConfirmResult{}, err
}
return ConfirmResult{Linked: true}, nil
}
if owner == callerID {
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
}
// MergeTelegram merges the account owning a gateway-validated Telegram identity
// into the caller's (subject to the guest-primary rule).
func (s *Service) MergeTelegram(ctx context.Context, callerID uuid.UUID, externalID string) (MergeResult, error) {
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindTelegram, externalID)
if err != nil {
return MergeResult{}, err
}
if !ok {
if err := s.attachTelegram(ctx, callerID, externalID); err != nil {
return MergeResult{}, err
}
return MergeResult{PrimaryID: callerID}, nil
}
if owner == callerID {
return MergeResult{PrimaryID: callerID}, nil
}
return s.merge(ctx, callerID, owner)
}
// attachTelegram links the identity to the caller and promotes a guest.
func (s *Service) attachTelegram(ctx context.Context, callerID uuid.UUID, externalID string) error {
if err := s.accounts.AttachIdentity(ctx, callerID, account.KindTelegram, externalID, true); err != nil {
return err
}
return s.accounts.ClearGuest(ctx, callerID)
}
// merge decides the primary (the caller, unless it is a guest and the other is
// durable), runs the data merge, retires the secondary's sessions and mints a new
// session when the active account switches.
func (s *Service) merge(ctx context.Context, callerID, otherID uuid.UUID) (MergeResult, error) {
caller, err := s.accounts.GetByID(ctx, callerID)
if err != nil {
return MergeResult{}, err
}
primary, secondary := callerID, otherID
if caller.IsGuest {
primary, secondary = otherID, callerID
}
if err := s.merger.Merge(ctx, primary, secondary); err != nil {
return MergeResult{}, err
}
if err := s.sessions.RevokeAllForAccount(ctx, secondary); err != nil {
return MergeResult{}, err
}
res := MergeResult{PrimaryID: primary}
if primary != callerID {
token, _, err := s.sessions.Create(ctx, primary)
if err != nil {
return MergeResult{}, err
}
res.SwitchedToken = token
}
return res, nil
}
+62 -8
View File
@@ -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.
func (svc *InvitationService) notify(kind string, userIDs ...uuid.UUID) {
if len(userIDs) == 0 {
// emitInvitation publishes the invitation notification to each invitee, carrying the invitation
// itself so the client adds it to its lobby list without a refetch.
func (svc *InvitationService) emitInvitation(ctx context.Context, inv Invitation, inviteeIDs []uuid.UUID) {
if len(inviteeIDs) == 0 {
return
}
intents := make([]notify.Intent, 0, len(userIDs))
for _, id := range userIDs {
intents = append(intents, notify.Notification(id, kind))
summary := svc.invitationSummary(ctx, inv)
intents := make([]notify.Intent, 0, len(inviteeIDs))
for _, id := range inviteeIDs {
intents = append(intents, notify.NotificationInvitation(id, summary))
}
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
// 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
@@ -176,7 +230,7 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
if err != nil {
return Invitation{}, err
}
svc.notify(notify.NotifyInvitation, inviteeIDs...)
svc.emitInvitation(ctx, inv, inviteeIDs)
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 {
return err
}
svc.notify(notify.NotifyGameStarted, seats...)
svc.emitGameStarted(ctx, g, seats)
return nil
}
+8 -2
View File
@@ -12,20 +12,26 @@ import (
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/notify"
)
// 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 {
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
// auto-match. robot.Service satisfies it; it returns an error when no robot is
// available so the matchmaker can defer substitution.
type RobotProvider interface {
Pick() (uuid.UUID, error)
Pick(variant engine.Variant) (uuid.UUID, error)
}
// Blocker reports whether two accounts have a block between them (either
+22 -8
View File
@@ -75,10 +75,21 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) {
// 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).
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))
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...)
}
@@ -125,7 +136,7 @@ func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant e
m.mu.Lock()
m.results[opponent] = g
m.mu.Unlock()
m.emitMatchFound(g)
m.emitMatchFound(ctx, g)
return EnqueueResult{Matched: true, Game: g}, nil
}
@@ -142,11 +153,14 @@ func (m *Matchmaker) Poll(_ context.Context, accountID uuid.UUID) (EnqueueResult
return EnqueueResult{}, nil
}
// Cancel removes accountID from whatever pool it waits in, reporting whether it
// was queued.
// Cancel removes accountID from whatever pool it waits in and drops any pending
// matched result, reporting whether it was queued. Clearing the result closes the
// race where the reaper substituted a robot just before the player cancelled: the
// stale game must not later surface through Poll as a game the player did not want.
func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.results, accountID)
variant, ok := m.queued[accountID]
if !ok {
return false
@@ -197,12 +211,12 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
}
var subs []sub
for _, acc := range due {
robotID, err := m.robots.Pick()
variant := m.queued[acc]
robotID, err := m.robots.Pick(variant)
if err != nil {
m.log.Warn("robot substitution deferred", zap.Error(err))
continue
}
variant := m.queued[acc]
m.removeLocked(acc, variant)
seats := []uuid.UUID{acc, robotID}
if m.rng.Intn(2) == 0 {
@@ -221,7 +235,7 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
m.mu.Lock()
m.results[s.human] = g
m.mu.Unlock()
m.emitMatchFound(g)
m.emitMatchFound(ctx, g)
}
}
+32 -2
View File
@@ -11,6 +11,7 @@ import (
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/notify"
)
// fakeCreator records the games a matchmaker asks it to start.
@@ -27,14 +28,22 @@ func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game,
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
// an empty pool.
// an empty pool. It records the variant of the last substitution request.
type fakeRobots struct {
id uuid.UUID
err error
lastVariant engine.Variant
}
func (f *fakeRobots) Pick() (uuid.UUID, error) {
func (f *fakeRobots) Pick(variant engine.Variant) (uuid.UUID, error) {
f.lastVariant = variant
if f.err != nil {
return uuid.Nil, f.err
}
@@ -238,6 +247,27 @@ func TestMatchmakerReaperSkipsCancelled(t *testing.T) {
}
}
// TestMatchmakerCancelClearsPendingResult covers the race where the reaper substitutes a
// robot just before the player cancels: Cancel must drop the pending result so the
// abandoned game never surfaces through Poll.
func TestMatchmakerCancelClearsPendingResult(t *testing.T) {
creator := &fakeCreator{}
mm := newTestMatchmaker(creator, uuid.New())
base := time.Now()
mm.clock = func() time.Time { return base }
ctx := context.Background()
a := uuid.New()
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
t.Fatalf("enqueue: %v", err)
}
mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // substitution stores a pending result
mm.Cancel(ctx, a) // ... then the player cancels
if got, _ := mm.Poll(ctx, a); got.Matched {
t.Error("cancel must drop the pending substituted game; Poll still matched")
}
}
func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) {
creator := &fakeCreator{}
mm := NewMatchmaker(creator, &fakeRobots{err: errors.New("empty pool")}, testWaitDelay, zap.NewNop())
+117
View File
@@ -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,
})
}
+101 -21
View File
@@ -6,6 +6,7 @@ import (
flatbuffers "github.com/google/flatbuffers/go"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
fb "scrabble/pkg/fbs/scrabblefb"
)
@@ -13,30 +14,65 @@ import (
// the payload with the shared scrabblefb schema. Keeping the encoding here lets
// 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
// turn's nominal deadline.
func YourTurn(userID, gameID uuid.UUID, deadline time.Time) Intent {
b := flatbuffers.NewBuilder(64)
// YourTurn announces to userID that it is their turn in game gameID, with the turn's nominal
// deadline. opponentName, lastAction, lastWord and scoreLine enrich the out-of-app push:
// the player who just moved, their move kind, the main word of a scoring play (empty
// 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())
name := b.CreateString(opponentName)
action := b.CreateString(lastAction)
word := b.CreateString(lastWord)
score := b.CreateString(scoreLine)
fb.YourTurnEventStart(b)
fb.YourTurnEventAddGameId(b, gid)
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))
return Intent{UserID: userID, Kind: KindYourTurn, Payload: b.FinishedBytes(), EventID: eventID()}
}
// OpponentMoved tells userID that seat just committed a move in game gameID,
// summarising it (the client refetches the full state).
func OpponentMoved(userID, gameID uuid.UUID, seat int, action string, score, total int) Intent {
b := flatbuffers.NewBuilder(64)
// GameOver announces to userID that game gameID finished. result is the outcome from userID's
// own perspective ("won"/"lost"/"draw") and scoreLine is the recipient-first final score; both
// feed the out-of-app "game over" push. game is the final post-game summary (the
// 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())
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.OpponentMovedEventAddGameId(b, gid)
fb.OpponentMovedEventAddSeat(b, int32(seat))
fb.OpponentMovedEventAddAction(b, act)
fb.OpponentMovedEventAddScore(b, int32(score))
fb.OpponentMovedEventAddTotal(b, int32(total))
fb.OpponentMovedEventAddMove(b, moveOff)
fb.OpponentMovedEventAddGame(b, gameOff)
fb.OpponentMovedEventAddBagLen(b, int32(bagLen))
b.Finish(fb.OpponentMovedEventEnd(b))
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()}
}
// MatchFound tells userID that game gameID, which they are seated in, has
// started (an auto-match pairing or a robot substitution).
func MatchFound(userID, gameID uuid.UUID) Intent {
b := flatbuffers.NewBuilder(64)
// MatchFound tells userID that game gameID, which they are seated in, has started (an auto-match
// pairing or a robot substitution). state is the recipient's full initial view of the new game,
// so the client navigates straight in from the event with no follow-up fetch.
func MatchFound(userID, gameID uuid.UUID, state PlayerState) Intent {
b := flatbuffers.NewBuilder(512)
gid := b.CreateString(gameID.String())
stateOff := buildStateView(b, state)
fb.MatchFoundEventStart(b)
fb.MatchFoundEventAddGameId(b, gid)
fb.MatchFoundEventAddState(b, stateOff)
b.Finish(fb.MatchFoundEventEnd(b))
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
// invitation changed. kind is a sub-discriminator (NotifyFriendRequest,
// NotifyFriendAdded, NotifyInvitation, NotifyGameStarted) the client may use to
// scope its refresh.
// Notification is a lightweight "re-poll" signal to userID that something in their lobby
// changed. kind is a sub-discriminator (NotifyFriendRequest, NotifyFriendAdded,
// NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted). It carries no payload; prefer the
// enriched constructors below, which let the client update its lobby without a refetch.
func Notification(userID uuid.UUID, kind string) Intent {
b := flatbuffers.NewBuilder(32)
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()}
}
// 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.
func eventID() string {
if id, err := uuid.NewV7(); err == nil {
+11
View File
@@ -27,6 +27,9 @@ const (
// KindNotification is a lightweight "re-poll your lobby counters" signal
// (incoming friend requests, invitations) that drives the lobby badge.
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
@@ -34,6 +37,9 @@ const (
const (
NotifyFriendRequest = "friend_request"
NotifyFriendAdded = "friend_added"
// NotifyFriendDeclined tells the original requester their request was declined, so a
// game screen watching that opponent re-derives its "add to friends" state.
NotifyFriendDeclined = "friend_declined"
NotifyInvitation = "invitation"
NotifyGameStarted = "game_started"
)
@@ -46,6 +52,11 @@ type Intent struct {
Kind string
Payload []byte
EventID string
// Language routes an out-of-app push to a specific per-language bot: for a
// game event it is the game's language ("en"/"ru"), so the notification comes from the
// game's bot rather than the recipient's last-login bot. Empty falls back to the
// recipient's service language at the gateway.
Language string
}
// Publisher accepts live-event intents. Implementations must be safe for
+112 -5
View File
@@ -6,6 +6,7 @@ import (
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/notify"
fb "scrabble/pkg/fbs/scrabblefb"
)
@@ -61,7 +62,7 @@ func TestNopPublisherDiscards(t *testing.T) {
func TestYourTurnPayloadRoundTrips(t *testing.T) {
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 == "" {
t.Fatalf("intent metadata wrong: %+v", in)
}
@@ -72,18 +73,124 @@ func TestYourTurnPayloadRoundTrips(t *testing.T) {
if got := ev.DeadlineUnix(); got != 1717000000 {
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) {
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 {
t.Fatalf("kind = %q", in.Kind)
}
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 {
t.Fatalf("decoded wrong: game=%q seat=%d action=%q score=%d total=%d",
ev.GameId(), ev.Seat(), ev.Action(), ev.Score(), ev.Total())
if string(ev.GameId()) != gid.String() {
t.Fatalf("game id = %q", ev.GameId())
}
// 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)
}
}

Some files were not shown because too many files have changed in this diff Show More