Compare commits

...

87 Commits

Author SHA1 Message Date
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 40d8f06588 Merge pull request 'Deploy v2 — release versioning + visible deploy jobs + manual rollback' (#105) from feature/release-versioning into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 57s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 56s
CI / deploy (push) Successful in 1m19s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 05:40:55 +00:00
Ilia Denisov c59e522732 feat(deploy): visible prod-deploy jobs + manual prod-rollback
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m23s
- prod-deploy.yaml is now four visible sequential jobs (build -> deploy-main ->
  deploy-bot -> verify) so the rollout stages show in the Actions UI; the
  per-service rolling stays in the deploy-main log.
- prod-rollback.yaml: a separate manual workflow_dispatch. Leave target_version
  blank to roll back to the previous deployed version (the host now tracks
  DEPLOYED_TAG + PREVIOUS_TAG), or pick a release tag. Re-deploys an already
  published image rolling + health-gated, image-only (no rebuild, no DB migration).
- prod-deploy.sh tracks the previous tag (commit_tag) for the blank-input rollback.
- Docs: ARCHITECTURE §13 + deploy/README runbook cover versioning + rollback.
2026-06-22 07:37:08 +02:00
Ilia Denisov 8d45ae6e3b feat: stamp the build version into every service
pkg/version.Version (default "dev") is set at link time via -ldflags from each
service Dockerfile's VERSION build-arg, which the deploy passes as the git tag
(git describe --tags). It surfaces as the OpenTelemetry service.version resource
attribute (so Grafana/Tempo are version-aware), alongside the SPA's existing
About version. Adds the VERSION build-arg to the backend/gateway/validator/bot
compose builds and a serviceResource test covering service.name + service.version.
2026-06-22 07:28:27 +02:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
developer 520a9092fe Merge pull request 'Stage 18 — prod contour deploy (two-host registry rollout, rolling + auto-rollback)' (#103) from feature/prod-contour-deploy into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 57s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m4s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 04:59:46 +00:00
Ilia Denisov 9f970495ee fix(deploy): guard cd and split DOCKER_GID assignment (shellcheck)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
cd $COMPOSE_DIR now aborts on failure instead of deploying from the wrong dir;
DOCKER_GID is declared then exported so the subshell exit isn't masked.
2026-06-22 00:35:20 +02:00
Ilia Denisov 3d9ba3ac3d docs(deploy): bake Stage 18 prod-deploy decisions into the live docs
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
- ARCHITECTURE §13 prod bullet -> the realized mechanism: registry transport,
  two-host, rolling + auto-rollback, migration maintenance window, node_exporter,
  the undersized launch; the contour paragraph notes node_exporter + the
  telegram-local profile.
- deploy/README gains a prod rollout runbook (how to run, migrations/restore, cert
  rotation, sizing/monitoring, the full PROD_ set) + node_exporter row, the
  telegram-local profile note, and the soft AWG_CONF note.
- PLAN Stage 18 records the resolved open details and the remaining live cutover
  (pending erudit-game.ru DNS); the tracker reads 'machinery built; cutover pending DNS'.
- PRERELEASE TX/AG note the prod wiring is built.
2026-06-22 00:30:30 +02:00
Ilia Denisov 171b71b7e0 feat(deploy): manual prod-deploy pipeline with rolling rollback (Stage 18)
A workflow_dispatch-only rollout from master (confirm=deploy):

- .gitea/workflows/prod-deploy.yaml builds + pushes the images to the registry,
  ships the compose/config/certs/env over SSH, deploys the main host via
  prod-deploy.sh, then the bot host, then verifies the public site.
- deploy/prod-deploy.sh rolls the main stack one service at a time in dependency
  order (postgres->backend->gateway->landing->validator->caddy), health-checking
  after each; any failure rolls the whole stack back to the previous tag. A schema
  migration adds a maintenance window: the backend (sole writer) is stopped for a
  consistent pg_dump before migrating; image rollback stays DB-safe (expand-contract),
  the dump is kept for a manual restore.
- prod overlay: pull the four main images from the registry by tag.
- Runtime secrets reach the host via a sourced env.sh (single-quoted values keep the
  bcrypt hash's literal $ intact, unlike a --env-file).
2026-06-22 00:25:09 +02:00
Ilia Denisov 2b399d0838 feat(deploy): prod compose split + host-memory monitoring (Stage 18)
Split the contour across the two prod hosts and retune for the small main host:

- Gate vpn+bot to the telegram-local profile. The CI test deploy now passes
  --profile telegram-local so the test contour still brings them; the prod main
  host omits both, and the prod bot runs standalone from docker-compose.bot.yml.
- docker-compose.prod.yml (main-host overlay): publish caddy 80/443 (no host
  caddy in prod; caddy owns ACME) and gateway 9443 (the remote bot dials in over
  mTLS); GOMAXPROCS=2, smaller memory caps and 7d Prometheus retention for the
  2 vCPU / 1.9 GiB host. It launches deliberately undersized; resize reactively.
- docker-compose.bot.yml: standalone bot for the tg host (no VPN, OTLP off since
  otelcol is unreachable from there, dials the main host's bot-link).
- Add node_exporter + a Prometheus scrape so host memory pressure (the OOM
  signal on the tight main host), not just per-container docker_stats, is visible.
- Soften AWG_CONF to a default: only the profiled vpn sidecar consumes it, and
  compose interpolates profiled-out services too, so prod must not require it.
2026-06-22 00:12:43 +02:00
Ilia Denisov f5f45e7afb feat(deploy): Ansible provisioning for prod hosts (Stage 18)
Idempotent playbooks under deploy/ansible/ prepare both production hosts:
docker-ce + compose plugin, a non-sudo deploy service account holding the CI
deploy key, key-only sshd, default-deny ufw, fail2ban, unattended upgrades and
chrony. The main host also opens 80/443/9443 and creates the external edge
network; the tg host verifies direct Bot API egress (the no-VPN assumption).

The application is deployed separately by the prod-deploy workflow (later
phase), running as the deploy account this playbook provisions.
2026-06-21 23:54:57 +02:00
developer b54cb8878d Merge pull request 'fix(ui): retry Mini App launch on backend failure; hide account linking' (#102) from feature/tg-boot-retry-hide-linking 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 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m6s
2026-06-21 19:38:37 +00:00
Ilia Denisov e336638ca8 fix(ui): retry Mini App launch on backend failure; hide account linking
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
Inside Telegram, a failed initData authentication (e.g. the backend down
during a deploy) dropped the user onto the web login screen — the /app/
experience, which has no place inside the Mini App. bootstrap now retries the
launch a few times in silence and then renders a dedicated boot-error screen
with a Retry button (new BootError.svelte, app.bootError), never falling back
to the web sign-in. A blocked account is still terminal and goes straight to
the blocked screen.

The profile "Link an account" section (email + Telegram link) is hidden while
sign-in is provider-only; the anonymous /app/ guest whose upgrade path this is
comes later. The flow is kept wired (`hidden` on .emailbox) and its two e2e
specs are skipped, both to be re-enabled together.

Adds i18n boot.* copy (en/ru), a mock authTelegram failure hook plus an e2e
covering the retry screen, and bakes both behaviours into FUNCTIONAL(.md/_ru).
2026-06-21 21:23:27 +02:00
developer 62f42ed102 Merge pull request 'perf(gateway): pool backend conns; loadtest evaluate hot path' (#101) from feature/loadtest-evaluate-hotpath into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 1s
CI / deploy (push) Successful in 1m26s
2026-06-21 18:51:58 +00:00
Ilia Denisov ecb21bd218 perf(backend): cut evaluate's DB round-trips; load the game in one query
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m18s
EvaluatePlay (the hottest gameplay call, fired on every tile placement) now uses
the warm live-game cache directly: an active game stays cached (mutated in place
across moves, evicted only on finish), so the cached engine game and its immutable
seat list answer the membership check and the score with no DB read. The cold path
(eviction / first load) still loads and validates via the store. The seat list is
cached alongside the engine game for the membership fast path.

GetGame also folds its two round-trips (game, then seats) into one LEFT JOIN,
preserving the contract (same Game, a seatless game still returns empty seats, seat
order kept) — one round-trip for every remaining caller.

Measured at 500 players: evaluate p99 halves (200 -> 100 ms) and the per-op query
count drops. It does NOT cut postgres CPU — that is write-bound (per-move CommitMove
plus draft upserts and journal replays), the cheap indexed GetGame reads were never
its bottleneck, and postgres runs with headroom (~1.5 of 2 cores). So this is a
latency / query-volume optimization, not a DB-CPU one.

Regression cover: a non-player evaluate against a warm game asserts the cached-seat
membership path; the integration suite exercises GetGame's join across every game op.
2026-06-21 20:47:13 +02:00
Ilia Denisov e2771826fd perf(gateway): pool backend conns; loadtest evaluate hot path
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s
The loadtest harness never modelled game.evaluate — the debounced per-tile
play preview a real client fires several times per turn, the hottest gameplay
call. Model it (one evaluate per placed tile + reconsideration re-previews +
draft.save, human-paced; --eval / --eval-recon toggle it).

That realistic load surfaced the real bottleneck: the gateway's backend HTTP
client used the default transport (MaxIdleConnsPerHost=2), so every sync call
to the single backend host churned a fresh TCP connection — ~26500 TIME_WAIT
sockets at 500 players (near the ephemeral-port ceiling), burning ~1.75 gateway
cores while the backend sat near-idle. It was the unfixed root of the residual
transport_error the earlier passes chased on the client side.

Widen the keep-alive pool (backendMaxIdleConns=512, ~2x the observed 225-conn
peak). At 500 players the churn collapses to ~0 and peak gateway CPU drops ~7x
(~1.75 -> ~0.26 cores); postgres (~1.65 cores) becomes the busiest service.
This overturns the earlier "gateway is the binding constraint, scale it
horizontally" sizing — that was sizing around this bug, not a real floor.

Consolidate the loadtest trip reports into one loadtest/REPORT.md (drop the
R2/R7 split) and bake the finding into README / PRERELEASE / ARCHITECTURE /
TESTING.
2026-06-21 19:55:57 +02:00
developer dec6fac013 Merge pull request 'fix(telegram): reply to /start only in private chats' (#100) from feature/telegram-private-reply-guard into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 16s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s
2026-06-21 15:32:11 +00:00
Ilia Denisov c494da553a fix(telegram): reply to /start only in private chats
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
The main bot is now an admin in the moderated discussion group and receives its
messages (allowed_updates includes message). Its default handler replied to
every message with a Mini App launch button — an inline web_app button, which
Telegram permits only in private chats — so replying in the group failed with
BUTTON_TYPE_INVALID (silently: the send fails, no user-facing error). Reply only
in a private chat; in the group the bot only manages permissions. The promo bot
gets the same guard.
2026-06-21 17:28:01 +02:00
developer fa8abf22db Merge pull request 'feat(telegram): promo bot + channel-chat moderation gate' (#99) from feature/telegram-promo-bot-chat-moderation into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m6s
2026-06-21 15:21:23 +00:00
Ilia Denisov 1ba789a1f1 docs(telegram): invert chat-gate strategy in docs; tune logs; i18n text
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m15s
- Bake the final default-allow + mute-the-ineligible strategy into
  docs/ARCHITECTURE.md, docs/FUNCTIONAL.md (+_ru), platform/telegram/README.md,
  the deploy compose comment and the PRERELEASE tracker. The live test proved a
  per-user grant cannot exceed a deny-by-default group (Telegram intersects the
  chat default with the per-user permission), so the chat allows sending by
  default and the bot restricts the ineligible instead of granting the eligible.
- Lower the per-event chat_member trace and eligibility evaluation to Debug;
  keep the actual mute/unmute actions, the startup self-check and warnings at
  Info, so prod logs only what the bot did.
- Update game.searchingForOpponent (Searching -> Waiting for opponent / Поиск ->
  Ждём соперника) and the quickmatch e2e assertions to match.
2026-06-21 17:15:10 +02:00
Ilia Denisov bdd1cc7d85 fix(telegram): invert the chat gate — mute the ineligible (default-allow)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Telegram intersects the chat default with each user's permissions, so a per-user
grant can never exceed a deny-by-default group: the original default-deny +
grant design could not let any user write (can_send=true was AND-ed with the
denying default). Invert it — the chat allows sending by default and the bot
MUTES an ineligible member (unregistered, admin-suspended, or chat_muted) and
restores an eligible one it had muted, acting only when the current state
differs (idempotent, no self-loop). The block/unblock/chat_muted/registration
path already sets can_send to the eligibility, so it is unchanged.
2026-06-21 16:50:44 +02:00
Ilia Denisov 0ab1719ee9 fix(telegram): grant in-chat members regardless of reported can_send
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
The CanSendMessages loop-guard skipped exactly the stuck case — a restricted
member whose chat_member event reports can_send=true yet who cannot actually
write. Replace it with a precise loop guard (skip only the bot's own restrict
action, i.e. the update whose performer is the bot) and grant any eligible
in-chat member (member or restricted) otherwise. Also log the new member's
can_send, is_member and the actor id for full visibility.
2026-06-21 16:25:57 +02:00
Ilia Denisov 380f82438c fix(telegram): grant write to restricted members in default-deny chats
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
A default-deny discussion group reports a present or freshly joined member as
`restricted` (no send right), not `member`. The join filter required `member`,
so the real case never matched and a registered user stayed muted. Grant any
eligible in-chat member (member or restricted) that still lacks the send right,
with a loop guard (skip when send is already allowed) so the bot's own grant
does not re-fire. Revoking a now-ineligible user stays the chat-gate path's job,
so this never fights a chat_muted/block.
2026-06-21 16:12:58 +02:00
Ilia Denisov a404513037 feat(telegram): chat-gate observability + grant on first registration
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
Two follow-ups from a contour test where a user joined the chat, then
registered, and got no write access — with silent logs.

Observability: log every chat_member update (chat id, configured id, user,
old->new status), the eligibility result and the grant outcome; plus a startup
self-check that warns loudly when the bot is not an administrator in the chat
with the restrict-members ("Ban users") right — the common misconfiguration,
previously invisible in the logs.

Grant on first registration: a user who joins the moderated chat BEFORE
registering is covered by no chat_member event, so the join-time grant never
fires for them. ProvisionTelegram now reports first contact, and the Telegram
auth handler emits chat_access_changed on it, so the gateway re-evaluates and
grants write access if the user is already in the chat.
2026-06-21 15:19:21 +02:00
Ilia Denisov b22b624d28 fix(telegram): keep a failed promo-bot construction non-fatal
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
tgbot.New validates the token with getMe, so a bad or unreachable promo token
would otherwise return an error from run() and crash-loop the whole bot process
— taking the main game bot down with it, since they share the container. Log it
and skip the promo bot instead; the main bot and bot-link are unaffected.
2026-06-21 14:50:01 +02:00
Ilia Denisov e71e40eef5 feat(telegram): promo bot + channel-chat moderation gate
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
Add a second standalone promo bot to the bot container (answers /start with a
localized message + a URL button into the main bot's Mini App) and gate write
access in a channel's linked discussion chat: grant on join when the Telegram
user is registered and neither admin-suspended nor holding a new chat_muted
role, and revoke/grant on the matching moderation change for a member currently
in the chat.

Eligibility (registered AND NOT suspended AND NOT chat_muted; the game
suspension dominates) is resolved once in the backend and reached two ways: the
bot's join-time unary ResolveChatEligibility over the existing mTLS bot-link,
and a backend chat_access_changed event -> gateway -> ChatGate command
(idempotent; a temporary-block-expiry sweeper may over-emit). The bot guards the
block/unblock path with getChatMember, since bots cannot list members.

A web_app button cannot open another bot's Mini App (it signs initData with the
sending bot's token), so the promo button is a t.me ?startapp URL reusing the
UI's VITE_TELEGRAM_LINK. The bot must be a chat admin with the restrict-members
right and chat_member in its allowed updates.

No schema change: chat_muted reuses the data-driven account_roles table.
2026-06-21 14:46:51 +02:00
developer 41d21f3f6f Merge pull request 'feat(ui): tile-crossword loading splash for cold lobby open' (#98) from feature/lobby-splash-tiles 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 57s
CI / gate (push) Successful in 1s
CI / deploy (push) Successful in 1m15s
2026-06-21 10:19:15 +00:00
Ilia Denisov 9642cafc1f fix(ui): hold each splash word for the pause before the readiness check
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
The dismiss check fired at the instant a word finished laying, so a word
was never held: ЭРУДИТ fell straight into the lobby (too fast) and
ЗАГРУЗКА got no readable pause. The pause was a *leading* gap before the
next word, not a hold after the current one.

Move the hold to after each word and run the check after it: every word
(ЭРУДИТ included) now stays up for PAUSE_MS before the splash either
dismisses or lays the next word. prefixMs = WORD_MS + PAUSE_MS,
cycleMs = 2*(WORD_MS + PAUSE_MS).
2026-06-21 10:42:31 +02:00
Ilia Denisov ba6ee90278 feat(ui): tile-crossword loading splash for cold lobby open
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
On a cold app open the lobby's game list arrives over the network; on a
slow link the empty "no games yet" line flashed before the games loaded.
Add a full-screen tile splash that lays a Scrabble crossword of ЭРУДИТ /
ЗАГРУЗКА / ОЖИДАНИЕ (Эрудит point values, hardcoded since the alphabet
table is not cached at boot) until the lobby's first load settles, then
removes itself to reveal the populated list.

- lib/splash.ts: pure layout + reveal schedule (unit-tested).
- components/Splash.svelte: App-level overlay; per-tile drop-in; loops
  ЗАГРУЗКА → ОЖИДАНИЕ until ready, dismisses on a word boundary. Static
  ЭРУДИТ under reduced motion / the mock build.
- app state: lobbyReady (set by Lobby on first settle) + splashDone,
  reset on logout.
- App.svelte: overlay while routeIsLobby && !splashDone; the plain text
  splash now only covers non-lobby deep-links during bootstrap.
- docs: UI_DESIGN + FUNCTIONAL (+ _ru).
2026-06-21 10:29:39 +02:00
developer e79c1ea891 Merge pull request 'feat(gateway): temporary IP ban (fail2ban) + honeypot/honeytoken (PRERELEASE AG)' (#97) from feature/abuse-ip-ban-honeypot into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m15s
2026-06-21 07:28:30 +00:00
Ilia Denisov cf9fa75d62 fix(deploy): honeypot tag dropped — Caddy applies header_up delete after set
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
The @honeypot block both deleted and set X-Scrabble-Honeypot in one reverse_proxy.
Caddy applies header_up deletions *after* sets, so the tag we set was immediately
stripped: the gateway never saw it, and a decoy hit (e.g. GET /.env) fell through
to the gateway's /app redirect (308) instead of tripping the honeypot. Drop the
delete — the bare set already replaces any client-supplied value. The real
endpoints keep stripping the header in the @gateway block (delete-only, no
conflicting set). Caught on the live test contour (no caddy locally).
2026-06-21 09:02:23 +02:00
Ilia Denisov 81b44c2b02 docs(prerelease): mark phase AG done (code + test contour)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
2026-06-21 08:58:10 +02:00
Ilia Denisov 041106d623 feat(gateway): temporary IP ban (fail2ban) fed by rejections + honeypot/honeytoken
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m11s
Add a prod-only, in-memory IP ban enforced at the edge, fed by three signals:
sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the
user class stays the backend soft-flag's concern), a honeypot decoy-path hit (the
contour caddy tags decoys with X-Scrabble-Honeypot and routes them to the gateway),
and a honeytoken (a planted bearer, GATEWAY_HONEYTOKEN). A banned IP is refused with
429 by the abuseGuard middleware before any work — covering the Connect edge, the
live stream and the static SPA/landing the per-op limiter never gated.

The ban is off by default: it keys by the real client IP the shared-NAT test contour
does not expose, so a ban there would be self-inflicted; detection still logs in the
contour, only the ban action is gated (GATEWAY_ABUSE_BAN_ENABLED). Rejection bans last
GATEWAY_ABUSE_BAN_DURATION; tripwire/honeytoken hits are near-zero-false-positive and
earn longer fixed bans. Each ban increments gateway_abuse_banned_total{reason}.

Operators see and lift active bans on the admin console's Throttled page; the gateway
syncs its active set to the backend every 30s (POST /api/v1/internal/bans/sync,
backend/internal/banview) and applies the operator unbans the response returns.

PRERELEASE phase AG. Docs baked into ARCHITECTURE / FUNCTIONAL (+ru) / both READMEs.
2026-06-21 08:54:20 +02:00
developer 3fffee7817 Merge pull request 'feat(telegram): split connector into home validator + remote bot (mTLS bot-link)' (#96) from feature/telegram-egress-botlink into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m24s
2026-06-21 05:35:02 +00:00
Ilia Denisov 860cfeb30f fix(deploy): make bot-link cert leaves readable by the distroless nonroot UID
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 53s
The gateway and bot run on distroless nonroot (UID 65532) and bind-mount the
cert dir read-only, but gen-certs.sh wrote the keys 0600 (owner-only, the deploy
user), so both crash-looped at boot with "open /certs/*.key: permission denied"
and the deploy probe correctly failed (the contour's gateway was down).

The .crt files were already 0644 (openssl default); make the leaf keys 0644 too
so UID 65532 can read them. These are ephemeral TEST certificates regenerated
every deploy on the trusted runner; prod keys come from PROD_ secrets. The CA
private key stays 0600 (containers never read it).
2026-06-21 00:27:43 +02:00
Ilia Denisov 6aeb529f13 feat(telegram): split connector into home validator + remote bot
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Failing after 2m6s
Move all Telegram egress off the main host. The single connector held the
bot token, long-polled Telegram and answered the gateway/backend over the
trusted internal network, so the whole component (including login validation)
shared fate with its VPN sidecar. Split it into two binaries that share the
token:

- cmd/validator (home, no VPN): Mini App initData + Login Widget HMAC only,
  never calls the Bot API. The gateway dials it for Telegram auth, so game
  login is now independent of Telegram reachability.
- cmd/bot (remote): Bot API long-poll + sendMessage, the only component
  reaching Telegram. It holds no inbound port — it dials the gateway over a
  new reverse mTLS bot-link (pkg/proto/botlink/v1) and executes the send
  commands the gateway pushes.

The gateway funnels sends to the bot-link: out-of-app push is fire-and-forget
(at-most-once, dropped if no bot is connected); the backend admin broadcasts
reach a gateway-served relay that forwards them and awaits the bot's ack
(SendToUser/SendToGameChannel contract preserved). mTLS (pkg/mtls) is the one
inter-service link that leaves the trusted segment; validator<->gateway and
the relay stay plaintext internal. The bot is Telegram-rate-limited.

One bot now; the gateway bot registry, an owns_updates flag and per-command
ids leave seams for N later. Webhook rejected (one URL per token, adds inbound
+ a static address).

The unified test contour runs the split (the bot keeps its VPN sidecar and
dials the gateway by its internal name; bot-link certs from deploy/gen-certs.sh,
generated in CI). The prod wiring — the bot on a separate host (no VPN), the
gateway bot-link port published, PROD_ certs with scheduled rotation, an SSH
deploy of both hosts together — is the deferred final stage (PRERELEASE.md TX,
Stage 18).

Docs: ARCHITECTURE, PRERELEASE (phase TX), platform/telegram + gateway +
backend + deploy READMEs, FUNCTIONAL(+ru), CLAUDE.md, .env.example.
2026-06-21 00:19:07 +02:00
developer 2a8717c930 Merge pull request 'fix(ui): green both lobby scores on a tie, mute a 0:0 board' (#95) from feature/lobby-equal-score-color 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 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 58s
2026-06-20 19:33:06 +00:00
Ilia Denisov 264097bbf6 fix(ui): green both lobby scores on a tie, mute a 0:0 board
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 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
The lobby tinted only the viewer's own number, and a tie counted as
"leading" — so an even score showed only the viewer's number green,
reading as if the viewer were ahead. A fresh 0:0 board did the same,
accenting the start of a game where nobody has scored.

scoreStanding is now per-seat: the viewer's seat stays green when
leading or tied and red when trailing; an opponent's seat greens only
when it ties the viewer for the lead, so an equal non-zero score paints
both numbers green. When the top score is 0 (nobody has moved) every
number is left muted, like a finished game.
2026-06-20 21:28:31 +02:00
developer 9824214fd7 ci(deploy): probe backend /readyz so a dead backend fails the deploy (#94)
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m11s
The post-deploy probe checked only the static landing and the gateway-served SPA shell, so a crash-looping backend passed the deploy green. Add an http://backend:8080/readyz probe on the internal network (and dump backend logs on failure) so an unready backend fails the deploy loudly.
2026-06-20 19:04:52 +00:00
developer c72adddb91 Merge pull request 'fix(engine): make .seed_version marker authoritative (no boot refusal)' (#93) from feature/dict-seed-marker-wins into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 54s
CI / gate (push) Successful in 0s
CI / deploy (push) Failing after 21s
2026-06-20 18:25:12 +00:00
Ilia Denisov 95f5703372 fix(engine): make .seed_version marker authoritative (no boot refusal)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s
The seed-drift guard shipped as refuse-boot: the backend exited when
BACKEND_DICT_VERSION disagreed with the flat dir's recorded .seed_version. On
the test contour that turned a harmless-in-intent action — bumping the
TEST_DICT_VERSION variable to the active release (v1.2.1) on a volume seeded as
v1.0.0 — into a crash loop, because DICT_VERSION is the *seed* of a fresh
volume, not the active version (which the admin console drives).

Make the marker authoritative instead: OpenWithVersions resolves the flat dir's
version from .seed_version when present and ignores bootVersion on an
already-seeded volume; bootVersion only seeds a fresh volume's marker. So a
bumped build seed on a live volume is a no-op (it can't relabel live bytes and
can't void games pinned to the prior label), and it correctly seeds the next
fresh volume. The subdirectory scan now skips the resolved seed, so a version
also present as a subdir (e.g. v1.2.1 uploaded via the console while the build
seed is bumped to v1.2.1) is still loaded rather than shadowed by the flat bytes.

Tests: marker-wins over a bumped boot version; a bumped boot keeps the matching
subdir resident (the live-contour case). Docs updated (ARCHITECTURE §5, READMEs,
compose/.env, PRERELEASE DV) from "refuses to boot" to "marker wins / ignored".

Verified locally against v1.2.1: gofmt, build, vet, unit, integration green.
2026-06-20 20:06:57 +02:00
developer d40fe1edec Merge pull request 'feat(engine,deploy): seed-drift guard + track current dictionary release (v1.2.1)' (#92) from feature/dict-version-track-release into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m4s
2026-06-20 17:39:02 +00:00
Ilia Denisov a5db10c46e feat(engine,deploy): seed-drift guard + track current dictionary release
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
The dictionary release moved to v1.2.1 while DICT_VERSION stayed pinned at
v1.0.0 in CI and the image/compose seed defaults. Two problems:

1. CI validated against a stale dictionary.
2. The contour seed could be bumped on a live volume, which silently relabels
   the already-seeded bytes — voiding games pinned to the prior label and
   serving the wrong dictionary for new ones. The flat DAWGs carry no embedded
   version, so this drift was undetectable.

Changes:

- Seed-drift guard: OpenWithVersions records the flat dir's version in a
  .seed_version marker on first boot and refuses to start when a later
  BACKEND_DICT_VERSION disagrees. DICT_VERSION is now the seed for a *fresh*
  volume only; a live contour migrates through the admin console (old versions
  stay resident, in-progress games keep replaying).
- Track the current release: CI's DICT_VERSION centralised to one workflow-level
  env (v1.2.1); image/compose/.env seed defaults bumped to v1.2.1. The deploy
  job keeps reading the per-contour vars.TEST_DICT_VERSION.
- Docs: ARCHITECTURE §5 (decision record), backend/deploy READMEs, PRERELEASE
  tracker (DV row).

Verified locally against the v1.2.1 artifact: gofmt, build, vet, unit and
integration (-tags=integration) all green.
2026-06-20 19:26:32 +02:00
developer c739f12d3d Merge pull request 'refactor(db): squash migrations into a single baseline' (#91) from feature/squash-migrations into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m11s
2026-06-20 13:23:44 +00:00
Ilia Denisov 483e945209 refactor(db): squash migrations into a single baseline
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
Consolidate the incremental goose migrations (00001-00014) into one
baseline. There is no production data, so the squash carries no data
migration. The baseline was generated from the end-state schema and
verified schema-identical to the squashed set (pg_dump diff) plus a full
integration run; the default house ad-campaign seed is carried over (a
schema-only dump omits it). The per-feature narrative that lived in the
squashed migrations is preserved in git history and docs/ARCHITECTURE.md.

Deploy note: this breaks goose's version continuity, so the test contour
DB must be wiped once — DROP SCHEMA backend CASCADE + restart backend —
for goose to re-apply the single baseline fresh. No prod data exists.
2026-06-20 15:11:40 +02:00
developer a21ba23e5e Merge pull request 'feat(telegram,game): single bot + per-user variant preferences' (#90) from feature/single-bot-variant-preferences into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 54s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m10s
2026-06-20 12:48:10 +00:00
Ilia Denisov 57c778f9b2 feat(telegram,game): single bot + per-user variant preferences
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
Collapse the two per-language Telegram bots into one unified bot and
replace language-based variant gating with explicit per-user variant
preferences.

- Telegram: one bot; drop service_language and the supported_languages
  set everywhere (DB, account, auth, FlatBuffers Session wire, gateway,
  connector proto). The single bot renders chat and out-of-app push in
  the recipient's preferred_language; remove the game-language push
  routing override (notify Intent.Language / push Event.language).
- Preferences: new accounts.variant_preferences (text[], DB default
  {erudit_ru}, CHECK non-empty + subset of the three variants). Gates
  the New Game picker, vs-AI and the friend invitation the player
  creates, enforced server-side (HTTP 400 otherwise); an invited friend
  may still accept any variant. Edited on the Settings screen; variants
  are Erudit-first everywhere.
- Admin: drop the per-bot language selectors (broadcast / send-to-user)
  and the feedback channel_lang column/field.
- Env/CI: collapse TELEGRAM_BOT_TOKEN_{EN,RU}, TELEGRAM_GAME_CHANNEL_ID_{EN,RU},
  VITE_TELEGRAM_LINK{,_EN,_RU} and VITE_TELEGRAM_GAME_CHANNEL_NAME_{EN,RU}
  to single unsuffixed names; drop GATEWAY_DEFAULT_SUPPORTED_LANGUAGES.
- Docs updated (ARCHITECTURE, FUNCTIONAL + _ru, platform/telegram, gateway,
  backend, ui, UI_DESIGN, PRERELEASE).

The migration squash is deferred to a follow-up PR.
2026-06-20 14:23:25 +02:00
developer 1933849dba Merge pull request 'feat(game): official first-move tile draw + admin step-by-step replay' (#89) from feature/first-move-draw into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 57s
2026-06-20 08:42:51 +00:00
Ilia Denisov e06c6ff67f docs: drop the design-spec artifact (content lives in ARCHITECTURE/FUNCTIONAL/PRERELEASE)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s
2026-06-20 10:40:37 +02:00
Ilia Denisov caefc8f579 feat(game): official first-move tile draw + admin step-by-step replay
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Decide who moves first by the official rule: each seated player draws one
tile, the one closest to "A" leads (a blank beats every letter), ties
re-drawing until a single leader remains. Each draw uses honest per-draw
crypto/rand entropy (not the deterministic bag seed), so the recorded draw —
not a seed — is the only account of the outcome. The leader takes seat 0, so
the engine and journal replay are unchanged.

The draw is recorded with the game (game_setup_draws, migration 00013) for
future tournaments, designed as a discrete "player N draws a tile" step.
Friend/AI games draw at creation. Auto-match draws when the game opens,
against a synthetic uuid.Nil opponent whose draw rows (NULL account_id) are
back-filled to the real opponent on join — so the opener's seat is fixed up
front and the existing open-game pre-move is preserved with no reseating.

Admin /_gm/games/:id gains the recorded draw list and a simple step-by-step
board replay (game.ReplayTimeline + a vanilla-JS stepper): a board with
A-O/1-15 headers and highlighted premium squares, placed letters with their
tile value as a subscript, rack panels around the board (seat 0 top, 1
bottom, 2 left, 3 right) with the current player highlighted, and a per-move
log with the tiles drawn and the bag remainder.

Docs: ARCHITECTURE §6/§9, FUNCTIONAL (+_ru), PRERELEASE (FM row), design spec.
2026-06-20 08:47:18 +02:00
developer 76d4610e6f Merge pull request 'feat(social): per-game friend request to disguised robots + lobby/stats/tile cosmetics' (#88) from feature/robot-friend-request into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s
2026-06-19 20:06:20 +00:00
Ilia Denisov c9a5ca3ed7 fix(lobby): keep the spaces around the score separator
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
Svelte trims leading/trailing whitespace inside an element, so the literal
`<span class="sep"> : </span>` rendered as a bare ":" ("123:123"). Emit the
separator as a string expression `{' : '}`, which Svelte preserves, restoring
"123 : 123".
2026-06-19 21:56:05 +02:00
Ilia Denisov 9644bd6e5e style(lobby): bold the score line, a touch smaller
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
Per owner follow-up: the whole lobby score line is now bold (font-weight 700,
matching the over-the-board score plaques) and a hair smaller (0.8rem) to offset
the heavier weight. The viewer's own number keeps its green/red standing tint.
2026-06-19 21:51:50 +02:00
Ilia Denisov c127bc9f0e feat(social): per-game friend request to disguised robots + lobby/stats/tile cosmetics
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 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m25s
Functional: the in-game add-friend handshake aimed at an auto-match opponent who is
secretly a pooled robot now records the request per (game, seat) in a new
robot_friend_requests table -- never against the shared robot account -- mirroring the
robot_blocks pattern. The shared account stays out of friendships, the "requested" state
is pinned to the seat (not leaked across the player's other games), the robot ignores it,
and a background reaper drops the row 7 days after its game finishes. The outgoing-requests
list carries these per-game rows so the seat control stays disabled across reloads. No
withdraw UI, per owner decision.

Cosmetics:
- Lobby: an in-progress game tints the viewer's own score number green when leading or
  tied, red when trailing (reusing --ok/--danger); scores now render in seat-number order,
  matching the over-the-board scoreboard.
- Stats: the per-variant best move is laid out on two lines -- the variant label, then the
  score (right-aligned in its column) and the word tiles (left-aligned) below it.
- Dark theme: the played-tile background is a touch darker so a player-placed tile reads
  with more contrast (light theme unchanged).

Docs (ARCHITECTURE, FUNCTIONAL + _ru mirror, backend README, UI_DESIGN) updated in the
same change.
2026-06-19 21:39:27 +02:00
developer 9ded5d3d86 Merge pull request 'feat: sparser robot nudges, typed unread badge, lobby unread bump' (#87) from feature/robot-nudge-badge-sort into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m10s
2026-06-19 18:36:34 +00:00
Ilia Denisov 6e77de4c1e feat: sparser robot nudges, typed unread badge, lobby unread bump
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 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m26s
Three owner-requested polish changes:

- robot: replace the lengthening 60-90 min -> 6 h proactive-nudge ramp with a
  flat uniform 9-12 h wait before every nudge; the existing sleep-window gate
  still skips and defers a nudge that would land in the robot's night.
- ui: colour the lobby/in-game unread dot by type -- the regular danger colour
  when a chat message is unread, a softer amber (--warn) when only nudges are.
  Adds a per-viewer unread_messages flag (chat_messages.kind='message') across
  the backend DTO, FlatBuffers wire, gateway transcode and the UI store.
- ui: float games with any unread notification to the top of the lobby's
  your-turn and opponent-turn sections (finished keeps its order), reusing the
  existing unread_chat flag.

Docs (ARCHITECTURE 7, FUNCTIONAL + _ru) updated. No DB migration; the new wire
field is backward-compatible.
2026-06-19 16:50:48 +02:00
developer c67a5d51f1 Merge pull request 'feat(robot): shrink endgame think time when both sides pass' (#86) from feature/robot-endgame-shrink into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s
2026-06-19 14:00:59 +00:00
Ilia Denisov f3768d20f2 feat(robot): shrink endgame think time when both sides pass
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
In a dead-drawn endgame — the two most recent journal moves are both
passes, so the board and the robot's rack are frozen and the robot is
bound to pass again — the robot still waited out its long late-game think
time (up to 90 min) before passing, needlessly dragging out a decided game.

Shorten that delay to a [0.8, 1.5]x band around the human's last-move think
time (the gap between the last two journal entries), clamped to [30s, 8min]
and taken as a min with the normal schedule, so the robot never moves
slower. A slow human collapses to the 8-min cap; a fast human is tracked,
with the floor keeping the robot from passing suspiciously instantly. The
anchor reads the move journal only (no schema change), stays deterministic
from the seed, and still defers to the sleep window.

RobotTurns now carries EndgamePass + OppLastMove, filled by one batched
journal query on the scan; the honest-AI single-game trigger keeps the
normal path (it moves at once). NextMoveAt (admin ETA) is left as the
normal-schedule upper bound.
2026-06-19 12:48:39 +02:00
developer 0eb1e0ef47 Merge pull request 'perf(ui): reuse hint result as move preview, skip redundant evaluate' (#85) from feature/hint-preview-reuse 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 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s
2026-06-19 10:18:14 +00:00
Ilia Denisov d0681c5efe fix(ui): lower-case hint preview words to match evaluate
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 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m20s
A hint's MoveRecord words are upper-cased on decode (for the move-history view),
whereas an EvalResult keeps the backend's lower case. Seeding the move preview
from the hint verbatim flipped the score caption to upper case and nudged its
height. Lower-case the words in previewFromHint so the caption reads the same
as the evaluate path it replaces.
2026-06-19 12:12:53 +02:00
Ilia Denisov 0dd4099d68 perf(ui): reuse hint result as move preview, skip redundant evaluate
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 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
Taking a hint auto-placed the suggested tiles and then called recompute(),
which round-trips a debounced evaluate for that exact placement. The hint is
the engine's own top-ranked, fully scored legal move, so its move already
carries the score, words and direction an evaluate would return — the second
call was pure duplicate work and added a visible disabled->enabled flicker on
the submit button over slow links.

Seed the move preview directly from the hint move via previewFromHint and
cancel any pending evaluate timer so a stale one cannot clobber it. A later
manual edit re-arms recompute() as before, so rearranged tiles are re-evaluated
normally. Client-only; no backend, wire or schema change.
2026-06-19 12:03:27 +02:00
developer b65a3ecc9c Merge pull request 'feat(ui): UI polish batch (toast, gestures, lobby, settings, admin count)' (#84) from feature/ui-polish-batch into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 52s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 56s
2026-06-19 08:17:50 +00:00
Ilia Denisov b156d3403c fix(ui): polish board→rack recall drag and its gesture interplay
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
- The rack now animates the opening drop gap for a board-tile recall drag too
  (the reorder transition was gated on a rack-source drag only).
- While a board tile is dragged over the rack (a recall, gap shown), the 
  confirm and its preview caption are hidden so they don't sit over the gap;
  they return if the tile is dragged back out over the board.
- A recall drop lands off the boardwrap, so its pointerup never reached the
  boardwrap handler and left a stale id in the active-pointer set — the next
  board swipe then read as multi-touch and the shuffle / history pull went dead.
  Reconcile the pointer when a drag ends or cancels. Regression e2e added.
2026-06-19 10:12:48 +02:00
Ilia Denisov 4adb608ad1 feat(ui): vs-AI comms trim, overlay confirm button, recall-to-slot drag
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
- vs-AI: the comms hub drops the Chat tab (Dictionary only); a finished AI
  game has no comms at all, so its 💬 history-header entry is hidden too.
- Confirm-move : redone as an absolute overlay pinned to the right edge (its
  right edge sits under the preview caption), so the rack keeps a fixed tile
  size — this reverts the tile-shrink that resized the letters at 6 tiles.
- Recall: dragging a placed tile back to the rack now drops it at the slot the
  pointer is over (drag-to-position, a gap opens), instead of snapping to its
  original slot; double-tap still recalls to the origin. New pure recallToSlot.

Tests: placement recallToSlot units; e2e recall-to-position; vs-AI comms e2e
updated. Docs: UI_DESIGN + FUNCTIONAL (+ru).
2026-06-19 09:54:30 +02:00
Ilia Denisov b5688d4848 fix(ui): right-align the confirm-move glyph under the preview caption
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
The  box already ended at var(--pad) from the screen edge, but place-items:
center centred the glyph inside the 56px box, so it sat ~14px left of the green
preview caption (.scores, text-align:right at the same var(--pad)). Right-align
the glyph (place-items: center end) so its right edge lands under the caption's.
2026-06-19 09:17:39 +02:00
Ilia Denisov ab1ad998aa feat(ui): batch of UI polish tweaks
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 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
- admin dashboard "Users" count excludes robots (CountUsers, humans only)
- lobby finished-game delete reveal: background matches header/tab-bar (--bg-elev)
- mobile: swipe up on the zoom-out board (no staged tiles) shuffles the rack
- lobby: status icons -25%, game-row top/bottom padding halved
- toast: info tier drifts up ~a tab-bar height while fading over 2s and dismisses
  on tap; error tier unchanged
- game: confirm-move button stays pinned at the right edge; rack tiles shrink to
  fit so a full rack never overflows onto it
- telegram: a successful invite-link redeem shows a welcome window pointing at the bot
- settings: remove the grid-lines toggle; the board is always a gapless checkerboard
- settings/comms hubs: the selected tab highlight wraps icon + label as a pill

Docs: UI_DESIGN (toast, board surface, selected tab), PLAN; e2e updated for the
removed grid-lines toggle.
2026-06-19 08:56:29 +02:00
owner 1c87313a78 Merge pull request 'feat(social): asymmetric per-user block, in-game block control, admin lists' (#83) from feature/in-game-block-and-friend-ux into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m13s
Reviewed-on: https://gitea.lan/developer/scrabble-game/pulls/83
Reviewed-by: Ilia <3+owner@noreply.gitea.iliadenisov.ru>
2026-06-19 06:15:15 +00:00
Ilia Denisov 64be0572b3 fix(social): robot blocks
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
Blocking an auto-match opponent who is secretly a pooled robot is recorded  instead in a separate `robot_blocks` table.

Now blocking behaves the same in that game (struck name, hidden composer) and lists the blocked opponent under the name you saw, but is recorded only against that game — the disguise holds, the shared robot is never globally blocked, and the matchmaker keeps pairing you with robots (so you can never block yourself out of opponents).

- the shared robot account is never put in `blocks`
- the matchmaker keeps it free and it is not blocked under its other per-game names
- the blocked list and the in-game card still show it by joining that table; an unblock deletes the row
2026-06-18 13:12:19 +02:00
Ilia Denisov 81b9e1529e feat(social): asymmetric per-user block, in-game block control, admin lists
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Make a per-user block one-directional and non-destructive: the blocker stops
receiving everything from the blocked user (chat, nudge, friend requests,
invitations) and the matchmaker never pairs them, while the blocked user
notices nothing — their sends still persist by the normal rules but are never
delivered or surfaced (born-read). A block no longer deletes the friendship
(an unblock cleanly restores it) and instant-reads any unread the blocked user
had left for the blocker.

- backend: a directional blockExists guard across chat/nudge/friends/invitations
  (store-but-hide for the blocked->blocker direction, refuse blocker->blocked);
  the matchmaker excludes a block-related pair (both directions) from auto-match;
  user_blocked/user_unblocked notifications to the blocker only (in-app only).
- ui: the opponent score card gains a block ✖️ control mirroring add-friend
  (red "Block?" confirm, mutual-hide while confirming, struck name, hidden chat
  composer when blocked); optimistic apply + event confirm + rollback for both.
- admin: the user card gains cross-linked blocks / blocked-by / friends lists.
- docs: FUNCTIONAL(+ru), ARCHITECTURE §10 + decision record, UI_DESIGN, PRERELEASE.
2026-06-18 11:50:34 +02:00
owner 9074417762 Merge pull request 'fix(matchmaking): re-enqueue opens a new game, not the caller's own' (#82) from feature/reenqueue-opens-new-game into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 13s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 56s
Reviewed-on: https://gitea.lan/developer/scrabble-game/pulls/82
Reviewed-by: Ilia <3+owner@noreply.gitea.iliadenisov.ru>
2026-06-18 08:38:41 +00:00
Ilia Denisov 9d52885a6e fix(matchmaking): re-enqueue opens a new game, not the caller's own
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
A second "random opponent" enqueue with the same variant and per-turn
rule, while the caller's first game was still open (awaiting an
opponent), returned that same open game, so a player could never start a
fresh random game while one was still searching.

Drop the own-open short-circuit (step 1) in store.OpenOrJoin: a
re-enqueue now joins another player's open game or opens a fresh one.
Accumulation stays bounded by MaxActiveQuickGames, which counts open
games. Update the matchmaker/service/store doc comments and
ARCHITECTURE.md, and flip the pinning test to assert the new behavior.
2026-06-18 10:18:44 +02:00
developer 8793bd34f2 feat(stats): best-move word, moves & hint-share, and a hint-count fix (#81)
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 52s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s
The statistics screen gains real depth, plus a hint-count bug fix found along the way.

- Best move per variant: the screen shows the actual best-move word (drawn as game
  tiles; a wildcard shows its letter but no value), broken down by game variant, empty
  variants omitted. New account_best_move table, written at game finish.
- Moves & hint share: two new lifetime tiles — the player's play count and the share of
  plays that used a hint — from summed account_stats counters (moves, hints_used).
  Honest-AI games are excluded, like the rest of the stats.
- Hint-count fix: the in-game hint badge no longer goes stale across games. The global
  wallet now rides the wire apart from the per-game allowance (wallet_balance on
  StateView/HintResult/StatsView), so the client reads the live wallet rather than a
  per-game snapshot; game_players.hints_used now counts every hint (allowance + wallet),
  its true per-game total.
- Account merge: sums the new moves/hints_used counters and merges the per-variant best
  moves (higher score kept), which it previously dropped.
- Admin: the user card shows Moves and Hints used.
- UI polish: tab/label wording, game-over text, and e2e selectors hardened against label
  changes.

All wire additions are trailing (backward-compatible). Docs (ARCHITECTURE, FUNCTIONAL +ru,
UISN_DESIGN) updated in step.
2026-06-17 22:17:27 +00:00
developer 5a3f0951ae Merge pull request 'feat: AI-game & resign UX cleanup (lobby drop, board reveal, GCG share fix, hide AI export)' (#80) from feature/lobby-drop-left-ai-games into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 51s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 58s
2026-06-17 12:11:24 +00:00
Ilia Denisov fb0ddab0f1 feat(ui): hide GCG export in honest-AI games
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m1s
A vs_ai game is throwaway practice, so its finished history header no longer
offers the 📤 GCG export (an empty slot keeps the comms icon pinned right).
Docs note the AI exclusion; UI_DESIGN also records that confirming a resign
reveals the full board (closes the history drawer, zooms out).
2026-06-17 14:01:08 +02:00
Ilia Denisov e850ecd83b fix(ui): don't strand the Mini App on a cancelled GCG share
On iOS WKWebView (the Telegram Mini App), cancelling the Web Share sheet fell
through to the Blob <a download> fallback. iOS ignores the download attribute,
so clicking the anchor navigated the webview to the blob: URL — replacing the
SPA with the raw GCG file, with no way back (force-quit only).

The share path no longer falls back to a download: Web Share is available on
that platform, so a cancelled or failed share is a no-op and the user can
retry. The Blob download stays the desktop-only path (no Web Share).
2026-06-17 14:01:02 +02:00
Ilia Denisov 2c24d54047 feat(ui): reveal the full board on resign (close history, zoom out)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 50s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
After the player confirms a resign, close the move-history drawer (portrait;
the landscape dock is unaffected) and zoom the board out if it was magnified,
so the resigned game shows its full final board.
2026-06-17 13:33:27 +02:00
Ilia Denisov 2f4aa1b75b feat(lobby): drop left honest-AI games from the finished list
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m4s
A finished honest-AI (vs_ai) game the player left — by resigning or by
abandoning it to the 7-day inactivity timeout (end_reason 'resign'/'timeout')
— no longer appears in that player's own lobby finished list.

The new game.Service.ListForLobby filters ListForAccount for the lobby
endpoint only; the admin console and the account-merge count keep the full
set. The filter keys on the game's end reason, not on which seat left, so it
extends to any player should the robot ever resign.
2026-06-17 13:03:35 +02:00
developer 712ef205c1 Merge pull request 'fix(ui): pin the in-game ad-banner height on a short viewport' (#79) from feature/game-banner-fixed-height 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 49s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 58s
2026-06-17 10:28:53 +00:00
Ilia Denisov 7cd4474945 fix(ui): pin the in-game ad-banner height on a short viewport
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 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
The ad banner lives inside the grown game header (`.nav.grow`, portrait
game only). That rule carried `flex: 1 1 auto`, so on a short viewport the
flex algorithm shrank the header — clipping the banner (`.ad` is
`overflow:hidden`) — *and* the board's scroll area at the same time. The
banner and the board ended up splitting the vertical squeeze.

Drop the shrink (`flex: 1 0 auto`): the header still grows into spare height
(banner under the title, board pinned to the bottom), but on a short viewport
it holds its natural height and the board's own scroll (`.stage`) absorbs the
whole squeeze. The banner now keeps a constant height.

Portrait-only: in landscape and on every other screen `.nav` is already
`flex: 0 0 auto`, so the banner never shrank there.

Verified in the mock UI (portrait, live banner): at 440px tall the banner
held 30px (was clipped to 14px) while the board scrolled; the tall-viewport
layout is unchanged. Full UI suite green locally (check, 272 unit, build,
bundle-size, 140 e2e).
2026-06-17 12:24:11 +02:00
developer 236152ea7b Merge pull request 'feat(observability): Scrabble — Messages Grafana dashboard' (#78) from feature/messages-dashboard into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 13s
CI / ui (push) Successful in 49s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 56s
2026-06-17 10:05:16 +00:00
Ilia Denisov c3b3cafcdd feat(observability): add the Scrabble — Messages Grafana dashboard
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s
Visualises the chat read-receipt metrics added with the read-receipt feature
(the dashboard was deferred there per the owner): posted rate by kind, the
unread backlog (chat_unread_messages gauge), and the publish-to-read latency
(chat_read_duration — average by kind plus overall p50/p95). Mirrors the
game-domain dashboard's structure; the file provider auto-discovers it.
2026-06-17 12:00:58 +02:00
developer b71f006ddf Merge pull request 'feat(chat): unread read-receipts — lobby/game dot, history-open ack, admin + metrics' (#77) from feature/chat-read-receipts into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 49s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 56s
2026-06-17 09:52:23 +00:00
Ilia Denisov 20f2a5a011 feat(chat): a message to a disguised robot opponent is born read
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 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
A pooled robot substituted into an ordinary (non-AI) game never opens the
chat, so a text message to it would linger unread forever — skewing the unread
count and the publish-to-read metric. Clear its recipient bit at PostMessage
time (robotRecipients via account.IsRobot), so the message is born read. The
human sender never had their own message unread, so this is invisible to them;
a nudge to a robot already self-clears when the robot answers by moving.
2026-06-17 11:46:59 +02:00
Ilia Denisov a7f3df9346 fix(chat): don't restore unread on a failed read-ack (would loop the in-game effect; REST re-seed self-heals)
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 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s
2026-06-17 11:14:25 +02:00
Ilia Denisov aaac816dc2 feat(chat): unread read-receipts with lobby/game dot and history-open ack
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
Persist per-message read state as a chat_messages.unread_seats bitmask
(migration 00008): a text message seeds every recipient seat's bit, a nudge
only the awaited seat's. A seat's bit clears when the player opens the move
history or chat (POST /games/:id/chat/read, sent only when something is
unread), and a nudge additionally clears when its recipient answers by moving
(a wired game NudgeClearer, dependency-inverted so game keeps off social).

UI shows a per-viewer unread dot in the lobby (next to the opponent) and the
game score bar — the unread_chat game-view flag seeds it from authoritative
REST views, live chat/nudge events raise it. Opening the move history counts
as reading (even without entering chat): the 💬 fade-blinks twice and the
client acks. Admin Messages gains an unread-only filter, a read/unread column,
and a per-message card with the per-seat read breakdown. Observability:
chat_read_duration histogram + chat_unread_messages gauge + social tracing.
2026-06-17 11:12:38 +02:00
developer d53ff18a67 Merge pull request 'feat(lobby): cap simultaneous quick games at 10' (#76) from feature/quick-game-limit into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 50s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 58s
2026-06-16 21:00:41 +00:00
Ilia Denisov 63ab85a5e5 feat(lobby): cap simultaneous quick games at 10
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
Limit a player to 10 active quick games (auto-match + AI); friend games created
by invitation are not counted. At the cap the backend refuses both new-game
entry points — quick enqueue and invitation creation — with 409
game_limit_reached, while accepting an incoming invitation stays allowed, so
friend games are capped from the other end. The lobby disables "New Game" and
shows a low-emphasis notice, driven by a new at_game_limit flag on games.list
(no per-event payload: a turn change does not move the count, and the lobby
already re-fetches games.list on entry and every game event).

- game.MaxActiveQuickGames + Store/Service.CountActiveQuickGames (active/open
  seats, no game_invitations row; hidden games still count -> dedicated count)
- Server.ensureUnderGameLimit gating handleEnqueue + handleCreateInvitation;
  game.ErrGameLimitReached -> 409 game_limit_reached
- FB GameList.at_game_limit (regenerated Go + TS) through the gateway transcode
  and UI codec; gameListDTO + lobbycache snapshot + Lobby.svelte + i18n
- tests: integration count rule + HTTP gate + accept bypass; server error map;
  gateway transcode round-trip; UI codec + lobbycache unit; e2e gamelimit
- docs: PRERELEASE (GL), FUNCTIONAL(+ru), ARCHITECTURE 8, UI_DESIGN, backend README
2026-06-16 22:51:18 +02:00
337 changed files with 20520 additions and 3765 deletions
+64 -40
View File
@@ -26,6 +26,13 @@ on:
push:
branches: [development]
# The dictionary release the test suite validates against — the current
# scrabble-dictionary release. Centralised here so a release bump is one edit; the
# unit/integration jobs inherit it. The deploy job overrides it per contour with
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
env:
DICT_VERSION: v1.2.1
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.
@@ -86,7 +93,6 @@ jobs:
# 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
@@ -134,7 +140,6 @@ jobs:
# 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
@@ -254,25 +259,24 @@ jobs:
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 }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID_EN: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_EN }}
TELEGRAM_GAME_CHANNEL_ID_RU: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_RU }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
TELEGRAM_BOT_USERNAME: ${{ vars.TEST_TELEGRAM_BOT_USERNAME }}
# The promo button reuses the UI's Mini App link variable.
TELEGRAM_BOT_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
# The test contour always uses Telegram's test environment — pinned here,
# not an operator variable. The prod workflow leaves it false.
TELEGRAM_TEST_ENV: "true"
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_LINK_EN: ${{ vars.TEST_VITE_TELEGRAM_LINK_EN }}
VITE_TELEGRAM_LINK_RU: ${{ vars.TEST_VITE_TELEGRAM_LINK_RU }}
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_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
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 }}
@@ -289,62 +293,82 @@ jobs:
mkdir -p "$conf"
cp -r caddy otelcol prometheus tempo grafana "$conf"/
export SCRABBLE_CONFIG_DIR="$conf"
# Bot-link mTLS material for the test contour: a private CA + gateway/bot
# leaves (CN=gateway, the service name the bot dials). Prod supplies these
# from PROD_ secrets instead. Regenerated each deploy; both ends redeploy
# together so they always share the fresh CA (see deploy/gen-certs.sh).
bash "$GITHUB_WORKSPACE/deploy/gen-certs.sh" "$conf/certs"
# App version for the About screen: the git tag if present, else the short SHA
# (the test checkout is shallow/untagged, so this is the SHA here — fine).
export APP_VERSION="$(git -C "$GITHUB_WORKSPACE" describe --tags --always 2>/dev/null || echo dev)"
docker compose --ansi never build --progress plain
docker compose --ansi never up -d --remove-orphans
# The telegram-local profile brings the bot + its VPN sidecar; prod runs the
# bot on its own host instead (deploy/docker-compose.bot.yml), and the prod
# main host omits both. Without the profile they would not start here.
docker compose --ansi never --profile telegram-local build --progress plain
docker compose --ansi never --profile telegram-local up -d --remove-orphans
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
# changed Caddyfile or Grafana dashboard is ignored — force-recreate them to
# pick up the fresh config.
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
- name: Probe the landing and the gateway through caddy
- name: Probe the landing, gateway and backend
run: |
set -u
# Two probes through the contour caddy: "/" is the static
# landing container, "/app/" is the gateway-served SPA shell.
# Three probes. "/" is the static landing container and "/app/" the
# gateway-served SPA shell (both through the contour caddy on the edge net).
# The backend /readyz is probed on the internal net as well: the caddy probes
# are blind to a crash-looping backend (the landing is static and the SPA
# shell is served without it), which let a bad deploy go green while the
# backend was down — so check it directly here.
for i in $(seq 1 20); do
if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/ &&
docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/app/; then
echo "healthy: GET http://scrabble/ (landing) + /app/ (gateway)"
docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/app/ &&
docker run --rm --network scrabble-internal alpine:3.20 wget -q -T 5 -O /dev/null http://backend:8080/readyz; then
echo "healthy: GET / (landing) + /app/ (gateway) + backend /readyz"
exit 0
fi
sleep 3
done
echo "probe failed; recent landing + gateway logs:"
echo "probe failed; recent landing + gateway + backend logs:"
docker logs --tail 50 scrabble-landing || true
docker logs --tail 50 scrabble-gateway || true
docker logs --tail 50 scrabble-backend || true
exit 1
- name: Probe the Telegram connector liveness
- name: Probe the Telegram validator and bot 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).
# The gateway/backend probes cannot see a crash-looping validator or bot
# (the validator answers only internal gRPC; the bot long-polls + egresses
# through the VPN sidecar with no public ingress). Inspect the containers
# directly: each must be running, not restarting, with a stable restart
# count. A grace period lets the VPN handshake and the bot-link dial settle.
sleep 20
for 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
for name in scrabble-telegram-validator scrabble-telegram-bot; do
ok=
for i in $(seq 1 20); do
status="$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null || echo missing)"
restarting="$(docker inspect -f '{{.State.Restarting}}' "$name" 2>/dev/null || echo true)"
if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then
c1="$(docker inspect -f '{{.RestartCount}}' "$name")"
sleep 5
c2="$(docker inspect -f '{{.RestartCount}}' "$name")"
if [ "$c1" = "$c2" ]; then
echo "$name healthy: status=$status restarts=$c2"
ok=1
break
fi
echo "$name still restarting ($c1 -> $c2); waiting"
fi
echo "connector still restarting ($c1 -> $c2); waiting"
sleep 3
done
if [ -z "$ok" ]; then
echo "$name not healthy; recent logs:"
docker logs --tail 80 "$name" || true
exit 1
fi
sleep 3
done
echo "connector not healthy; recent logs:"
docker logs --tail 80 scrabble-telegram || true
exit 1
- name: Prune dangling images
if: always()
+266
View File
@@ -0,0 +1,266 @@
# Manual production rollout. Runs ONLY from master, ONLY on workflow_dispatch with
# confirm=deploy (development->master is merged + green first; this is the separate,
# deliberate prod step). Visible sequential jobs from most to least significant:
# build -> deploy-main -> deploy-bot -> verify
# The per-service rolling (postgres->backend->gateway->landing->validator->caddy),
# health-gating and auto-rollback live in deploy/prod-deploy.sh on the main host and
# show in the deploy-main log. Manual post-deploy rollback is prod-rollback.yaml.
# See deploy/README.md (prod runbook).
name: prod-deploy
run-name: "prod deploy ${{ github.sha }}"
on:
workflow_dispatch:
inputs:
confirm:
description: 'Type "deploy" to confirm a production rollout from master.'
required: true
default: ""
permissions:
contents: read
env:
NO_COLOR: "1"
DOCKER_CLI_HINTS: "false"
REGISTRY: docker.iliadenisov.ru/developer
jobs:
build:
if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'deploy' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
outputs:
tag: ${{ steps.ver.outputs.tag }}
env:
PROD_REGISTRY_USER: ${{ vars.PROD_REGISTRY_USER }}
PROD_REGISTRY_PASSWORD: ${{ secrets.PROD_REGISTRY_PASSWORD }}
VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }}
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute version tag
id: ver
run: echo "tag=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
- name: Registry login
run: echo "$PROD_REGISTRY_PASSWORD" | docker login "${REGISTRY%%/*}" -u "$PROD_REGISTRY_USER" --password-stdin
- name: Build and push images
working-directory: deploy
run: |
export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=.
# The four main-stack images via compose (reuses the build args, incl. VERSION);
# the bot separately, since it is profiled out of the prod compose.
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
docker compose -f docker-compose.yml -f docker-compose.prod.yml push backend gateway landing validator
docker build -f ../platform/telegram/Dockerfile --target bot --build-arg VERSION="$TAG" -t "$REGISTRY/scrabble-telegram-bot:$TAG" ..
docker push "$REGISTRY/scrabble-telegram-bot:$TAG"
deploy-main:
needs: build
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
TAG: ${{ needs.build.outputs.tag }}
PROD_REGISTRY_USER: ${{ vars.PROD_REGISTRY_USER }}
PROD_REGISTRY_PASSWORD: ${{ secrets.PROD_REGISTRY_PASSWORD }}
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
MAIN_HOST: ${{ vars.PROD_MAIN_HOST }}
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$PROD_SSH_KEY" > ~/.ssh/id_deploy && chmod 600 ~/.ssh/id_deploy
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Determine previous tag and migration
run: |
ssh_main() { ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$MAIN_HOST" "$@"; }
PREV_TAG="$(ssh_main 'cat /opt/scrabble/DEPLOYED_TAG 2>/dev/null || echo none')"
MIGRATION=0
if [ "$PREV_TAG" != none ]; then
if ! git cat-file -e "$PREV_TAG^{commit}" 2>/dev/null; then
MIGRATION=1
elif git diff --name-only "$PREV_TAG..$TAG" -- backend/internal/postgres/migrations/ | grep -q .; then
MIGRATION=1
fi
fi
{ echo "PREV_TAG=$PREV_TAG"; echo "MIGRATION=$MIGRATION"; } >> "$GITHUB_ENV"
echo "prev=$PREV_TAG migration=$MIGRATION"
- name: Render main env + certs
run: |
umask 077
mkdir -p stage/certs-main
cat > stage/env.sh <<EOF
export REGISTRY='$REGISTRY'
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION'
export APP_VERSION='$TAG'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_ABUSE_BAN_ENABLED='true'
EOF
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
chmod 644 stage/certs-main/*
- name: Deploy the main host
run: |
ssh_main() { ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$MAIN_HOST" "$@"; }
ssh_main 'mkdir -p /opt/scrabble/compose'
tar -C deploy -czf - docker-compose.yml docker-compose.prod.yml prod-deploy.sh \
| ssh_main 'tar -C /opt/scrabble/compose -xzf -'
tar -C deploy -czf - caddy otelcol prometheus tempo grafana \
| ssh_main 'tar -C /opt/scrabble -xzf -'
tar -C stage -czf - certs-main \
| ssh_main 'rm -rf /opt/scrabble/certs && mkdir -p /opt/scrabble/certs && tar -C /opt/scrabble/certs --strip-components=1 -xzf -'
scp -i ~/.ssh/id_deploy -o BatchMode=yes stage/env.sh "deploy@$MAIN_HOST:/opt/scrabble/env.sh"
echo "$PROD_REGISTRY_PASSWORD" | ssh_main "docker login ${REGISTRY%%/*} -u $PROD_REGISTRY_USER --password-stdin"
ssh_main "TAG='$TAG' PREV_TAG='$PREV_TAG' MIGRATION='$MIGRATION' bash /opt/scrabble/compose/prod-deploy.sh"
deploy-bot:
needs: [build, deploy-main]
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
TAG: ${{ needs.build.outputs.tag }}
PROD_REGISTRY_USER: ${{ vars.PROD_REGISTRY_USER }}
PROD_REGISTRY_PASSWORD: ${{ secrets.PROD_REGISTRY_PASSWORD }}
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
TG_HOST: ${{ vars.PROD_TG_HOST }}
MAIN_HOST: ${{ vars.PROD_MAIN_HOST }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_PROMO_BOT_TOKEN }}
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
steps:
- uses: actions/checkout@v4
- name: Set up SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$PROD_SSH_KEY" > ~/.ssh/id_deploy && chmod 600 ~/.ssh/id_deploy
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Render bot env + certs
run: |
umask 077
mkdir -p stage/certs-bot
cat > stage/env.bot.sh <<EOF
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export BOT_IMAGE='$REGISTRY/scrabble-telegram-bot:$TAG'
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
export LOG_LEVEL='${LOG_LEVEL:-info}'
EOF
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
chmod 644 stage/certs-bot/*
- name: Deploy the bot host
run: |
ssh_tg() { ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$TG_HOST" "$@"; }
ssh_tg 'mkdir -p /opt/scrabble/compose'
tar -C deploy -czf - docker-compose.bot.yml | ssh_tg 'tar -C /opt/scrabble/compose -xzf -'
tar -C stage -czf - certs-bot \
| ssh_tg 'rm -rf /opt/scrabble/certs && mkdir -p /opt/scrabble/certs && tar -C /opt/scrabble/certs --strip-components=1 -xzf -'
scp -i ~/.ssh/id_deploy -o BatchMode=yes stage/env.bot.sh "deploy@$TG_HOST:/opt/scrabble/env.bot.sh"
echo "$PROD_REGISTRY_PASSWORD" | ssh_tg "docker login ${REGISTRY%%/*} -u $PROD_REGISTRY_USER --password-stdin"
ssh_tg 'set -a; . /opt/scrabble/env.bot.sh; set +a; cd /opt/scrabble/compose;
docker compose -f docker-compose.bot.yml pull;
docker compose -f docker-compose.bot.yml up -d'
ssh_tg 'for i in $(seq 1 20); do
s=$(docker inspect -f "{{.State.Status}}" scrabble-telegram-bot 2>/dev/null || echo missing)
r=$(docker inspect -f "{{.State.Restarting}}" scrabble-telegram-bot 2>/dev/null || echo true)
if [ "$s" = running ] && [ "$r" = false ]; then
c1=$(docker inspect -f "{{.RestartCount}}" scrabble-telegram-bot); sleep 5
c2=$(docker inspect -f "{{.RestartCount}}" scrabble-telegram-bot)
[ "$c1" = "$c2" ] && { echo "bot healthy"; exit 0; }
fi
sleep 3
done
echo "bot not healthy:"; docker logs --tail 80 scrabble-telegram-bot; exit 1'
verify:
needs: [deploy-main, deploy-bot]
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
MAIN_HOST: ${{ vars.PROD_MAIN_HOST }}
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
steps:
- name: Set up SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$PROD_SSH_KEY" > ~/.ssh/id_deploy && chmod 600 ~/.ssh/id_deploy
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Verify the public site
run: |
domain="${CADDY_SITE_ADDRESS%% *}"
ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$MAIN_HOST" "for i in \$(seq 1 20); do
if curl -fsS -k --resolve $domain:443:127.0.0.1 https://$domain/ -o /dev/null &&
curl -fsS -k --resolve $domain:443:127.0.0.1 https://$domain/app/ -o /dev/null &&
docker run --rm --network scrabble-internal alpine:3.20 wget -q -T 5 -O /dev/null http://backend:8080/readyz; then
echo 'public site + /app/ + backend healthy'; exit 0
fi
sleep 5
done
echo 'public verify failed; recent caddy + gateway + backend logs:'
docker logs --tail 40 scrabble-caddy; docker logs --tail 40 scrabble-gateway; docker logs --tail 40 scrabble-backend
exit 1"
+223
View File
@@ -0,0 +1,223 @@
# Manual production rollback. Runs ONLY from master, ONLY on workflow_dispatch with
# confirm=rollback. Re-deploys an already-published image tag (no build): leave
# target_version blank to roll back to the previously deployed version (read from the
# main host), or set it to a specific release tag from the Releases page. The
# re-deploy is the same rolling, health-gated path as prod-deploy (TAG=target,
# MIGRATION=0 — rollback is image-only and never migrates the DB; image rollback is
# DB-safe under the expand-contract rule). See deploy/README.md (prod runbook).
name: prod-rollback
run-name: "prod rollback ${{ inputs.target_version || 'previous' }}"
on:
workflow_dispatch:
inputs:
confirm:
description: 'Type "rollback" to confirm a production rollback.'
required: true
default: ""
target_version:
description: "Release tag to roll back to (blank = the previous deployed version)."
required: false
default: ""
permissions:
contents: read
env:
NO_COLOR: "1"
DOCKER_CLI_HINTS: "false"
REGISTRY: docker.iliadenisov.ru/developer
jobs:
rollback-main:
if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'rollback' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
outputs:
target: ${{ steps.resolve.outputs.target }}
env:
PROD_REGISTRY_USER: ${{ vars.PROD_REGISTRY_USER }}
PROD_REGISTRY_PASSWORD: ${{ secrets.PROD_REGISTRY_PASSWORD }}
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
MAIN_HOST: ${{ vars.PROD_MAIN_HOST }}
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
INPUT_TARGET: ${{ inputs.target_version }}
steps:
- uses: actions/checkout@v4
- name: Set up SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$PROD_SSH_KEY" > ~/.ssh/id_deploy && chmod 600 ~/.ssh/id_deploy
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Resolve rollback target
id: resolve
run: |
ssh_main() { ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$MAIN_HOST" "$@"; }
CURRENT="$(ssh_main 'cat /opt/scrabble/DEPLOYED_TAG 2>/dev/null || echo none')"
if [ -n "$INPUT_TARGET" ]; then
TARGET="$INPUT_TARGET"
else
TARGET="$(ssh_main 'cat /opt/scrabble/PREVIOUS_TAG 2>/dev/null || echo none')"
fi
if [ -z "$TARGET" ] || [ "$TARGET" = none ]; then
echo "no rollback target (no PREVIOUS_TAG on the host and no target_version input)"; exit 1
fi
if [ "$TARGET" = "$CURRENT" ]; then
echo "target $TARGET is already the deployed version; nothing to do"; exit 1
fi
echo "rolling back: current=$CURRENT -> target=$TARGET"
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
{ echo "TARGET=$TARGET"; echo "CURRENT=$CURRENT"; } >> "$GITHUB_ENV"
- name: Render main env + certs
run: |
umask 077
mkdir -p stage/certs-main
cat > stage/env.sh <<EOF
export REGISTRY='$REGISTRY'
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION'
export APP_VERSION='$TARGET'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_ABUSE_BAN_ENABLED='true'
EOF
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
chmod 644 stage/certs-main/*
- name: Roll the main host back
run: |
ssh_main() { ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$MAIN_HOST" "$@"; }
ssh_main 'mkdir -p /opt/scrabble/compose'
tar -C deploy -czf - docker-compose.yml docker-compose.prod.yml prod-deploy.sh \
| ssh_main 'tar -C /opt/scrabble/compose -xzf -'
tar -C deploy -czf - caddy otelcol prometheus tempo grafana \
| ssh_main 'tar -C /opt/scrabble -xzf -'
tar -C stage -czf - certs-main \
| ssh_main 'rm -rf /opt/scrabble/certs && mkdir -p /opt/scrabble/certs && tar -C /opt/scrabble/certs --strip-components=1 -xzf -'
scp -i ~/.ssh/id_deploy -o BatchMode=yes stage/env.sh "deploy@$MAIN_HOST:/opt/scrabble/env.sh"
echo "$PROD_REGISTRY_PASSWORD" | ssh_main "docker login ${REGISTRY%%/*} -u $PROD_REGISTRY_USER --password-stdin"
# Image-only rollback: no migration window (TAG=target, MIGRATION=0). A failed
# rollback's auto-revert returns to the current version (PREV_TAG=$CURRENT).
ssh_main "TAG='$TARGET' PREV_TAG='$CURRENT' MIGRATION=0 bash /opt/scrabble/compose/prod-deploy.sh"
rollback-bot:
needs: rollback-main
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
TARGET: ${{ needs.rollback-main.outputs.target }}
PROD_REGISTRY_USER: ${{ vars.PROD_REGISTRY_USER }}
PROD_REGISTRY_PASSWORD: ${{ secrets.PROD_REGISTRY_PASSWORD }}
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
TG_HOST: ${{ vars.PROD_TG_HOST }}
MAIN_HOST: ${{ vars.PROD_MAIN_HOST }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_PROMO_BOT_TOKEN }}
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
steps:
- uses: actions/checkout@v4
- name: Set up SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$PROD_SSH_KEY" > ~/.ssh/id_deploy && chmod 600 ~/.ssh/id_deploy
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Render bot env + certs
run: |
umask 077
mkdir -p stage/certs-bot
cat > stage/env.bot.sh <<EOF
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export BOT_IMAGE='$REGISTRY/scrabble-telegram-bot:$TARGET'
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
export LOG_LEVEL='${LOG_LEVEL:-info}'
EOF
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
chmod 644 stage/certs-bot/*
- name: Roll the bot host back
run: |
ssh_tg() { ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$TG_HOST" "$@"; }
ssh_tg 'mkdir -p /opt/scrabble/compose'
tar -C deploy -czf - docker-compose.bot.yml | ssh_tg 'tar -C /opt/scrabble/compose -xzf -'
tar -C stage -czf - certs-bot \
| ssh_tg 'rm -rf /opt/scrabble/certs && mkdir -p /opt/scrabble/certs && tar -C /opt/scrabble/certs --strip-components=1 -xzf -'
scp -i ~/.ssh/id_deploy -o BatchMode=yes stage/env.bot.sh "deploy@$TG_HOST:/opt/scrabble/env.bot.sh"
echo "$PROD_REGISTRY_PASSWORD" | ssh_tg "docker login ${REGISTRY%%/*} -u $PROD_REGISTRY_USER --password-stdin"
ssh_tg 'set -a; . /opt/scrabble/env.bot.sh; set +a; cd /opt/scrabble/compose;
docker compose -f docker-compose.bot.yml pull;
docker compose -f docker-compose.bot.yml up -d'
verify:
needs: [rollback-main, rollback-bot]
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
MAIN_HOST: ${{ vars.PROD_MAIN_HOST }}
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
steps:
- name: Set up SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$PROD_SSH_KEY" > ~/.ssh/id_deploy && chmod 600 ~/.ssh/id_deploy
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Verify the public site
run: |
domain="${CADDY_SITE_ADDRESS%% *}"
ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$MAIN_HOST" "for i in \$(seq 1 20); do
if curl -fsS -k --resolve $domain:443:127.0.0.1 https://$domain/ -o /dev/null &&
docker run --rm --network scrabble-internal alpine:3.20 wget -q -T 5 -O /dev/null http://backend:8080/readyz; then
echo 'rolled-back site healthy'; exit 0
fi
sleep 5
done
echo 'verify failed'; docker logs --tail 40 scrabble-caddy; docker logs --tail 40 scrabble-backend; exit 1"
+4
View File
@@ -17,5 +17,9 @@
**/.env.local
**/.env.*.local
# Bot-link mTLS material: private keys never belong in the repo. The test contour
# generates them with deploy/gen-certs.sh; prod supplies them from PROD_ secrets.
deploy/certs/
# Claude Code harness runtime artifacts
.claude/scheduled_tasks.lock
+3 -3
View File
@@ -125,9 +125,9 @@ backend/ # module scrabble/backend
internal/inttest/ # //go:build integration Postgres-backed tests
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
platform/telegram/ # Telegram side-service, two binaries (Stage 9; split in phase TX): cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link)
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)
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless (Stage 16; loadtest R2); gateway/Dockerfile has the `landing` target (R3), platform/telegram/Dockerfile has `validator`+`bot` targets (TX)
deploy/ # docker-compose (per-service limits, R7) + caddy + landing + otelcol (OTLP + docker_stats per-container metrics) + prometheus/tempo/grafana + postgres_exporter
```
@@ -138,7 +138,7 @@ go build ./backend/... # per module ('./...' from the root won't span t
go vet ./backend/...
gofmt -l . # must print nothing
go test -count=1 ./backend/...
go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram connector (Stage 9)
go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram validator + bot (Stage 9; split in TX)
go run ./backend/cmd/backend # /healthz, /readyz on :8080
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+)
+30 -14
View File
@@ -51,7 +51,7 @@ independent (see ARCHITECTURE §9.1).
| 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 |
| 18 | Prod contour deploy (registry, two-host, rolling + auto-rollback; manual after merge) | machinery built; first cutover pending DNS |
| 19 | User feedback (in-app submit + attachment, admin review/reply, account roles) | **done** |
Scaffolding is incremental: `go.work` lists only existing modules; each stage
@@ -281,6 +281,12 @@ back to `preferred_language`). Non-Telegram logins (web/email/guest) carry the g
(`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants). Admin broadcasts (`SendToUser`/`SendToGameChannel`)
pick the bot by an **operator-chosen** language in the console — unrelated to `ValidateInitData`.
> **Superseded (2026-06-20, pre-release phase SB):** the two bots collapsed into **one** and the
> language-gated variant choice moved to a per-user **`variant_preferences`** profile set (default Erudit
> only). `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars,
> `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` and game-language push routing are gone; the single bot renders in
> the recipient's `preferred_language`. Current state: `docs/ARCHITECTURE.md` §3/§10, `PRERELEASE.md` row SB.
### 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
@@ -407,18 +413,28 @@ raw list is kept here as the record of what the first contour run surfaced.
"что-то пошло не так". при этом "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.
Scope: the **production contour** on **two remote hosts** over SSH — main (full stack, `erudit-game.ru`)
and tg (the bot only). Resolved open details (re-interviewed):
- **Transport: a registry** (not export/import) — build + push to `docker.iliadenisov.ru`, the hosts pull by tag.
- **Cert: ACME** at the contour caddy (`CADDY_SITE_ADDRESS=erudit-game.ru www.erudit-game.ru`, no host caddy).
- **No prod VPN** — the bot host has native Bot API egress (verified `api.telegram.org` → 200).
- **Rollback** — rolling per-service deploy (least → most dependent), health-gated, auto-rollback to the
previous image tag; a maintenance window + consistent `pg_dump` only on a schema migration
(expand-contract keeps the auto-rollback image-only; the dump is a manual safety net).
**Strictly manual** (`workflow_dispatch` from `master`, `confirm=deploy`) after `development → master`
is merged green. `TEST_`/`PROD_` prefixed Gitea secrets/variables (Gitea 1.26 has no deployment
environments — the `environments` API 404s). Hosts are provisioned by **`deploy/ansible/`** (docker, a
non-sudo `deploy` user with the CI key, key-only sshd, ufw, fail2ban). The main host is **launch-sized**
(2 vCPU / 1.9 GiB): `docker-compose.prod.yml` trims the R7 limits (`GOMAXPROCS=2`, smaller caps, 7d
Prometheus retention) and adds `node_exporter` for host-memory monitoring (launch undersized, resize at
Selectel reactively). `vpn`+`bot` are gated to a `telegram-local` compose profile (test only); the prod
bot runs standalone from `docker-compose.bot.yml`. `GATEWAY_ABUSE_BAN_ENABLED=true`.
**Built:** `deploy/ansible/` (both hosts provisioned + verified), the compose split + `node_exporter`,
`.gitea/workflows/prod-deploy.yaml` + `deploy/prod-deploy.sh`, the full `PROD_` secret/variable set.
**Remaining (acceptance):** the **first live cutover** — waits on the `erudit-game.ru` DNS delegation
(`A`/`www` → the main host) that ACME requires; then run the workflow and verify the public site end-to-end.
### Stage 19 — User feedback *(done)*
A user→operator feedback channel, sequenced after the numbered stages but shipped **before** the Stage 18
@@ -776,7 +792,7 @@ cannot submit; three-way admin filter.
reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms);
board **zoom reworked** to a width-based zoom in a fixed viewport (real native
scroll, double-tap; pinch/swipe dropped) with constant `cqw` labels, corner-letter
tiles, contrasting grid lines, last-word dark-tile highlight, and a Settings
tiles, last-word dark-tile highlight, and a Settings
**bonus-label style** (beginner/
classic/none); **hint lays its tiles on the board** (no spend when no move — a new
`no_hint_available` result code); the history opens as an in-place **slide-down**
+113 -3
View File
@@ -33,6 +33,15 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
| AB | Manual account block (admin suspension): permanent/temporary with an editable en+ru reason picklist; a block forfeits the player's active games + cancels their open ones; a backend gate refuses a blocked account with **403 `account_blocked`**; the UI shows a terminal blocked screen and stops all push/poll; manual unblock; temporary blocks self-expire (migration `00003`) | owner ad-hoc | **done** |
| AI | Honest AI opponent in quick game: an explicit 🤖 AI / 👤 random selector (AI default); the robot is seated and moves at once; 7-day inactivity loss (the per-turn timeout reused); chat/nudge disabled, no statistics; the opponent is shown as 🤖 everywhere | owner ad-hoc | **done** |
| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) |
| GL | Simultaneous quick-game cap (10): grey "New Game" + a lobby notice at the cap; backend gate on quick enqueue + invitation creation (409 `game_limit_reached`), accepting invitations exempt; `at_game_limit` rides `games.list` | owner ad-hoc | **done** |
| CR | In-game chat read receipts: per-message `unread_seats` bitmask (migration `00008`); a per-viewer unread **dot** in the lobby + game header (a nudge counts and clears when its recipient moves); reading = opening the move history (the 💬 fade-blinks twice) or the chat, acked (`chat.read`) only when unread; `chat_read_duration` + `chat_unread_messages` metrics + tracing + the **Scrabble — Messages** Grafana dashboard (follow-up PR); a message to a disguised robot opponent is born read; admin unread-only filter / read column / per-seat read card | owner ad-hoc | **done** |
| BX | Asymmetric per-user block + in-game controls: a block now silently suppresses everything **from** the blocked user (chat, nudge, friend requests, invitations are kept but never delivered/surfaced, born-read) while they notice nothing, **without** deleting the friendship (unblock restores it); auto-match excludes a block-related pair (either direction); in-game opponent card gains a ✖️ **block** control (mirroring 🤝, red "Block?" confirm, mutual-hide, struck name + hidden chat composer when blocked); optimistic apply + `user_blocked`/`user_unblocked` event confirm + rollback; admin user card gains **blocks / blocked-by / friends** cross-linked lists. Blocking a disguised-robot opponent is recorded per-game in a separate **`robot_blocks`** table (migration `00011`), keyed on game+seat with the seen name — never the shared robot account — so the matchmaker keeps giving robots; it shows in the blocked list and re-marks the in-game card | owner ad-hoc | **done** |
| FM | First-move tile draw (official rules): each seated player draws a tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader; **honest per-draw `crypto/rand` entropy**, not the bag seed, so the **record** (`game_setup_draws`, migration `00013`) — not a seed — is the only account of the outcome, kept for future **tournaments** (designed as a discrete per-tile "player N draws" step). Friend/AI draws at create; **auto-match draws at *open*** against a synthetic `uuid.Nil` opponent whose draw rows are back-filled on join, so the opener's seat is fixed up front and the existing open-game pre-move is preserved (no reseating, no play-gating). Admin `/_gm/games/:id` gains the recorded draw list + a simple **step-by-step board replay** (`ReplayTimeline`). | owner ad-hoc | **done** |
| SB | Single Telegram bot + per-user variant preferences: the two per-language bots collapse into **one** (drop `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars and game-language push routing — the single bot renders in the recipient's `preferred_language`); New Game variant gating moves to a profile **`variant_preferences`** set (default Erudit only, Erudit-first, server-enforced on the caller's auto-match/vs-AI/invitation-create paths, an invited friend may accept any variant); env vars collapse to unsuffixed `TELEGRAM_BOT_TOKEN`/`TELEGRAM_GAME_CHANNEL_ID`/`VITE_TELEGRAM_LINK`/`VITE_TELEGRAM_GAME_CHANNEL_NAME` and `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` is removed; wire drops `service_language`/`supported_languages` (Session, ValidateInitDataResponse) + the push `language` routing field and adds `variant_preferences` to Profile/UpdateProfile. | owner ad-hoc | **done** |
| DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in an authoritative `.seed_version` marker so a bumped build seed on a live volume is ignored (it can't relabel live bytes — which would mis-serve the dictionary + void games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** |
| TX | Telegram egress off the main host: split the connector into a home **validator** (Mini App / Login-Widget HMAC, no VPN, no Bot API — so game login no longer depends on Telegram being reachable) and a remote **bot** (Bot API long-poll + `sendMessage`) that holds **no inbound port** and dials the gateway over a reverse **mTLS bot-link** (`pkg/proto/botlink/v1`); the gateway funnels out-of-app push (fire-and-forget, at-most-once) and the backend admin broadcasts (a relay that awaits the bot's ack) down the link. The bot is Telegram-rate-limited; **one bot now**, with seams (a bot registry + `owns_updates` + command ids) for N later; **no webhook** (rejected: one URL per token, adds inbound + a static address). The **unified test contour** runs the split (the bot keeps its VPN sidecar and dials the gateway by its internal name; certs from `deploy/gen-certs.sh`). The **prod** wiring — the bot on a separate host (no VPN), the gateway bot-link port published, `PROD_` certs, an SSH deploy of both hosts together — is **built in Stage 18** (the two-host registry rollout; first cutover pending the `erudit-game.ru` DNS). | owner ad-hoc | **done** (code + test contour; prod wiring built — Stage 18) |
| AG | Anti-abuse IP ban + honeypot/honeytoken (prod-only): a fail2ban-style in-memory `ratelimit.Banlist` keyed by client IP, fed by sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the user class stays the soft-flag's concern), a **honeypot** decoy path (the contour caddy tags `/.env`, `/.git`, `/wp-*`, … with `X-Scrabble-Honeypot` and routes them to the gateway), and a **honeytoken** (`GATEWAY_HONEYTOKEN`, a planted bearer). The `abuseGuard` edge middleware refuses a banned IP with **429** before any work — closing the R3 gap that the static SPA/landing was outside the token bucket. Off by default — it keys by the real client IP the shared-NAT test contour does not expose (detection still logs there); enabled in prod via `GATEWAY_ABUSE_BAN_ENABLED`. Operators see + lift bans on the console **Throttled** page; the gateway syncs its active set to the backend (`/api/v1/internal/bans/sync`, `internal/banview`) every 30 s and applies operator unbans. | owner ad-hoc | **done** (code + test contour; ban on in prod via Stage 18 — machinery built, cutover pending DNS) |
| CM | Channel-chat moderation + promo bot: a second standalone bot in the bot container answers `/start` with a localized message + a **URL** button into the **main** bot's Mini App (`?startapp`; a `web_app` button would sign initData with the promo token, which the main validator rejects). The **main** bot gates write access in a channel's linked discussion chat. The chat **allows sending by default** and the bot only restricts (Telegram intersects the chat default with the per-user permission, so a per-user grant cannot exceed a deny-by-default group): it **mutes** a member who is not registered or is admin-suspended or holding a new **`chat_muted`** role, and **un-mutes** an eligible one it had muted, for a member currently in the chat (a `getChatMember` guard, since bots cannot list members). Eligibility = `registered AND NOT suspended AND NOT chat_muted` (the game suspension dominates), resolved once in the backend and reached two ways: the bot's `ResolveChatEligibility` on a `chat_member` event over the existing mTLS bot-link, and a backend `chat_access_changed` event → gateway → `ChatGate` command (emitted on block/unblock, a `chat_muted` change, a first registration, or a temporary-block expiry via a sweeper; idempotent). No schema change — `chat_muted` reuses `account_roles`. | owner ad-hoc | **done** |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
## Key findings (these reshaped the raw list — read before starting a phase)
@@ -76,6 +85,19 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
- **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**.
- **Anti-abuse IP ban (AG, owner ad-hoc):** a honeypot was considered and rejected as a *DDoS*
defence — it detects/deceives but does not shed volumetric load, cannot cover the real
endpoints, and a tarpit backfires under flood; volumetric L3/L4 is an upstream/CDN concern,
out of scope. The effective layer is a **temporary IP ban** (fail2ban-style) that the honeypot
and honeytoken merely *feed*. This does **not** reverse the TODO-8 "no auto-ban": that decision
governs the **account** soft-flag (still never a gate); the IP ban is a separate, IP-keyed,
**prod-only** layer with an **operator unban** in the console. Decisions: banlist lives in the
existing `ratelimit` package (smallest surface); the decoy path list is a **single source of
truth in the caddy** (it tags requests with a header — the gateway keeps no second list);
bans are in-memory + single-instance (like `ratewatch`), auto-expiring, **plus** an admin
console view + manual unban over a bidirectional 30 s sync (operator control = owner's choice).
An active-bans Grafana **gauge** was trimmed (the console view + the `gateway_abuse_banned_total`
counter cover it) to keep the diff focused.
- **Open auto-match (owner ad-hoc):** a quick game **enters a real game at once and waits inside
it** (status `open`, the opponent seat empty); a second human searching the same variant+rule
joins it, or a robot fills it after a **90 s + random 090 s** wait, pushing the in-app
@@ -84,6 +106,20 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
now **DB-backed open games** — the in-memory pool, `lobby.poll` and `lobby.cancel` are gone. The
schema is edited in the baseline (no prod data); `game_players.account_id` is nullable for the
empty seat.
- **Telegram egress off-host (TX, owner ad-hoc):** the driver is **removing VPN/Telegram
traffic from the main host** (OPSEC / one fewer analysis vector), not only notification
resilience. Login is local HMAC, so it stays up regardless of the bot — confirmed in the
code and made structural by the split. **Unified topology in code** (validator + bot, the
bot dialing the gateway) in **both** contours, differing only in deployment; the test bot
keeps its VPN sidecar. Transport = a **reverse gRPC bidi stream, mTLS, bot-dials-gateway**
(no inbound/static IP on the bot), reusing the push-stream pattern; **webhook rejected**
(one URL per token, adds inbound + a static address). Delivery **at-most-once** (a dropped
nudge beats a duplicate). **One bot now**, seams (registry + `owns_updates` + command ids)
for N later. **Cert rotation** by a scheduled CI job from a long-lived CA. **Prod deploy by
SSH** (pull excluded), the bot rolled **together** with the main app (the bot-link protocol
kept back-compatible by one version as the non-atomic-two-host-deploy safety net). The bot
is monitored **from the gateway** (connection + ack metrics). The bot-host token-at-rest is
**accepted**.
## Phases
@@ -275,7 +311,7 @@ Then Stage 18.
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);
figures are pessimistic. Full trip report in [`../loadtest/REPORT.md`](../loadtest/REPORT.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.
@@ -418,7 +454,12 @@ Then Stage 18.
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).
info). Full write-up in [`../loadtest/REPORT.md`](../loadtest/REPORT.md). *(Superseded in part: a
later pass modelling the `game.evaluate` hot path traced the gateway's CPU appetite to
**gateway→backend connection churn** — the default 2-idle-connection HTTP transport — not proxying
work. Pooling the connections cut peak gateway CPU ~7× (~1.75 → ~0.26 cores at 500 players) and
removed the ephemeral-port-exhaustion cliff behind the residual `transport_error`, so the gateway is
no longer the binding constraint — postgres is. The 3-core gateway cap below is now generous headroom.)*
- **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).
@@ -428,7 +469,7 @@ Then Stage 18.
**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`,
- **No schema change → no contour DB wipe.** Bake-back: `loadtest/REPORT.md`, `loadtest/README.md`,
`docs/TESTING.md`, the telemetry/observability section of `docs/ARCHITECTURE.md`, the repo-layout line in `CLAUDE.md`.
- **UI — Tab-bar navigation redesign** (owner ad-hoc, not on the raw TODO list): drop the hamburger
@@ -520,3 +561,72 @@ Then Stage 18.
the shared `selectMove`; the per-game intent (and the admin card) is unchanged. Tests: `robot` unit
(taper bounds + monotonicity, never-in-endgame, determinism, ~20% distribution). Bake-back:
`docs/ARCHITECTURE.md` §7, `docs/FUNCTIONAL.md` (+`_ru`), `backend/README.md`, `PLAN.md` Stage 5.
- **GL — Simultaneous quick-game cap** (owner ad-hoc, not on the raw TODO list): a player may hold at
most **10** active quick games; at the cap the lobby greys **New Game** and shows a plain notice
"Вы достигли лимита одновременных партий", both clearing automatically when an active game finishes.
- **Locked decisions (interview):** what counts = active **+** open (searching) quick games, **including
AI** (`vs_ai`); friend games (invitation-linked) **never** count. The backend gate refuses **all** new-game
creation at the cap — `lobby/enqueue` **and** `invitations` — with **409 `game_limit_reached`**; **accepting**
an invitation is never gated, so friend games are capped "from the other end". Delivery = a boolean
**`at_game_limit`** on the existing `games.list` (no per-event payload: a turn change does not move the count,
and the lobby already re-fetches `games.list` on entry + every game event); the first uncached lobby frame
defaults the button **enabled** (the backend gate is the authority).
- **What shipped:** `game.MaxActiveQuickGames` + `Store/Service.CountActiveQuickGames` (active/open seats, no
`game_invitations` row; hidden games still count → a dedicated count, not a filter over the lobby list);
`Server.atGameLimit`/`ensureUnderGameLimit` gating `handleEnqueue` + `handleCreateInvitation`;
`gameListDTO.at_game_limit`; the FB `GameList` trailing `at_game_limit` (regenerated Go + TS) threaded through
the gateway transcode + UI codec; `lib/model` + `lobbycache` snapshot + `Lobby.svelte` (disabled tab + a muted
`.limit` notice); i18n `lobby.limitReached` (en authoritative + ru).
- **Caveat (logged):** the gate is a pre-check, not transaction-atomic — concurrent creates from one account could
momentarily exceed by 12 (harmless soft cap; the UI disables the button regardless). Strict atomicity was judged
a disproportionate diff across the two create paths.
- **No schema change → no contour DB wipe** (only a trailing FB field, no migration). Tests: backend integration
(`game_limit_test.go`: count rule + HTTP gate 409 + accept bypass), server unit (error mapping), gateway
transcode round-trip, UI codec + lobbycache unit, e2e (`gamelimit.spec.ts`). Bake-back: `docs/FUNCTIONAL.md`
(+`_ru`), `docs/ARCHITECTURE.md` §8, `docs/UI_DESIGN.md`, `backend/README.md`.
- **CM — Channel-chat moderation + promo bot** (owner ad-hoc, not on the raw TODO list):
- **Locked decisions (interview):** the promo bot is a **goroutine in `cmd/bot`** (its own token, no
bot-link); the moderated chat's default-no-send is configured by a **human** in the group settings (the
bot only grants, never `setChatPermissions`); a non-eligible joiner is **left muted silently**; a
temporary-suspension expiry is handled by a **backend sweeper** that emits the re-evaluate event; and a new
**`chat_muted` role** is a chat-only mute with the **game suspension dominating**
(`eligible = registered AND NOT suspended AND NOT chat_muted`).
- **Bot API reality (verified against the docs):** a cross-bot Mini App launch must be a **URL button** to the
main bot's `t.me/<bot>?startapp` link — a `web_app` button signs initData with the *sending* bot's token,
which the main validator rejects — so the promo button reuses the UI's `VITE_TELEGRAM_LINK`. `chat_member`
updates arrive **only** when the bot is a chat **admin** with the "Ban users" right (the client label for the
Bot API `can_restrict_members`) and `chat_member` is in `allowed_updates`; bots cannot list members but can
`getChatMember` a single user, which is the membership guard on the block/unblock path.
- **Wire:** `pkg/proto/botlink/v1` gains a `ChatGateCommand` in the `Command` oneof and a unary
`ResolveChatEligibility`; the backend gains `notify.KindChatAccessChanged` (no payload, infra-only — never an
out-of-app message) and an internal `POST /api/v1/internal/chat-access` resolver; the gateway resolves the
join (by external_id) and the event (by user_id) through it and pushes the chat-gate command fire-and-forget
(at-most-once, recovered by the next moderation action or a re-join).
- **No schema change → no contour DB wipe:** `chat_muted` is a new `account.KnownRoles` entry (the
`account_roles` table is data-driven). The suspension-expiry sweeper is a new `account.SuspensionSweeper`
(a 1-minute window, idempotent) started in `cmd/backend`, alongside the guest reaper.
- **Deploy:** new `TEST_`/`PROD_` `TELEGRAM_PROMO_BOT_TOKEN` (secret), `TELEGRAM_BOT_USERNAME` and
`TELEGRAM_CHAT_ID` (variables); the promo link reuses the existing `*_VITE_TELEGRAM_LINK` variable as
`TELEGRAM_BOT_LINK`. The bot must be promoted to admin in the real discussion group, and the group default
set to no-send, as part of the Stage 18 prod cutover (the test contour exercises the code path).
- **Bake-back:** `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md` (+`_ru`), `platform/telegram/README.md`,
`backend/README.md`, Go Doc comments. Tests: backend resolver truth table + publish on block/unblock/role +
the sweeper window (unit + integration); gateway hub `ResolveChatEligibility` + the chat-gate command; bot
`chat_member` grant + `ApplyChatGate` getChatMember-guard; promo `/start` localization + URL button; config
parsing.
- **Post-contour-test fixes (same PR):** a live test drove three corrections. (1) **Strategy
inversion (the key one)** — the original "group default no-send, bot grants the eligible" cannot
work: Telegram intersects the chat default with each user's permission, so a per-user grant never
exceeds a deny-by-default group (the bot set `can_send=true` yet the user still could not write).
The group now **allows sending by default** and the bot only **restricts** — it mutes an ineligible
member (unregistered / admin-suspended / `chat_muted`) and un-mutes an eligible one it had muted,
acting only when the current state differs (idempotent; the bot's own change is skipped by matching
the actor id to the bot). A present member in a default-allow group can appear as `restricted` with
`is_member`, so the gate reads both. (2) **Join-before-register** — a user who joins before
registering is covered by no `chat_member` event, so `ProvisionTelegram` now reports first contact
and the Telegram auth handler emits `chat_access_changed` on it. (3) **Observability** — a startup
self-check logs whether the bot is an admin-with-restrict in the chat (it caught a misconfigured
`TELEGRAM_CHAT_ID` set to a channel id, not the discussion-group id); the per-event trace is at
Debug, the actual mute/unmute and warnings at Info.
+5 -3
View File
@@ -12,7 +12,7 @@
# --- dictionary artifact -----------------------------------------------------
FROM alpine:3.20 AS dawg
ARG DICT_VERSION=v1.0.0
ARG DICT_VERSION=v1.2.1
RUN apk add --no-cache curl tar
RUN mkdir -p /dawg \
&& curl -fsSL -o /tmp/dawg.tar.gz \
@@ -33,14 +33,16 @@ 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
# VERSION (the deploy passes the git tag) is stamped into the binary via the linker.
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-X scrabble/pkg/version.Version=${VERSION}" -o /out/backend ./backend/cmd/backend
# --- runtime -----------------------------------------------------------------
FROM gcr.io/distroless/static-debian12:nonroot
# Re-declare the build arg in this stage so it labels the seed dictionary. One
# DICT_VERSION drives both the artifact the dawg stage downloads and the version
# label the binary pins, so the resident version equals the release tag.
ARG DICT_VERSION=v1.0.0
ARG DICT_VERSION=v1.2.1
COPY --from=build /out/backend /usr/local/bin/backend
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
# mounted at /opt/dawg inherits this ownership on first use, so the admin console
+52 -16
View File
@@ -33,10 +33,22 @@ real game seating the caller with an **empty opponent seat** (status `open`) or,
another player already waits for the same variant and per-turn word rule, seats the
caller into that open game and starts it — and
friend-game invitations (invite → accept, starting a 24 player game once every
invitee accepts). `internal/social` owns the friend graph (request/accept),
invitee accepts). A **simultaneous-game cap** (`game.MaxActiveQuickGames` = 10) limits a
player's active quick games — status `active`/`open`, excluding invitation-linked friend
games (`game.Service.CountActiveQuickGames`); the server refuses `lobby/enqueue` and
`invitations` creation with **409 `game_limit_reached`** at the cap (accepting an invitation
is exempt), and the `games.list` response carries an `at_game_limit` flag for the lobby.
`internal/social` owns the friend graph (request/accept),
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
messages are length-capped, content-filtered (no links/emails/phone numbers,
including obfuscated forms) and stored with the sender's IP. `internal/account`
including obfuscated forms) and stored with the sender's IP. Each message carries an
`unread_seats` read bitmask (a set bit per recipient seat still to read it); `MarkRead`
clears a reader's bit when they open the move history or chat, and a wired `NudgeClearer`
clears a nudge when its recipient moves — both record the publish-to-read latency.
A friend request (or block) aimed at a **disguised pooled robot** is recorded per game+seat
in `robot_friend_requests` / `robot_blocks`, never against the shared robot account; a
background reaper drops a robot friend request once its game has been finished for **7 days**.
`internal/account`
gains profile editing and the email confirm-code flow (a `Mailer` seam: SMTP or a
development log mailer). The engine now also handles **multi-player drop-out**: in
a 34 player game a resignation or timeout drops that seat and the rest play on
@@ -59,7 +71,10 @@ win (≈ 40%), targets a small score margin — with an occasional off-strategy
none as the bag empties — and times its moves with a move-number-aware
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
behaviour — all derived deterministically from the game seed, so it keeps no extra
state. A background **reaper** seats a pooled robot (matching the game's language) in any open
state. In a dead-drawn endgame — the last two journal moves are both passes, so the robot is bound
to pass again — it shortens that delay to a `[0.8, 1.5]×` band around the human's last-move think
time (the gap between the last two moves), clamped to `[30 s, 8 min]` and `min`-ed with the normal
delay, so a decided game is not dragged out while the robot never moves slower than usual. A background **reaper** seats a pooled robot (matching the game's language) in any open
game whose wait window — a fixed **90 s** plus a random **090 s** (so **90180 s**) — has
elapsed, and the waiting starter is told an opponent took the seat by an in-app
**opponent_joined** push (carrying their refreshed game state) that fills the opponent card and
@@ -88,9 +103,16 @@ second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) str
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's
Telegram `external_id`, language and `notifications_in_app_only` flag) lets the gateway
route out-of-app push to the Telegram connector; the Telegram login
route out-of-app push to the Telegram bot over the gateway bot-link; the Telegram login
seeds a new account's language and display name from the launch fields, and the
`accounts.notifications_in_app_only` flag (default true).
The gateway-only `POST /api/v1/internal/chat-access` resolves a Telegram identity (the
bot's join-time query) or an account id (a `chat_access_changed` event) to its
**moderated-chat write eligibility**`registered AND NOT suspended AND NOT chat_muted`.
That event is emitted on an admin block/unblock, a `chat_muted` role grant/revoke, or — via
the `account.SuspensionSweeper` started in `cmd/backend` — a temporary block lapsing;
`chat_muted` is an `account.KnownRoles` entry, a chat-only mute distinct from the game
suspension (which dominates it).
`accounts.is_guest` marks an ephemeral guest — a durable row
with no identity, excluded from statistics. The server-rendered
**admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`;
@@ -101,16 +123,15 @@ pipeline, the online **dictionary update** (upload the `scrabble-dawg-vX.Y.Z.tar
archive, preview the per-variant word diff, then install + activate — `internal/dictadmin` +
`engine.DiffWords` / `Registry.LoadAvailable`, written to per-version subdirectories of the
`BACKEND_DICT_DIR` volume with the active version persisted in `dictionary_state`), and operator **broadcasts** via a
backend 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 console also manages the **advertising banner** (`/_gm/banners` +
backend client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) that calls the gateway's
**bot-link relay** — each broadcast renders through the bot in an operator-chosen language
and the relay awaits the bot's delivery ack. There is one bot,
so `/internal/push-target` returns the recipient's `preferred_language` as the render
language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` +
`/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional
window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches
the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0
&& !no_banner` role, the message language picked by `service_language`); changing those inputs
&& !no_banner` role, the message language picked by `preferred_language`); changing those inputs
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire
contracts live in the sibling [`../pkg`](../pkg) module.
@@ -134,6 +155,13 @@ rejected calls within `BACKEND_HIGHRATE_FLAG_WINDOW` gets the soft, reversible
`accounts.flagged_high_rate_at` marker (set-once; a badge in the user list and a
**Clear** action on the user card; never an automatic ban).
The gateway also syncs its active IP bans (prod-only — see ARCHITECTURE §11) to
`POST /api/v1/internal/bans/sync`; `internal/banview` mirrors them for the console's
**Throttled** page (an **Active IP bans** panel with an **Unban** action) and returns
the operator's pending unbans in the response, which the gateway applies on its next
sync. Like `ratewatch` it is in-memory and resets on restart — the enforced ban lives
in the gateway, not here.
## Package layout
```
@@ -157,8 +185,9 @@ internal/lobby/ # auto-match (DB-backed open games + robot substitution) +
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
internal/ads/ # advertising banner: campaigns + bilingual messages + display timings, weighted-rotation feed (ActiveSet)
internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts)
internal/connector/ # backend gRPC client to the gateway bot-link relay (operator broadcasts)
internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag
internal/banview/ # gateway active-ban mirror: the console's Active IP bans panel + the operator unban backchannel
```
## Configuration (environment)
@@ -177,7 +206,7 @@ internal/ratewatch/ # gateway rate-limit reports: episode window for the consol
| `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_DICT_VERSION` | `v1` | Version label for the flat dictionary dir. Recorded in a `.seed_version` marker on first boot and authoritative after: on a seeded volume a changed value is ignored (it seeds only a fresh volume) — the seed-drift guard (ARCHITECTURE.md §5). |
| `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. |
| `BACKEND_GAME_CACHE_TTL` | `24h` | Idle window before a live game is evicted from cache. |
| `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. |
@@ -188,7 +217,7 @@ internal/ratewatch/ # gateway rate-limit reports: episode window for the consol
| `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_CONNECTOR_ADDR` | — | the gateway bot-link relay gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
| `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged. |
@@ -199,7 +228,7 @@ internal/ratewatch/ # gateway rate-limit reports: episode window for the consol
```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
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.2.1/scrabble-dawg-v1.2.1.tar.gz | tar xz -C /tmp/dawg
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
BACKEND_DICT_DIR=/tmp/dawg \
GOPRIVATE='gitea.iliadenisov.ru/*' \
@@ -238,7 +267,14 @@ local solver co-development you may add a temporary replace — see `go.work`).
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).
(a missing dictionary aborts the boot). The flat directory is the seed version,
labelled `BACKEND_DICT_VERSION`; uploaded versions live in `<version>/`
subdirectories the admin console writes and a restart re-loads. Because the DAWGs
carry no embedded version, the first boot records the seed in a `.seed_version`
marker that is authoritative after: on a seeded volume a changed `BACKEND_DICT_VERSION`
is ignored (it seeds only a fresh volume) — the seed-drift guard — so a live contour's
dictionary is changed through the console, never by bumping the build seed
(ARCHITECTURE.md §5).
## Tests
+26
View File
@@ -15,11 +15,13 @@ import (
"syscall"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountmerge"
"scrabble/backend/internal/ads"
"scrabble/backend/internal/banview"
"scrabble/backend/internal/config"
"scrabble/backend/internal/connector"
"scrabble/backend/internal/engine"
@@ -159,6 +161,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
zap.Duration("interval", cfg.GuestReapInterval),
zap.Duration("retention", cfg.GuestRetention))
// Re-evaluate moderated-chat write access when a temporary block self-expires:
// no operator action fires then, so the sweeper emits the chat-access-changed
// event for lapsed blocks and the gateway re-pushes the chat-gate command.
chatSweeper := account.NewSuspensionSweeper(accounts, func(id uuid.UUID) {
hub.Publish(notify.ChatAccessChanged(id))
}, logger)
go chatSweeper.Run(ctx)
logger.Info("suspension expiry sweeper started", zap.Duration("interval", chatSweeper.Interval()))
// Lobby & social domains. Their REST and stream surface lives in the gateway,
// so they are handed to the server (like the route groups) for the handlers.
mailer := newMailer(cfg.SMTP, logger)
@@ -169,6 +180,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
socialSvc := social.NewService(social.NewStore(db), accounts, games)
socialSvc.SetNotifier(hub)
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
// A nudge the recipient answered by moving is marked read on the move path.
games.SetNudgeClearer(socialSvc.ClearNudges)
// Reap per-game disguised-robot friend requests once their game is long finished
// (the robot ignores them; the row only pins the in-game "request sent" state).
robotReqReaper := social.NewRobotFriendRequestReaper(socialSvc, logger)
go robotReqReaper.Run(ctx)
logger.Info("robot friend request reaper started",
zap.Duration("interval", robotReqReaper.Interval()),
zap.Duration("retention", robotReqReaper.Retention()))
feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts)
feedbackSvc.SetNotifier(hub)
@@ -187,6 +207,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, cfg.Lobby.RobotWaitJitter, logger)
matchmaker.SetNotifier(hub)
matchmaker.SetBlocker(socialSvc)
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
invitations.SetNotifier(hub)
@@ -201,6 +222,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
zap.Int("flag_threshold", cfg.RateWatch.FlagThreshold),
zap.Duration("flag_window", cfg.RateWatch.FlagWindow))
// Ban observability: mirror the gateway's active IP bans for the admin console's
// active-bans panel and collect operator unban requests.
banView := banview.New()
// Advertising-banner domain: campaign rotation feeding the profile.get banner
// block and the banner admin console section.
adsSvc := ads.NewService(ads.NewStore(db))
@@ -223,6 +248,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
DictDir: cfg.Game.DictDir,
Connector: conn,
RateWatch: rateWatch,
BanView: banView,
Ads: adsSvc,
Notifier: hub,
})
+35 -39
View File
@@ -55,12 +55,12 @@ 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
// VariantPreferences is the set of game variants (engine.Variant stable labels:
// "scrabble_en", "scrabble_ru", "erudit_ru") the player is willing to be matched
// into. It gates the New Game picker, the matchmaker and the friend-invite the
// player creates; an invited friend may still accept any variant. A new account
// defaults to Erudit only. Never empty — enforced on update and by a DB check.
VariantPreferences []string
// IsGuest marks an ephemeral guest account: a durable row with no identity,
// excluded from statistics, friends and history.
IsGuest bool
@@ -151,14 +151,26 @@ func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName stri
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 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))
// ProvisionTelegram provisions (or finds) the account bound to a Telegram identity,
// reporting whether this call created it (first contact). On first contact only, it
// seeds the new account's preferred language from the Telegram client languageCode
// (when it maps to a supported language) and its display name sanitized from firstName
// (falling back to username, then to a generated placeholder when neither yields any
// letters); an already-existing account is returned unchanged, so a later profile edit
// is never overwritten. The created flag lets the auth handler re-evaluate moderated-
// chat write access on first registration — the path of a user who joined the chat
// before registering, whom no chat_member event covers.
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, bool, error) {
// Pre-check whether the identity already exists so the caller can act on first
// contact. A race with a concurrent create only over- or under-reports created for
// that one call, which the idempotent chat-access re-evaluation tolerates.
_, err := s.findByIdentity(ctx, KindTelegram, externalID)
created := errors.Is(err, ErrNotFound)
if err != nil && !created {
return Account{}, false, err
}
acc, err := s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
return acc, created, err
}
// provision finds the account for (kind, externalID) or creates it with seed,
@@ -303,6 +315,14 @@ func (s *Store) CountAccounts(ctx context.Context) (int, error) {
return int(dest.Count), nil
}
// AccountByIdentity returns the account bound to (kind, externalID), or ErrNotFound
// when none exists. Unlike ProvisionByIdentity it never creates one: the chat-access
// resolver uses it to tell a registered Telegram user (eligible to be granted chat
// write access) from an unregistered one (left muted).
func (s *Store) AccountByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
return s.findByIdentity(ctx, kind, externalID)
}
// findByIdentity joins identities to accounts and returns the matching account,
// or ErrNotFound.
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
@@ -491,36 +511,12 @@ func (s *Store) ClearHighRateFlag(ctx context.Context, id uuid.UUID) error {
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
@@ -529,7 +525,7 @@ func modelToAccount(row model.Accounts) Account {
ID: row.AccountID,
DisplayName: row.DisplayName,
PreferredLanguage: row.PreferredLanguage,
ServiceLanguage: serviceLanguage,
VariantPreferences: []string(row.VariantPreferences),
TimeZone: row.TimeZone,
AwayStart: row.AwayStart,
AwayEnd: row.AwayEnd,
+52 -2
View File
@@ -14,6 +14,7 @@ import (
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"github.com/lib/pq"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
@@ -58,6 +59,46 @@ type ProfileUpdate struct {
BlockChat bool
BlockFriendRequests bool
NotificationsInAppOnly bool
// VariantPreferences is the set of game variants the player allows themselves to
// be matched into (engine.Variant stable labels). UpdateProfile cleans it to a
// deduplicated, canonically ordered subset of the known variants and rejects an
// empty set.
VariantPreferences []string
}
// knownVariants is the closed set of game-variant labels (engine.Variant stable
// labels) a profile's variant preferences may contain. It lives here so the store
// does not depend on the engine package; the server handler additionally validates
// against engine.ParseVariant, and a DB check enforces the same subset.
var knownVariants = map[string]bool{"erudit_ru": true, "scrabble_ru": true, "scrabble_en": true}
// canonicalVariantOrder is the deterministic order variant preferences are stored
// in (Erudit, Russian Scrabble, English), independent of the client's order.
var canonicalVariantOrder = []string{"erudit_ru", "scrabble_ru", "scrabble_en"}
// validateVariantPreferences cleans a profile's variant-preference set: it drops
// duplicates, rejects an unknown label or an empty set (ErrInvalidProfile) and
// returns the preferences in canonicalVariantOrder so the stored value is
// deterministic regardless of the order the client sent.
func validateVariantPreferences(prefs []string) ([]string, error) {
seen := make(map[string]bool, len(prefs))
for _, p := range prefs {
p = strings.TrimSpace(p)
if !knownVariants[p] {
return nil, fmt.Errorf("%w: variant preference %q", ErrInvalidProfile, p)
}
seen[p] = true
}
if len(seen) == 0 {
return nil, fmt.Errorf("%w: variant preferences must not be empty", ErrInvalidProfile)
}
out := make([]string, 0, len(seen))
for _, v := range canonicalVariantOrder {
if seen[v] {
out = append(out, v)
}
}
return out, nil
}
// UpdateProfile validates and overwrites the editable fields of the account, then
@@ -79,17 +120,26 @@ func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate
if err := validateAwayWindow(p.AwayStart, p.AwayEnd); err != nil {
return Account{}, err
}
prefs, err := validateVariantPreferences(p.VariantPreferences)
if err != nil {
return Account{}, err
}
stmt := table.Accounts.UPDATE(
table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone,
table.Accounts.AwayStart, table.Accounts.AwayEnd,
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests,
table.Accounts.NotificationsInAppOnly, table.Accounts.UpdatedAt,
table.Accounts.NotificationsInAppOnly, table.Accounts.VariantPreferences,
table.Accounts.UpdatedAt,
).SET(
postgres.String(name), postgres.String(lang), postgres.String(tz),
postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd),
postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests),
postgres.Bool(p.NotificationsInAppOnly), postgres.TimestampzT(time.Now().UTC()),
postgres.Bool(p.NotificationsInAppOnly),
// prefs are validated against the closed knownVariants set; bind as a text[]
// parameter (lib/pq encodes the array, the cast pins the column type).
postgres.Raw("#variant_prefs::text[]", map[string]interface{}{"#variant_prefs": pq.StringArray(prefs)}),
postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
RETURNING(table.Accounts.AllColumns)
+23 -1
View File
@@ -3,6 +3,7 @@ package account
import (
"context"
"errors"
"slices"
"strings"
"testing"
"time"
@@ -16,7 +17,7 @@ import (
// offset/IANA timezone), not just their unit tests in validate_test.go.
func TestUpdateProfileValidation(t *testing.T) {
s := &Store{}
base := ProfileUpdate{DisplayName: "Kaya", PreferredLanguage: "en", TimeZone: "UTC"}
base := ProfileUpdate{DisplayName: "Kaya", PreferredLanguage: "en", TimeZone: "UTC", VariantPreferences: []string{"erudit_ru"}}
hm := func(h, m int) time.Time { return time.Date(0, 1, 1, h, m, 0, 0, time.UTC) }
tests := []struct {
name string
@@ -28,6 +29,8 @@ func TestUpdateProfileValidation(t *testing.T) {
{"over-long name", func(p *ProfileUpdate) { p.DisplayName = strings.Repeat("x", maxDisplayName+1) }},
{"bad name layout", func(p *ProfileUpdate) { p.DisplayName = "Bad__Name" }},
{"away over 12h", func(p *ProfileUpdate) { p.AwayStart, p.AwayEnd = hm(8, 0), hm(21, 0) }},
{"empty variant preferences", func(p *ProfileUpdate) { p.VariantPreferences = nil }},
{"unknown variant preference", func(p *ProfileUpdate) { p.VariantPreferences = []string{"chess"} }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
@@ -39,3 +42,22 @@ func TestUpdateProfileValidation(t *testing.T) {
})
}
}
// TestValidateVariantPreferences checks the cleaning of a profile's variant set:
// duplicates collapse, the result is canonically ordered (Erudit, Russian Scrabble,
// English) regardless of input order, and an empty or unknown set is rejected.
func TestValidateVariantPreferences(t *testing.T) {
got, err := validateVariantPreferences([]string{"scrabble_en", "erudit_ru", "scrabble_en"})
if err != nil {
t.Fatalf("validate: %v", err)
}
if want := []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
if _, err := validateVariantPreferences(nil); !errors.Is(err, ErrInvalidProfile) {
t.Fatalf("empty err = %v, want ErrInvalidProfile", err)
}
if _, err := validateVariantPreferences([]string{"chess"}); !errors.Is(err, ErrInvalidProfile) {
t.Fatalf("unknown err = %v, want ErrInvalidProfile", err)
}
}
+9 -1
View File
@@ -24,11 +24,19 @@ const (
// unconditionally, overriding the usual eligibility (a free account with an
// empty hint wallet otherwise sees it). See internal/ads.
RoleNoBanner = "no_banner"
// RoleChatMuted forbids the account from writing in the moderated Telegram
// discussion chat, without otherwise restricting the game (the chat-only
// counterpart to a full account suspension). It is one input to the chat-access
// gate; an active admin suspension mutes the player regardless, so this role only
// matters for an account that is not suspended. Granting or revoking it re-pushes
// the chat-gate command for a member currently in the chat.
RoleChatMuted = "chat_muted"
)
// KnownRoles is the set of roles the console may grant or revoke; an operator
// cannot assign an unrecognised role.
var KnownRoles = []string{RoleFeedbackBanned, RoleNoBanner}
var KnownRoles = []string{RoleFeedbackBanned, RoleNoBanner, RoleChatMuted}
// IsKnownRole reports whether role is a recognised account role.
func IsKnownRole(role string) bool {
+66 -1
View File
@@ -2,6 +2,7 @@ package account
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -13,16 +14,43 @@ import (
"scrabble/backend/internal/postgres/jet/backend/table"
)
// BestMoveTile is one letter cell of a best-move word: its concrete letter (the
// designated letter for a blank), its tile point value (0 for a blank) and whether it
// is a blank. It is the persisted/served shape: the game domain marshals a slice of
// these into account_best_move.tiles, and the statistics screen renders them as game
// tiles without consulting the variant's alphabet.
type BestMoveTile struct {
Letter string `json:"letter"`
Value int `json:"value"`
Blank bool `json:"blank"`
}
// BestMove is an account's highest-scoring single play within one game variant: the
// move's total score (every word it formed plus the all-tiles bonus, matching
// MaxWordPoints) and its main word as an ordered slice of tiles.
type BestMove struct {
Variant string
Score int
Tiles []BestMoveTile
}
// Stats is a durable account's lifetime record, written by the game domain on each
// finish and read for the player's statistics screen. MaxGamePoints is the best
// single game's total; MaxWordPoints is the best single move's score (which already
// includes every word it formed plus the all-tiles bonus).
// includes every word it formed plus the all-tiles bonus). BestMoves holds the same
// best move broken down per variant, with the word itself — empty for an account with
// no recorded play yet, and never carrying a variant the account has not played.
type Stats struct {
Wins int
Losses int
Draws int
MaxGamePoints int
MaxWordPoints int
// Moves is the lifetime count of the account's plays (tile placements); HintsUsed is the
// lifetime count of hints taken. The statistics screen shows the hint share (HintsUsed / Moves).
Moves int
HintsUsed int
BestMoves []BestMove
}
// GetStats returns the lifetime statistics for id. An account with no account_stats
@@ -40,11 +68,48 @@ func (s *Store) GetStats(ctx context.Context, id uuid.UUID) (Stats, error) {
}
return Stats{}, fmt.Errorf("account: get stats %s: %w", id, err)
}
best, err := s.bestMoves(ctx, id)
if err != nil {
return Stats{}, err
}
return Stats{
Wins: int(row.Wins),
Losses: int(row.Losses),
Draws: int(row.Draws),
MaxGamePoints: int(row.MaxGamePoints),
MaxWordPoints: int(row.MaxWordPoints),
Moves: int(row.Moves),
HintsUsed: int(row.HintsUsed),
BestMoves: best,
}, nil
}
// bestMoves reads an account's per-variant best moves, ordered by variant for a stable
// response. Each row's tiles JSON is decoded into the served BestMoveTile slice. An
// account with no recorded play yields an empty (nil) slice rather than an error.
func (s *Store) bestMoves(ctx context.Context, id uuid.UUID) ([]BestMove, error) {
stmt := postgres.SELECT(
table.AccountBestMove.Variant,
table.AccountBestMove.Score,
table.AccountBestMove.Tiles,
).
FROM(table.AccountBestMove).
WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(id))).
ORDER_BY(table.AccountBestMove.Variant.ASC())
var rows []model.AccountBestMove
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("account: best moves %s: %w", id, err)
}
out := make([]BestMove, 0, len(rows))
for _, r := range rows {
var tiles []BestMoveTile
if err := json.Unmarshal([]byte(r.Tiles), &tiles); err != nil {
return nil, fmt.Errorf("account: decode best-move tiles %s/%s: %w", id, r.Variant, err)
}
out = append(out, BestMove{Variant: r.Variant, Score: int(r.Score), Tiles: tiles})
}
return out, nil
}
+25
View File
@@ -161,6 +161,31 @@ func (s *Store) queryCurrentSuspension(ctx context.Context, accountID uuid.UUID,
return modelToSuspension(row), true, nil
}
// SuspensionsExpiredBetween returns the distinct account ids whose temporary block lapsed in the
// half-open window (since, until]: a non-lifted suspension with a blocked_until in that range. The
// chat-access sweeper uses it to re-evaluate chat write access when a temporary block self-expires,
// since no operator action fires then. An account that still has another active block may be
// included; the eligibility resolver returns the true state, so emitting for it is harmless.
func (s *Store) SuspensionsExpiredBetween(ctx context.Context, since, until time.Time) ([]uuid.UUID, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT DISTINCT account_id FROM backend.account_suspensions
WHERE lifted_at IS NULL AND blocked_until > $1 AND blocked_until <= $2`,
since.UTC(), until.UTC())
if err != nil {
return nil, fmt.Errorf("account: suspensions expired between: %w", err)
}
defer rows.Close()
var out []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("account: scan expired suspension: %w", err)
}
out = append(out, id)
}
return out, rows.Err()
}
// invalidateSuspension drops the account's cached block so the next CurrentSuspension re-reads it.
// Called after Suspend and LiftSuspension.
func (s *Store) invalidateSuspension(accountID uuid.UUID) {
@@ -0,0 +1,84 @@
package account
import (
"context"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
// suspensionSweepInterval is how often the sweeper re-checks for temporary blocks
// that lapsed. A minute is well under the coarsest block grain (operators pick day
// presets) while keeping the query trivial.
const suspensionSweepInterval = time.Minute
// suspensionExpiryQuerier is the slice of the account store the sweeper depends on:
// the accounts whose temporary block lapsed in a window. *Store satisfies it; a fake
// drives the sweeper's unit tests.
type suspensionExpiryQuerier interface {
SuspensionsExpiredBetween(ctx context.Context, since, until time.Time) ([]uuid.UUID, error)
}
// SuspensionSweeper re-evaluates chat write access when a temporary block self-
// expires. No operator action fires on expiry — the suspension gate just re-reads
// the wall clock — so without this a temporarily blocked player would stay muted in
// the moderated discussion chat after their block lapsed. Each tick it finds blocks
// that expired since the previous tick and calls onExpire for the affected accounts;
// onExpire is wired to publish the chat-access-changed event, after which the gateway
// re-resolves the true eligibility. A liberal call (an account that still has another
// active block) is therefore harmless. The window is in-memory, so a block that
// expires while the process is down is not re-granted until the next operator action
// or the player rejoins — an accepted best-effort gap.
type SuspensionSweeper struct {
store suspensionExpiryQuerier
onExpire func(accountID uuid.UUID)
log *zap.Logger
// since is the upper bound of the previous swept window; the next sweep covers
// (since, now]. It advances only on a successful query, so a failed tick retries
// the same window rather than dropping expiries.
since time.Time
}
// NewSuspensionSweeper builds the sweeper over the account store, the per-account
// expiry callback (publishing the chat-access-changed event) and a logger. The first
// window opens at construction time, so blocks that lapsed earlier are not re-emitted.
func NewSuspensionSweeper(store *Store, onExpire func(accountID uuid.UUID), log *zap.Logger) *SuspensionSweeper {
if log == nil {
log = zap.NewNop()
}
return &SuspensionSweeper{store: store, onExpire: onExpire, log: log, since: time.Now().UTC()}
}
// Interval reports the sweep cadence, for the startup log line.
func (w *SuspensionSweeper) Interval() time.Duration { return suspensionSweepInterval }
// Run sweeps every Interval until ctx is cancelled.
func (w *SuspensionSweeper) Run(ctx context.Context) {
ticker := time.NewTicker(suspensionSweepInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w.sweep(ctx)
}
}
}
// sweep emits a chat-access-changed signal for every account whose temporary block
// lapsed in (since, now], then advances the window. On a query error it keeps the
// window so the next tick retries it.
func (w *SuspensionSweeper) sweep(ctx context.Context) {
now := time.Now().UTC()
ids, err := w.store.SuspensionsExpiredBetween(ctx, w.since, now)
if err != nil {
w.log.Warn("suspension expiry sweep failed", zap.Error(err))
return
}
w.since = now
for _, id := range ids {
w.onExpire(id)
}
}
@@ -0,0 +1,80 @@
package account
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
// fakeExpiryQuerier records the `since` bound of each call and replays a scripted
// result/error per call, so the sweeper's window and dispatch logic is testable
// without a database.
type fakeExpiryQuerier struct {
results [][]uuid.UUID
errs []error
sinces []time.Time
idx int
}
func (f *fakeExpiryQuerier) SuspensionsExpiredBetween(_ context.Context, since, _ time.Time) ([]uuid.UUID, error) {
f.sinces = append(f.sinces, since)
i := f.idx
f.idx++
if i < len(f.errs) && f.errs[i] != nil {
return nil, f.errs[i]
}
if i < len(f.results) {
return f.results[i], nil
}
return nil, nil
}
func newSweeper(store suspensionExpiryQuerier, onExpire func(uuid.UUID)) *SuspensionSweeper {
return &SuspensionSweeper{
store: store,
onExpire: onExpire,
log: zap.NewNop(),
since: time.Now().Add(-time.Minute).UTC(),
}
}
func TestSuspensionSweeperDispatchesAndAdvances(t *testing.T) {
id1, id2 := uuid.New(), uuid.New()
fake := &fakeExpiryQuerier{results: [][]uuid.UUID{{id1, id2}, nil}}
var got []uuid.UUID
w := newSweeper(fake, func(id uuid.UUID) { got = append(got, id) })
first := w.since
w.sweep(context.Background())
assert.Equal(t, []uuid.UUID{id1, id2}, got, "every expired account is dispatched")
assert.True(t, w.since.After(first), "the window advances on success")
// A second sweep opens the next window at the previous upper bound.
prev := w.since
w.sweep(context.Background())
require.Len(t, fake.sinces, 2)
assert.True(t, fake.sinces[1].After(fake.sinces[0]), "consecutive windows are contiguous and forward")
assert.True(t, fake.sinces[1].Equal(prev), "the next window starts at the previous upper bound")
}
func TestSuspensionSweeperKeepsWindowOnError(t *testing.T) {
fake := &fakeExpiryQuerier{errs: []error{errors.New("db down")}}
w := newSweeper(fake, func(uuid.UUID) { t.Fatal("onExpire must not run when the query fails") })
before := w.since
w.sweep(context.Background())
assert.True(t, w.since.Equal(before), "the window is retained on error so the next tick retries it")
}
func TestNewSuspensionSweeperDefaults(t *testing.T) {
w := NewSuspensionSweeper(nil, func(uuid.UUID) {}, nil)
assert.Equal(t, time.Minute, w.Interval())
assert.NotNil(t, w.log, "a nil logger is tolerated")
assert.WithinDuration(t, time.Now().UTC(), w.since, time.Second, "the first window opens at construction time")
}
+46 -4
View File
@@ -1,5 +1,6 @@
// Package accountmerge retires a secondary account into a primary one in a single
// transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints
// transaction: it sums statistics (merging the per-variant best moves), sums the hint
// wallet, ORs the paid flag, repoints
// the secondary's identities, transfers its games/chat/complaints/invitations,
// 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
@@ -68,6 +69,9 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
if err := mergeStats(ctx, tx, primary, secondary, now); err != nil {
return err
}
if err := mergeBestMoves(ctx, tx, primary, secondary, now); err != nil {
return err
}
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
return err
}
@@ -147,8 +151,8 @@ func activeGameIDs(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) ([]uuid
return out, nil
}
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws
// summed, max points kept) and deletes the secondary row.
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws and
// the moves/hints-used counters summed, max points kept) and deletes the secondary row.
func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
var sec model.AccountStats
err := postgres.SELECT(table.AccountStats.AllColumns).
@@ -178,13 +182,16 @@ func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, n
upd := table.AccountStats.UPDATE(
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints,
table.AccountStats.Moves, table.AccountStats.HintsUsed, table.AccountStats.UpdatedAt,
).SET(
postgres.Int(int64(pri.Wins+sec.Wins)),
postgres.Int(int64(pri.Losses+sec.Losses)),
postgres.Int(int64(pri.Draws+sec.Draws)),
postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))),
postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))),
postgres.Int(int64(pri.Moves+sec.Moves)),
postgres.Int(int64(pri.HintsUsed+sec.HintsUsed)),
postgres.TimestampzT(now),
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
@@ -198,6 +205,41 @@ func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, n
return nil
}
// mergeBestMoves folds secondary's per-variant best moves into primary, keeping the
// higher-scoring play per variant (the same rule the per-game upsert uses), then deletes
// the secondary's rows — the secondary is only tombstoned, not removed, so without this
// they would linger on a dead account and never reach the merged statistics screen.
func mergeBestMoves(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
var srows []model.AccountBestMove
err := postgres.SELECT(table.AccountBestMove.AllColumns).
FROM(table.AccountBestMove).
WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary))).
QueryContext(ctx, tx, &srows)
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountmerge: load secondary best moves: %w", err)
}
for _, s := range srows {
ins := table.AccountBestMove.
INSERT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant,
table.AccountBestMove.Score, table.AccountBestMove.Tiles, table.AccountBestMove.UpdatedAt).
VALUES(primary, s.Variant, s.Score, s.Tiles, postgres.TimestampzT(now)).
ON_CONFLICT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant).
DO_UPDATE(postgres.SET(
table.AccountBestMove.Score.SET(table.AccountBestMove.EXCLUDED.Score),
table.AccountBestMove.Tiles.SET(table.AccountBestMove.EXCLUDED.Tiles),
table.AccountBestMove.UpdatedAt.SET(table.AccountBestMove.EXCLUDED.UpdatedAt),
).WHERE(table.AccountBestMove.EXCLUDED.Score.GT(table.AccountBestMove.Score)))
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: merge best move %s: %w", s.Variant, err)
}
}
del := table.AccountBestMove.DELETE().WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary)))
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: delete secondary best moves: %w", err)
}
return nil
}
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
// all other profile fields stay the primary's.
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
@@ -137,3 +137,59 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
an image attachment is previewed inline, bounded so it cannot dominate the page. */
.msgbody { white-space: pre-wrap; word-break: break-word; background: var(--bg); padding: 0.6rem 0.8rem; border-radius: 6px; margin: 0.6rem 0; }
.attach { max-width: 100%; max-height: 480px; height: auto; border: 1px solid var(--line); border-radius: 6px; }
/* Game replay (admin): a script-stepped board with rack panels around it, a move log and the
first-move draw. A placed tile shows its value as a subscript; a 0 value (a blank) shows none. */
.replay-stage {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas: ". top ." "left board right" ". bottom .";
gap: 0.5rem;
align-items: center;
justify-items: center;
margin-bottom: 0.6rem;
}
.rack-top { grid-area: top; }
.rack-bottom { grid-area: bottom; }
.rack-left { grid-area: left; }
.rack-right { grid-area: right; }
.replay-board { grid-area: board; overflow: auto; }
.board-grid {
display: grid;
grid-template-columns: 1.4rem repeat(15, 1.7rem);
grid-auto-rows: 1.7rem;
gap: 1px;
background: var(--line);
border: 1px solid var(--line);
width: max-content;
}
.board-grid .bh { display: flex; align-items: center; justify-content: center; font-size: 0.6rem; color: var(--ink-dim); background: var(--panel); }
.board-grid .cell { position: relative; display: flex; align-items: center; justify-content: center; background: var(--panel-hi); }
.cell .prem { font-size: 0.55rem; color: var(--ink); opacity: 0.8; }
.cell.tw { background: #7a2230; }
.cell.dw { background: #a8506a; }
.cell.tl { background: #235a7a; }
.cell.dl { background: #3f87a8; }
.cell.centre .prem { font-size: 0.95rem; color: var(--warn); opacity: 1; }
.tile {
display: inline-flex; align-items: baseline; justify-content: center;
min-width: 1.35rem; height: 1.35rem; padding: 0 0.12rem;
background: #e8d9a0; color: #1b1408; border-radius: 3px;
font-weight: 700; font-size: 0.8rem; line-height: 1.35rem;
}
.tile sub { font-size: 0.5rem; font-weight: 600; line-height: 1; align-self: flex-end; margin-left: 1px; }
.tile.blank { background: #cdbfe0; }
.cell.filled { background: var(--panel-hi); }
.cell.filled .tile { width: 100%; height: 100%; border-radius: 2px; }
.rack-slot { padding: 0.25rem; border-radius: 6px; }
.rack-slot.active { outline: 2px solid var(--accent); background: var(--panel-hi); }
.rack-name { font-size: 0.72rem; color: var(--ink-dim); margin-bottom: 0.2rem; text-align: center; }
.rack-tiles { display: flex; gap: 2px; flex-wrap: wrap; justify-content: center; }
.rack-left .rack-tiles, .rack-right .rack-tiles { flex-direction: column; }
.replay-controls { display: flex; align-items: center; gap: 0.8rem; justify-content: center; margin: 0.6rem 0; }
.replay-controls button { background: var(--panel-hi); color: var(--ink); border: 1px solid var(--line); font-weight: 600; }
.replay-controls button:disabled { opacity: 0.4; cursor: default; }
.replay-pos { color: var(--ink-dim); font-variant-numeric: tabular-nums; }
.replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; }
.replay-log li { color: var(--ink-dim); padding: 0.1rem 0; }
.replay-log li.cur { color: var(--ink); font-weight: 600; }
+8 -2
View File
@@ -26,6 +26,11 @@ func TestRendererRendersEveryPage(t *testing.T) {
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
Friends: []RelationRow{{AccountID: "b2", DisplayName: "Ann", Date: "2026-06-10 12:00"}},
Blocks: []RelationRow{{AccountID: "c3", DisplayName: "Bob", Date: "2026-06-11 09:00"}},
BlockedBy: []RelationRow{{AccountID: "d4", DisplayName: "Cay", Date: "2026-06-12 08:00"}},
}, `/_gm/users/c3`},
{"throttled", ThrottledView{
Episodes: []ThrottleEpisodeRow{{Class: "user", Key: "a1", UserID: "a1", Rejected: 1234, FirstSeen: "2026-06-10 12:00", LastSeen: "2026-06-10 12:05"}},
Flagged: []FlaggedAccountRow{{ID: "a1", DisplayName: "Kaya", FlaggedAt: "2026-06-10 12:05"}},
@@ -35,9 +40,10 @@ func TestRendererRendersEveryPage(t *testing.T) {
{"games", GamesView{Items: []GameRow{{ID: "g-open", Variant: "scrabble_en", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "?status=open"},
{"game_detail", GameDetailView{ID: "g1", Variant: "scrabble_en", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"},
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1", Unread: true}}, UnreadOnly: true, Pager: NewPager(1, 50, 1)}, "unread only"},
{"chatmessage", ChatMessageDetailView{ID: "m1", GameID: "g1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Kind: "message", Body: "good luck", Unread: true, Seats: []ChatSeatStatusRow{{Seat: 0, AccountID: "a1", DisplayName: "Kaya", Role: "sender"}, {Seat: 1, AccountID: "b2", DisplayName: "Opp", Role: "unread"}}}, "Read by seat"},
{"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"},
{"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", BotLanguage: "ru", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "bot: ru"},
{"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "Interface language"},
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"},
{"dictionary", DictionaryView{ActiveVersion: "v1.0.0", Variants: []VariantVersions{{Variant: "scrabble_en", Versions: []string{"v1.0.0"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Update dictionaries"},
{"dictionary_preview", DictionaryPreviewView{Version: "v1.1.0", Token: "0123456789abcdef0123456789abcdef", ActiveVersion: "v1.0.0", Variants: []VariantDiffRow{{Variant: "scrabble_en", AddedCount: 2, RemovedCount: 1, AddedSample: []string{"qi", "za"}, RemovedSample: []string{"xqz"}, RemovedTruncated: true}}}, "v1.1.0"},
@@ -5,7 +5,6 @@
{{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}}
@@ -0,0 +1,28 @@
{{define "content" -}}
{{with .Data}}
<h1>Message</h1>
<nav class="subnav"><a href="/_gm/messages">&laquo; messages</a> · <a href="/_gm/games/{{.GameID}}">game</a> · <a href="/_gm/messages?game={{.GameID}}">game messages</a></nav>
<section class="panel"><h2>Summary</h2>
<ul class="kv">
<li><b>Time</b> {{.CreatedAt}}</li>
<li><b>Sender</b> <a href="/_gm/users/{{.SenderID}}">{{.SenderName}}</a> ({{.Source}})</li>
<li><b>IP</b> {{.IP}}</li>
<li><b>Kind</b> {{.Kind}}</li>
<li><b>Read</b> {{if .Unread}}unread{{else}}read{{end}}</li>
<li><b>Message</b> {{.Body}}</li>
</ul>
</section>
<section class="panel"><h2>Read by seat</h2>
<table class="list">
<thead><tr><th>Seat</th><th>Player</th><th>Status</th></tr></thead>
<tbody>
{{range .Seats}}
<tr><td>{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AccountID}}{{end}}</a></td><td>{{if eq .Role "read"}}<span class="ok">read</span>{{else if eq .Role "unread"}}unread{{else}}sender{{end}}</td></tr>
{{else}}
<tr><td colspan="3"><span class="note">no seats</span></td></tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
{{- end}}
@@ -5,7 +5,7 @@
<section class="panel"><h2>Message</h2>
<ul class="kv">
<li><b>From</b> <a href="/_gm/users/{{.AccountID}}">{{.SenderName}}</a> ({{.Source}})</li>
<li><b>Channel</b> {{.Channel}}{{if .BotLanguage}} (bot: {{.BotLanguage}}){{end}}</li>
<li><b>Channel</b> {{.Channel}}</li>
<li><b>Interface language</b> {{.InterfaceLanguage}}</li>
<li><b>IP</b> {{if .IP}}<code>{{.IP}}</code>{{else}}<span class="note">none</span>{{end}}</li>
<li><b>Filed</b> {{.CreatedAt}}</li>
@@ -27,5 +27,87 @@
</table>
{{if .HasRobot}}<p><small>Play-to-win is decided once per game from the bag seed; robots play to win in ~{{.RobotTargetPct}}% of games.</small></p>{{end}}
</section>
{{if .SetupDraws}}
<section class="panel"><h2>First-move draw</h2>
<p class="note">Each player draws a tile; the one closest to &ldquo;A&rdquo; moves first (a blank beats every letter), ties re-drawing until a single leader remains.{{if .FirstMover}} <b>{{.FirstMover}}</b> leads.{{end}}</p>
<table class="list">
<thead><tr><th>Round</th><th>Player</th><th>Tile</th><th>Rank</th></tr></thead>
<tbody>
{{range .SetupDraws}}
<tr><td>{{.Round}}</td><td>{{if .AccountID}}<a href="/_gm/users/{{.AccountID}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</td><td>{{.Letter}}{{if .Blank}} <small>(blank)</small>{{end}}</td><td>{{.Rank}}</td></tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
{{if .HasReplay}}
<section class="panel"><h2>Replay</h2>
<div class="replay-stage">
<div class="rack-slot rack-top" data-seat="0"></div>
<div class="rack-slot rack-left" data-seat="2"></div>
<div class="replay-board" id="replay-board"></div>
<div class="rack-slot rack-right" data-seat="3"></div>
<div class="rack-slot rack-bottom" data-seat="1"></div>
</div>
<div class="replay-controls">
<button type="button" id="replay-prev">&#9664; prev</button>
<span class="replay-pos" id="replay-pos"></span>
<button type="button" id="replay-next">next &#9654;</button>
</div>
<ol class="replay-log" id="replay-log"></ol>
<script>
const REPLAY = {{.ReplayJSON}};
(function(){
if(!REPLAY||!REPLAY.steps){return;}
const N=15, COLS="ABCDEFGHIJKLMNO", PREM={tw:"3W",dw:"2W",tl:"3L",dl:"2L"};
const boardEl=document.getElementById("replay-board"), logEl=document.getElementById("replay-log");
const posEl=document.getElementById("replay-pos"), prevBtn=document.getElementById("replay-prev"), nextBtn=document.getElementById("replay-next");
let step=0;
function esc(s){const d=document.createElement("div");d.textContent=s==null?"":s;return d.innerHTML;}
function tileHTML(t){const sub=(t.v&&t.v>0)?"<sub>"+t.v+"<\/sub>":"";return "<span class=\"tile"+(t.b?" blank":"")+"\">"+esc(t.l)+sub+"<\/span>";}
function placedAt(k){const m={};for(let s=1;s<=k;s++){const mv=REPLAY.steps[s]&&REPLAY.steps[s].move;if(mv&&mv.placements){for(const p of mv.placements){m[p.r+","+p.c]=p;}}}return m;}
function renderBoard(){
const placed=placedAt(step);let h="<div class=\"board-grid\"><div class=\"bh corner\"><\/div>";
for(let c=0;c<N;c++){h+="<div class=\"bh\">"+COLS[c]+"<\/div>";}
for(let r=0;r<N;r++){h+="<div class=\"bh\">"+(r+1)+"<\/div>";
for(let c=0;c<N;c++){const p=placed[r+","+c];
if(p){h+="<div class=\"cell filled\">"+tileHTML(p)+"<\/div>";continue;}
const prem=REPLAY.premium[r][c], centre=(r===REPLAY.centre[0]&&c===REPLAY.centre[1]);
const label=centre?"&#9733;":(prem?PREM[prem]:"");
h+="<div class=\"cell "+(prem||"")+(centre?" centre":"")+"\">"+(label?"<span class=\"prem\">"+label+"<\/span>":"")+"<\/div>";}}
h+="<\/div>";boardEl.innerHTML=h;
}
function renderRacks(){
const st=REPLAY.steps[step];
document.querySelectorAll(".rack-slot").forEach(function(slot){
const seat=parseInt(slot.dataset.seat,10), info=REPLAY.seats.find(function(s){return s.seat===seat;}), rack=st.racks[seat];
if(!info||!rack){slot.style.display="none";slot.innerHTML="";return;}
slot.style.display="";slot.classList.toggle("active",st.toMove===seat);
const nm=info.accountId?"<a href=\"/_gm/users/"+info.accountId+"\">"+esc(info.name)+"<\/a>":esc(info.name||("seat "+seat));
slot.innerHTML="<div class=\"rack-name\">"+nm+" &middot; "+(st.scores[seat]||0)+"<\/div><div class=\"rack-tiles\">"+rack.map(tileHTML).join("")+"<\/div>";
});
}
function renderLog(){
let h="";
for(let s=1;s<=step;s++){const st=REPLAY.steps[s], m=st.move;if(!m){continue;}
const who=((REPLAY.seats.find(function(x){return x.seat===m.seat;})||{}).name)||("seat "+m.seat);
let desc;
if(m.action==="play"){desc="played "+((m.words&&m.words.length)?m.words.join(", "):"")+" for "+m.score;}
else if(m.action==="exchange"){desc="exchanged "+((m.exchanged&&m.exchanged.length)||0)+" tiles";}
else if(m.action==="pass"){desc="passed";}
else{desc=m.action;}
const drew=(st.drawn&&st.drawn.length)?" &middot; drew "+st.drawn.map(function(t){return t.l;}).join(""):"";
h+="<li class=\""+(s===step?"cur":"")+"\">"+esc(who)+" "+esc(desc)+drew+" &middot; bag "+st.bagLen+"<\/li>";}
logEl.innerHTML=h||"<li class=\"note\">opening position<\/li>";
}
function render(){renderBoard();renderRacks();renderLog();posEl.textContent=step+" / "+(REPLAY.steps.length-1);prevBtn.disabled=step<=0;nextBtn.disabled=step>=REPLAY.steps.length-1;}
prevBtn.onclick=function(){if(step>0){step--;render();}};
nextBtn.onclick=function(){if(step<REPLAY.steps.length-1){step++;render();}};
document.addEventListener("keydown",function(e){if(e.key==="ArrowLeft"){prevBtn.click();}else if(e.key==="ArrowRight"){nextBtn.click();}});
render();
})();
</script>
</section>
{{end}}
{{end}}
{{- end}}
@@ -6,6 +6,7 @@
{{if .UserID}}<input type="hidden" name="user" value="{{.UserID}}">{{end}}
<input name="name" value="{{.NameMask}}" placeholder="sender name mask (* ?)">
<input name="ext" value="{{.ExtMask}}" placeholder="sender external id mask (* ?)">
<label class="check"><input type="checkbox" name="unread" value="1"{{if .UnreadOnly}} checked{{end}}> unread only</label>
<button type="submit">Filter</button>
<a class="export" href="/_gm/messages.csv?{{.FilterQuery}}">Export CSV ↓</a>
</form>
@@ -13,19 +14,20 @@
<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>
<thead><tr><th>Time</th><th>Source</th><th>Sender</th><th>IP</th><th>Message</th><th>Read</th><th>Game</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td>{{.CreatedAt}}</td>
<td><a href="/_gm/messages/{{.ID}}">{{.CreatedAt}}</a></td>
<td>{{.Source}}</td>
<td><a href="/_gm/users/{{.SenderID}}">{{.SenderName}}</a></td>
<td>{{.IP}}</td>
<td>{{.Body}}</td>
<td>{{if .Unread}}unread{{else}}read{{end}}</td>
<td><a href="/_gm/games/{{.GameID}}">game</a></td>
</tr>
{{else}}
<tr><td colspan="6"><span class="note">no messages</span></td></tr>
<tr><td colspan="7"><span class="note">no messages</span></td></tr>
{{end}}
</tbody>
</table>
@@ -5,6 +5,26 @@
list is in-memory and resets on a backend restart. An account sustaining
{{.FlagThreshold}}+ rejected calls within {{.FlagWindow}} is soft-flagged for review
below — never banned automatically; clear the flag on the user card.</p>
<section class="panel"><h2>Active IP bans</h2>
<p class="note">Temporary IP bans the gateway is currently enforcing (in-memory, prod-only;
reset on a gateway restart). Unban applies on the gateway's next sync.</p>
<table class="list">
<thead><tr><th>IP</th><th>Reason</th><th>Since</th><th>Expires</th><th></th></tr></thead>
<tbody>
{{range .Bans}}
<tr>
<td><code>{{.IP}}</code></td>
<td>{{.Reason}}</td>
<td>{{.Since}}</td>
<td>{{.Expires}}</td>
<td><form class="form" method="post" action="/_gm/bans/unban"><input type="hidden" name="ip" value="{{.IP}}"><button type="submit">Unban</button></form></td>
</tr>
{{else}}
<tr><td colspan="5"><span class="note">no active bans</span></td></tr>
{{end}}
</tbody>
</table>
</section>
<section class="panel"><h2>Recent episodes</h2>
<table class="list">
<thead><tr><th>Class</th><th>Key</th><th class="num">Rejected</th><th>First seen</th><th>Last seen</th></tr></thead>
@@ -32,6 +32,8 @@
<li><b>Wins</b> {{.Stats.Wins}}</li>
<li><b>Losses</b> {{.Stats.Losses}}</li>
<li><b>Draws</b> {{.Stats.Draws}}</li>
<li><b>Moves</b> {{.Stats.Moves}}</li>
<li><b>Hints used</b> {{.Stats.HintsUsed}}</li>
<li><b>Best game</b> {{.Stats.MaxGamePoints}}</li>
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
</ul>
@@ -100,12 +102,41 @@
</tbody>
</table>
</section>
<section class="panel"><h2>Friends</h2>
<table class="list">
<thead><tr><th>Account</th><th>Friends since</th></tr></thead>
<tbody>
{{range .Friends}}
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
{{else}}<tr><td colspan="2"><span class="note">no friends</span></td></tr>{{end}}
</tbody>
</table>
</section>
<section class="panel"><h2>Blocks</h2>
<table class="list">
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
<tbody>
{{range .Blocks}}
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
{{else}}<tr><td colspan="2"><span class="note">blocks no one</span></td></tr>{{end}}
</tbody>
</table>
</section>
<section class="panel"><h2>Blocked by</h2>
<table class="list">
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
<tbody>
{{range .BlockedBy}}
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
{{else}}<tr><td colspan="2"><span class="note">blocked by no one</span></td></tr>{{end}}
</tbody>
</table>
</section>
{{if .TelegramID}}
<section class="panel"><h2>Send Telegram message</h2>
{{if .ConnectorEnabled}}
<form class="form col" method="post" action="/_gm/users/{{.ID}}/message">
<label>Message <textarea name="text" required></textarea></label>
<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}}
+82 -9
View File
@@ -94,11 +94,13 @@ type MessagesView struct {
ExtMask string
GameID string
UserID string
UnreadOnly bool
FilterQuery template.URL
}
// MessageRow is one chat message in the moderation list: its sender (linked to the user
// card), source, IP, body, game (linked to the game card) and time.
// card), source, IP, body, game (linked to the game card), time, and whether it is still
// unread by at least one recipient.
type MessageRow struct {
ID string
SenderID string
@@ -108,6 +110,32 @@ type MessageRow struct {
Body string
GameID string
CreatedAt string
Unread bool
}
// ChatMessageDetailView is one chat message with its per-seat read breakdown, for the
// console message card.
type ChatMessageDetailView struct {
ID string
GameID string
SenderID string
SenderName string
Source string
Kind string
Body string
IP string
CreatedAt string
Unread bool
Seats []ChatSeatStatusRow
}
// ChatSeatStatusRow is one seat's read status on the message card: the seat index, the
// occupant (linked to the user card) and its role ("sender", "read" or "unread").
type ChatSeatStatusRow struct {
Seat int
AccountID string
DisplayName string
Role string
}
// UserDetailView is one account with its stats, identities and recent games.
@@ -147,6 +175,21 @@ type UserDetailView struct {
// grant form offers. The first role is the feedback ban (see internal/account).
Roles []string
KnownRoles []string
// Blocks, BlockedBy and Friends are the social graph on the card: who this account has
// blocked, who currently blocks it, and its mutual friendships — each cross-linked to the
// other account with the date it happened. They are the full truth; the asymmetric block
// suppression that hides relationships from players never applies to the console.
Blocks []RelationRow
BlockedBy []RelationRow
Friends []RelationRow
}
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
// lists: the other account's id (the link target), its display name, and the pre-formatted date.
type RelationRow struct {
AccountID string
DisplayName string
Date string
}
// SuspensionView is an account's current manual-block state shown on the user card: whether it
@@ -175,6 +218,8 @@ type StatsRow struct {
Draws int
MaxGamePoints int
MaxWordPoints int
Moves int
HintsUsed int
}
// IdentityRow is one platform/email identity of an account.
@@ -223,6 +268,27 @@ type GameDetailView struct {
// RobotTargetPct is the configured global play-to-win rate, in percent.
HasRobot bool
RobotTargetPct int
// ReplayJSON is the game-replay payload (board, seats, per-step racks/scores/bag) the
// game_detail page feeds to its vanilla-JS stepper; HasReplay gates the replay section.
ReplayJSON template.JS
HasReplay bool
// SetupDraws is the first-move draw — one row per tile drawn (docs/ARCHITECTURE.md §6) —
// and FirstMover is the resolved name of the seat-0 player the draw elected.
SetupDraws []SetupDrawRow
FirstMover string
}
// SetupDrawRow is one tile drawn in the first-move seeding (docs/ARCHITECTURE.md §6): the
// round, the player (Name/AccountID, or "(opponent)" with an empty AccountID for an
// auto-match synthetic draw not yet back-filled), the drawn letter (upper-cased; "?" for a
// blank) and its draw rank.
type SetupDrawRow struct {
Round int
Name string
AccountID string
Letter string
Blank bool
Rank int
}
// SeatRow is one seat of a game. For a robot seat (IsRobot) RobotIntent is the game's
@@ -323,17 +389,27 @@ 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.
// ThrottledView is the rate-limit observability page: the temporary IP bans the
// gateway is currently enforcing, the recent gateway-reported throttle episodes
// (in-memory, reset on restart) and the accounts currently carrying the high-rate
// flag. FlagThreshold and FlagWindow caption the active auto-flag tuning.
type ThrottledView struct {
Bans []BanRow
Episodes []ThrottleEpisodeRow
Flagged []FlaggedAccountRow
FlagThreshold int
FlagWindow string
}
// BanRow is one temporary IP ban the gateway is enforcing, with its reason and its
// since/expiry timestamps; the row carries an unban action.
type BanRow struct {
IP string
Reason string
Since string
Expires string
}
// ThrottleEpisodeRow is one recently throttled limiter key. UserID links to the
// user card and is set only for the user class (the other classes key by IP).
type ThrottleEpisodeRow struct {
@@ -465,11 +541,8 @@ type FeedbackDetailView struct {
SenderName string
Source string
Channel string
// InterfaceLanguage is the sender's interface language (account preference);
// BotLanguage is the connector bot they last used (en/ru), set only for a
// message that arrived through an external connector (Telegram).
// InterfaceLanguage is the sender's interface language (account preference).
InterfaceLanguage string
BotLanguage string
IP string
Body string
HasAttachment bool
+92
View File
@@ -0,0 +1,92 @@
// Package banview mirrors the gateway's active IP bans for the admin console and
// collects operator unban requests for the gateway to apply. Like ratewatch it is
// in-memory, single-instance and resets on a backend restart by design — the
// gateway re-reports its active set on the next sync, and the durable effect (the
// ban itself) lives in the gateway, not here.
package banview
import (
"sort"
"sync"
"time"
)
// Ban is one active IP ban as reported by the gateway.
type Ban struct {
IP string
Reason string
Since time.Time
Expires time.Time
}
// View holds the last-reported active bans and the operator's pending unbans.
type View struct {
now func() time.Time
mu sync.Mutex
bans map[string]Ban // last reported active set, keyed by IP
unban map[string]struct{} // IPs an operator marked for unban
}
// New constructs an empty View.
func New() *View {
return &View{now: time.Now, bans: make(map[string]Ban), unban: make(map[string]struct{})}
}
// Ingest replaces the mirrored active set with the gateway's latest report,
// skipping entries with an empty IP or one that has already expired.
func (v *View) Ingest(active []Ban) {
now := v.now()
v.mu.Lock()
defer v.mu.Unlock()
v.bans = make(map[string]Ban, len(active))
for _, b := range active {
if b.IP == "" || !now.Before(b.Expires) {
continue
}
v.bans[b.IP] = b
}
}
// Recent returns the mirrored active bans, most recently banned first.
func (v *View) Recent() []Ban {
now := v.now()
v.mu.Lock()
defer v.mu.Unlock()
out := make([]Ban, 0, len(v.bans))
for _, b := range v.bans {
if now.Before(b.Expires) {
out = append(out, b)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Since.After(out[j].Since) })
return out
}
// RequestUnban records an operator request to lift the ban on ip; the gateway
// applies it on its next sync (so the console reflects it within the sync
// interval). An empty ip is ignored.
func (v *View) RequestUnban(ip string) {
if ip == "" {
return
}
v.mu.Lock()
defer v.mu.Unlock()
v.unban[ip] = struct{}{}
}
// DrainUnbans returns and clears the IPs operators have marked for unban since the
// previous drain. It returns nil when there are none.
func (v *View) DrainUnbans() []string {
v.mu.Lock()
defer v.mu.Unlock()
if len(v.unban) == 0 {
return nil
}
out := make([]string, 0, len(v.unban))
for ip := range v.unban {
out = append(out, ip)
}
clear(v.unban)
return out
}
+64
View File
@@ -0,0 +1,64 @@
package banview
import (
"testing"
"time"
)
func viewAt(clk *time.Time) *View {
v := New()
v.now = func() time.Time { return *clk }
return v
}
func TestIngestRecentDropsExpired(t *testing.T) {
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
v := viewAt(&clk)
v.Ingest([]Ban{
{IP: "1.1.1.1", Reason: "tripwire", Since: clk, Expires: clk.Add(time.Hour)},
{IP: "2.2.2.2", Reason: "rejections", Since: clk.Add(-2 * time.Hour), Expires: clk.Add(-time.Hour)}, // expired
{IP: "", Reason: "x", Since: clk, Expires: clk.Add(time.Hour)}, // empty IP
})
got := v.Recent()
if len(got) != 1 || got[0].IP != "1.1.1.1" || got[0].Reason != "tripwire" {
t.Fatalf("Recent = %+v, want one live ban for 1.1.1.1", got)
}
}
func TestIngestReplaces(t *testing.T) {
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
v := viewAt(&clk)
v.Ingest([]Ban{{IP: "1.1.1.1", Since: clk, Expires: clk.Add(time.Hour)}})
v.Ingest([]Ban{{IP: "2.2.2.2", Since: clk, Expires: clk.Add(time.Hour)}})
got := v.Recent()
if len(got) != 1 || got[0].IP != "2.2.2.2" {
t.Fatalf("Recent = %+v, want only the latest report (2.2.2.2)", got)
}
}
func TestRecentOrdersBySince(t *testing.T) {
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
v := viewAt(&clk)
v.Ingest([]Ban{
{IP: "old", Since: clk.Add(-10 * time.Minute), Expires: clk.Add(time.Hour)},
{IP: "new", Since: clk.Add(-1 * time.Minute), Expires: clk.Add(time.Hour)},
})
got := v.Recent()
if len(got) != 2 || got[0].IP != "new" || got[1].IP != "old" {
t.Fatalf("Recent order = %+v, want most recent first", got)
}
}
func TestUnbanRoundTrip(t *testing.T) {
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
v := viewAt(&clk)
v.RequestUnban("3.3.3.3")
v.RequestUnban("") // ignored
drained := v.DrainUnbans()
if len(drained) != 1 || drained[0] != "3.3.3.3" {
t.Fatalf("DrainUnbans = %v, want [3.3.3.3]", drained)
}
if again := v.DrainUnbans(); again != nil {
t.Fatalf("second DrainUnbans = %v, want nil (cleared)", again)
}
}
+16 -18
View File
@@ -1,11 +1,10 @@
// 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 is the backend's gRPC client for operator broadcasts: a direct
// message to one user, or a post to the game channel. It calls the gateway's
// bot-link relay (which forwards the send to the remote bot over the reverse mTLS
// link and reports back whether it was delivered). The relay lives on the trusted
// internal network, so the connection uses insecure (plaintext) transport
// credentials (docs/ARCHITECTURE.md §12). It speaks the Telegram service contract,
// narrowed to the two broadcast methods the admin surface needs.
package connector
import (
@@ -37,22 +36,21 @@ func New(addr string) (*Client, error) {
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})
// platform external_id, through the bot. delivered reports whether the connector
// actually sent it (false when the user has not started the bot).
func (c *Client) SendToUser(ctx context.Context, externalID, text string) (bool, error) {
resp, err := c.c.SendToUser(ctx, &telegramv1.SendToUserRequest{ExternalId: externalID, Text: text})
if err != nil {
return false, err
}
return resp.GetDelivered(), nil
}
// SendToGameChannel posts an operator text message to the 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})
// SendToGameChannel posts an operator text message to the bot's game channel.
// delivered reports whether the connector sent it (false when the bot has no
// channel configured).
func (c *Client) SendToGameChannel(ctx context.Context, text string) (bool, error) {
resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text})
if err != nil {
return false, err
}
+17 -7
View File
@@ -70,11 +70,21 @@ func Open(dir, version string, variants ...Variant) (*Registry, error) {
// 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.
// at runtime is resident again after a restart. The flat dir's version is resolved
// from its .seed_version marker (see resolveSeedVersion): a fresh dir records
// bootVersion, an already-seeded dir keeps its recorded label and ignores bootVersion,
// so a bumped build seed never relabels live bytes. A subdirectory named like the
// resolved seed version is skipped (the flat dir already is it). A partially loaded
// registry is closed before any error is returned.
func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
r, err := Open(dir, bootVersion)
// Resolve the flat dir's version from its seed marker first: on an already-seeded
// volume the marker wins and bootVersion is ignored, so a bumped build seed cannot
// relabel live bytes (see resolveSeedVersion).
seed, err := resolveSeedVersion(dir, bootVersion)
if err != nil {
return nil, err
}
r, err := Open(dir, seed)
if err != nil {
return nil, err
}
@@ -84,9 +94,9 @@ func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err)
}
for _, e := range entries {
// Skip non-directories, the boot version (already loaded as the flat dir)
// and dot-prefixed directories (the upload staging area, dir/.staging/).
if !e.IsDir() || e.Name() == bootVersion || strings.HasPrefix(e.Name(), ".") {
// Skip non-directories, the resolved seed version (already loaded as the flat
// dir) and dot-prefixed directories (the upload staging area, dir/.staging/).
if !e.IsDir() || e.Name() == seed || strings.HasPrefix(e.Name(), ".") {
continue
}
if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); err != nil {
+98
View File
@@ -5,6 +5,7 @@ import (
"io"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -112,6 +113,103 @@ func TestOpenWithVersionsSkipsDotDirs(t *testing.T) {
}
}
// TestOpenWithVersionsRecordsSeedMarker verifies the first boot records the seed
// version in the flat dir's marker, the marker is not mistaken for a version, and a
// reboot at the same seed version succeeds.
func TestOpenWithVersionsRecordsSeedMarker(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() {
copyDawg(t, testDictDir(), dir, v)
}
reg, err := OpenWithVersions(dir, "v1")
if err != nil {
t.Fatalf("first open: %v", err)
}
if got := reg.Versions(VariantEnglish); len(got) != 1 || got[0] != "v1" {
t.Errorf("versions = %v, want only [v1] (marker not a version)", got)
}
_ = reg.Close()
data, err := os.ReadFile(filepath.Join(dir, seedMarkerFile))
if err != nil {
t.Fatalf("read seed marker: %v", err)
}
if got := strings.TrimSpace(string(data)); got != "v1" {
t.Fatalf("seed marker = %q, want v1", got)
}
reg2, err := OpenWithVersions(dir, "v1")
if err != nil {
t.Fatalf("reboot at same seed: %v", err)
}
_ = reg2.Close()
}
// TestOpenWithVersionsMarkerWinsOverBoot verifies the recorded .seed_version marker
// is authoritative: once a directory is seeded, a different bootVersion
// (BACKEND_DICT_VERSION) is ignored — the flat dir keeps its recorded label — so a
// bumped build seed on a live volume cannot relabel the already-seeded bytes.
func TestOpenWithVersionsMarkerWinsOverBoot(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() {
copyDawg(t, testDictDir(), dir, v)
}
reg, err := OpenWithVersions(dir, "v1") // seeds the marker = v1
if err != nil {
t.Fatalf("seed open: %v", err)
}
_ = reg.Close()
// Reboot with a bumped boot version: the marker (v1) wins, no error, v2 ignored.
reg2, err := OpenWithVersions(dir, "v2")
if err != nil {
t.Fatalf("reboot with bumped boot version: %v", err)
}
defer func() { _ = reg2.Close() }()
if got := reg2.Versions(VariantEnglish); len(got) != 1 || got[0] != "v1" {
t.Errorf("versions = %v, want [v1] (marker wins, v2 ignored)", got)
}
if _, err := reg2.Solver(VariantEnglish, "v2"); !errors.Is(err, ErrUnknownVersion) {
t.Errorf("v2 must not be resident: got %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, seedMarkerFile))
if got := strings.TrimSpace(string(data)); got != "v1" {
t.Errorf("marker = %q, want v1 (unchanged)", got)
}
}
// TestOpenWithVersionsBumpedBootKeepsSubdir mirrors the live-contour case: a volume
// seeded as v1 with a v2 subdirectory (uploaded via the console), booted with a bumped
// build seed bootVersion=v2. The marker (v1) wins for the flat dir, and the v2
// subdirectory is still loaded — not skipped as "the boot version" — so both versions
// stay resident. (Skipping it would silently leave only the flat v1 bytes under v2.)
func TestOpenWithVersionsBumpedBootKeepsSubdir(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() {
copyDawg(t, testDictDir(), dir, v)
}
reg0, err := OpenWithVersions(dir, "v1") // seed marker = v1
if err != nil {
t.Fatalf("seed: %v", err)
}
_ = reg0.Close()
copyDawg(t, testDictDir(), filepath.Join(dir, "v2"), VariantEnglish) // console upload
reg, err := OpenWithVersions(dir, "v2") // bumped build seed
if err != nil {
t.Fatalf("boot v2: %v", err)
}
defer func() { _ = reg.Close() }()
if _, err := reg.Solver(VariantEnglish, "v1"); err != nil {
t.Errorf("flat v1 must stay resident: %v", err)
}
if _, err := reg.Solver(VariantEnglish, "v2"); err != nil {
t.Errorf("v2 subdir must be resident (not skipped): %v", err)
}
}
// TestReloadRegistersNewVersion verifies Load adds a second version to a variant
// already resident, moves the latest pointer and keeps the earlier version.
func TestReloadRegistersNewVersion(t *testing.T) {
+53
View File
@@ -0,0 +1,53 @@
package engine
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// seedMarkerFile names the file, in the flat dictionary directory, that records the
// version the directory was first seeded as. It is dot-prefixed so OpenWithVersions'
// version scan skips it (like the .staging upload area).
const seedMarkerFile = ".seed_version"
// resolveSeedVersion returns the version label the flat dictionary directory is
// addressed by, recording it on first use.
//
// The contour's dictionary lives on a named volume seeded from the image once and
// never re-seeded (deploy/docker-compose.yml). The flat DAWGs carry no embedded
// version, so the version a volume was first seeded as is recorded in a
// .seed_version marker and is **authoritative** from then on:
//
// - fresh directory (no marker): record bootVersion (the build's
// BACKEND_DICT_VERSION) and return it — the seed of a fresh volume;
// - already-seeded directory: return the recorded marker and ignore bootVersion.
//
// So bumping the build seed on a live volume is a harmless no-op (it only takes
// effect on a future fresh volume) instead of relabelling the already-seeded bytes —
// which would void games pinned to the prior label and mis-serve new ones. New games
// still pin the active version (DB-persisted, set by the admin console), which is the
// real way a running contour moves to a new release.
//
// A directory that cannot be written makes the first record fail; that also breaks
// the admin console (which writes version subdirectories here), so the error is
// returned rather than swallowed, matching the package's fail-loud dictionary setup.
func resolveSeedVersion(dir, bootVersion string) (string, error) {
path := filepath.Join(dir, seedMarkerFile)
data, err := os.ReadFile(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("engine: read dictionary seed marker %s: %w", path, err)
}
if err == nil {
if recorded := strings.TrimSpace(string(data)); recorded != "" {
return recorded, nil
}
// An empty/corrupt marker falls through and is rewritten from bootVersion.
}
if werr := os.WriteFile(path, []byte(bootVersion+"\n"), 0o644); werr != nil {
return "", fmt.Errorf("engine: record dictionary seed marker %s: %w", path, werr)
}
return bootVersion, nil
}
+52
View File
@@ -0,0 +1,52 @@
package engine
import "fmt"
// SetupTile is one tile of a variant's full bag, decoded for the first-move draw
// (docs/ARCHITECTURE.md §6): its concrete letter (or the blank marker), a blank
// flag, and its draw rank. Lower rank wins the draw — a blank ranks above every
// letter, and letters rank by alphabet index, so the tile closest to the start of
// the alphabet ("A") wins. It is dictionary-independent, built from the variant's
// solver ruleset alone.
type SetupTile struct {
// Letter is the concrete character (the case the solver ruleset emits), or
// the blank marker "?" for a blank.
Letter string
// Blank reports whether the tile is a blank.
Blank bool
// Rank orders the draw: BlankRank for a blank (best), else the letter's
// alphabet index (0 = closest to "A").
Rank int
}
// BlankRank is the first-move draw rank of a blank: below every letter index, so a
// blank always beats a lettered tile, matching the official rule that a blank
// supersedes all letters.
const BlankRank = -1
// SetupBag returns variant's full tile bag — every lettered tile expanded by its
// count, plus one entry per blank — decoded for the first-move seeding draw. The
// order is deterministic (alphabet order, blanks last); callers shuffle it with
// their own entropy. It needs no dictionary, so it is built from the variant's
// ruleset alone and reports ErrUnknownVariant for an unrecognised variant.
func SetupBag(v Variant) ([]SetupTile, error) {
rs, ok := v.ruleset()
if !ok {
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
}
bag := make([]SetupTile, 0, 128)
for i, n := range rs.Counts {
ch, err := rs.Alphabet.Character(byte(i))
if err != nil {
// An offered variant's alphabet never yields a bad index; skip defensively.
continue
}
for range n {
bag = append(bag, SetupTile{Letter: ch, Rank: i})
}
}
for range rs.Blanks {
bag = append(bag, SetupTile{Letter: blankLetter, Blank: true, Rank: BlankRank})
}
return bag, nil
}
+47
View File
@@ -0,0 +1,47 @@
package engine
import (
"errors"
"testing"
)
func TestSetupBagEnglish(t *testing.T) {
bag, err := SetupBag(VariantEnglish)
if err != nil {
t.Fatalf("SetupBag: %v", err)
}
// English Scrabble: 98 lettered tiles + 2 blanks = 100.
if len(bag) != 100 {
t.Fatalf("bag size = %d, want 100", len(bag))
}
blanks, aCount := 0, 0
for _, tl := range bag {
switch {
case tl.Blank:
blanks++
if tl.Rank != BlankRank {
t.Errorf("blank rank = %d, want %d", tl.Rank, BlankRank)
}
if tl.Letter != blankLetter {
t.Errorf("blank letter = %q, want %q", tl.Letter, blankLetter)
}
case tl.Letter == "a":
aCount++
if tl.Rank != 0 {
t.Errorf("'a' rank = %d, want 0 (closest to A)", tl.Rank)
}
}
}
if blanks != 2 {
t.Errorf("blanks = %d, want 2", blanks)
}
if aCount != 9 {
t.Errorf("'a' count = %d, want 9", aCount)
}
}
func TestSetupBagUnknownVariant(t *testing.T) {
if _, err := SetupBag(Variant(99)); !errors.Is(err, ErrUnknownVariant) {
t.Fatalf("err = %v, want ErrUnknownVariant", err)
}
}
+3 -8
View File
@@ -112,14 +112,9 @@ func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string
attachmentName = "" // a name without bytes carries no attachment
}
ch := normalizeChannel(channel)
// Snapshot the languages at submit time (acc is already loaded for the guest check):
// the sender's interface language, and the connector bot language when the message
// came through an external connector (currently Telegram).
var channelLang string
if ch == "telegram" {
channelLang = acc.ServiceLanguage
}
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, channelLang, parseIP(senderIP))
// Snapshot the sender's interface language at submit time (acc is already loaded
// for the guest check) so the operator later sees the state as it was.
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, parseIP(senderIP))
return err
}
+10 -13
View File
@@ -34,11 +34,10 @@ func NewStore(db *sql.DB) *Store {
// Insert stores one feedback message from accountID and returns its id. attachment
// is the raw file bytes (nil for none); attachmentName, ip and a non-default
// channel are stored as given. lang (the sender's interface language) and channelLang
// (the connector bot language, empty for a non-connector channel) are snapshots taken
// now, so the operator later sees the state at submit time. created_at defaults to
// now() in the database.
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang, channelLang string, ip *string) (uuid.UUID, error) {
// channel are stored as given. lang (the sender's interface language) is a snapshot
// taken now, so the operator later sees the state at submit time. created_at defaults
// to now() in the database.
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang string, ip *string) (uuid.UUID, error) {
id, err := uuid.NewV7()
if err != nil {
return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err)
@@ -49,9 +48,9 @@ func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, at
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO backend.feedback_messages
(message_id, account_id, body, attachment, attachment_name, channel, lang, channel_lang, sender_ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(channelLang), ip); err != nil {
(message_id, account_id, body, attachment, attachment_name, channel, lang, sender_ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), ip); err != nil {
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
}
return id, nil
@@ -228,10 +227,8 @@ type AdminMessage struct {
Source string
Body string
Channel string
// Lang is the sender's interface language and ChannelLang the connector bot language
// (en/ru, empty for a non-connector channel) — both snapshotted at submit time.
// Lang is the sender's interface language, snapshotted at submit time.
Lang string
ChannelLang string
SenderIP string
HasAttachment bool
AttachmentName string
@@ -346,7 +343,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
var m AdminMessage
var repliedAt sql.NullTime
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel,
COALESCE(m.lang, ''), COALESCE(m.channel_lang, ''),
COALESCE(m.lang, ''),
COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''),
(m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL),
COALESCE(m.reply_body, ''), m.replied_at, m.created_at
@@ -355,7 +352,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
WHERE m.message_id = $1`
err := s.db.QueryRowContext(ctx, q, id).Scan(
&m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel,
&m.Lang, &m.ChannelLang,
&m.Lang,
&m.SenderIP, &m.HasAttachment, &m.AttachmentName,
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
+12 -8
View File
@@ -63,6 +63,7 @@ type gameCache struct {
type cachedGame struct {
game *engine.Game
seats []Seat
variant string
lastAccess time.Time
}
@@ -71,24 +72,27 @@ func newGameCache(ttl time.Duration, now func() time.Time) *gameCache {
return &gameCache{entries: make(map[uuid.UUID]*cachedGame), ttl: ttl, now: now}
}
// get returns the live game for id and refreshes its idle timer, or (nil, false).
func (c *gameCache) get(id uuid.UUID) (*engine.Game, bool) {
// get returns the live game and its immutable seat list for id and refreshes its idle
// timer, or (nil, nil, false). The seats let a read check membership (and label seats)
// without re-loading the game from the store, since seats never change after a game starts.
func (c *gameCache) get(id uuid.UUID) (*engine.Game, []Seat, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[id]
if !ok {
return nil, false
return nil, nil, false
}
e.lastAccess = c.now()
return e.game, true
return e.game, e.seats, true
}
// put stores g as the live game for id. variant labels the entry so the active-
// games gauge can report counts by variant without inspecting engine internals.
func (c *gameCache) put(id uuid.UUID, g *engine.Game, variant string) {
// put stores g as the live game for id together with its seat list. variant labels the
// entry so the active-games gauge can report counts by variant without inspecting engine
// internals; seats are the game's immutable seat standings for the membership fast path.
func (c *gameCache) put(id uuid.UUID, g *engine.Game, variant string, seats []Seat) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[id] = &cachedGame{game: g, variant: variant, lastAccess: c.now()}
c.entries[id] = &cachedGame{game: g, seats: seats, variant: variant, lastAccess: c.now()}
}
// remove drops id from the cache (used on a finished game and after a failed
-7
View File
@@ -68,10 +68,6 @@ func TestEmitMoveNotifiesActor(t *testing.T) {
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,
@@ -106,7 +102,4 @@ func TestEmitMoveAnnouncesGameOver(t *testing.T) {
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)
}
}
+1
View File
@@ -74,6 +74,7 @@ func playerState(v StateView, names []string, includeAlphabet bool) (notify.Play
Rack: rack,
BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining,
WalletBalance: v.WalletBalance,
}
if includeAlphabet {
tab, err := engine.AlphabetTable(v.Game.Variant)
+3 -3
View File
@@ -94,8 +94,8 @@ 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, "scrabble_en")
if _, ok := cache.get(id); !ok {
cache.put(id, nil, "scrabble_en", nil)
if _, _, ok := cache.get(id); !ok {
t.Fatal("game must be resident after put")
}
cur = cur.Add(30 * time.Minute)
@@ -104,7 +104,7 @@ func TestGameCacheEviction(t *testing.T) {
if n := cache.sweep(); n != 1 {
t.Errorf("sweep evicted %d, want 1", n)
}
if _, ok := cache.get(id); ok {
if _, _, ok := cache.get(id); ok {
t.Error("game must be evicted after idle TTL")
}
if cache.size() != 0 {
+144
View File
@@ -0,0 +1,144 @@
package game
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
// ReplayStep is one step of an admin game replay: the move that produced it (nil for the
// initial dealt state, step 0) and the resulting position — every seat's rack, the running
// scores, whose turn it is and the bag remainder. Step k's board is the union of every
// play's placements through step k, which the renderer accumulates onto an empty grid
// (docs/ARCHITECTURE.md §9.1 visual replay).
type ReplayStep struct {
// Move is the journalled move that produced this state, or nil for the initial deal.
Move *HistoryMove
// Drawn lists the tiles the mover drew from the bag after this move ("?" for a blank);
// empty for the initial deal, a pass or a resignation.
Drawn []string
// Racks holds every seat's rack at this step, indexed by seat ("?" for a blank).
Racks [][]string
// Scores holds every seat's running score, indexed by seat.
Scores []int
// ToMove is the seat to move at this step.
ToMove int
// BagLen is the number of tiles left in the bag at this step.
BagLen int
}
// ReplayTimelineView is the admin replay of a game: the persisted game plus the ordered
// replay steps (the initial deal followed by one step per journalled move).
type ReplayTimelineView struct {
Game Game
Steps []ReplayStep
}
// ReplayTimeline rebuilds a game from its pinned seed and journal and returns the ordered
// replay steps for the admin console: the initial deal (step 0) then one step per
// journalled move, each carrying the resulting racks, scores, turn cursor, bag size and the
// tiles the mover drew. The deterministic bag makes the reconstruction exact. It needs no
// dictionary beyond the engine the seed deals, and — like the live replay — stops early if a
// committed move became illegal under tightened rules rather than failing.
func (svc *Service) ReplayTimeline(ctx context.Context, gameID uuid.UUID) (ReplayTimelineView, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return ReplayTimelineView{}, err
}
seed, err := svc.store.GameSeed(ctx, gameID)
if err != nil {
return ReplayTimelineView{}, err
}
g, err := engine.New(svc.registry, engine.Options{
Variant: pre.Variant,
Version: pre.DictVersion,
Players: pre.Players,
Seed: seed,
DropoutTiles: pre.DropoutTiles,
MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
})
if err != nil {
return ReplayTimelineView{}, err
}
moves, err := svc.store.GetJournal(ctx, gameID)
if err != nil {
return ReplayTimelineView{}, err
}
steps := make([]ReplayStep, 0, len(moves)+1)
steps = append(steps, snapshotStep(g, nil, nil))
for i := range moves {
mv := moves[i]
before := g.Hand(mv.Seat)
if err := replayMove(g, mv); err != nil {
if errors.Is(err, engine.ErrIllegalPlay) {
g.Abort()
break
}
return ReplayTimelineView{}, fmt.Errorf("game: replay-timeline %s move %d: %w", gameID, mv.Seq, err)
}
moveCopy := mv
steps = append(steps, snapshotStep(g, &moveCopy, drawnTiles(before, g.Hand(mv.Seat), usedTiles(mv))))
}
return ReplayTimelineView{Game: pre, Steps: steps}, nil
}
// snapshotStep captures the position after applying move (nil for the initial deal): every
// seat's rack and score, the turn cursor and the bag size, with the supplied drawn tiles.
func snapshotStep(g *engine.Game, move *HistoryMove, drawn []string) ReplayStep {
n := g.Players()
racks := make([][]string, n)
scores := make([]int, n)
for i := 0; i < n; i++ {
racks[i] = g.Hand(i)
scores[i] = g.Score(i)
}
return ReplayStep{Move: move, Drawn: drawn, Racks: racks, Scores: scores, ToMove: g.ToMove(), BagLen: g.BagLen()}
}
// usedTiles returns the rack tiles a move consumed ("?" for a blank): the placed tiles of a
// play or the swapped tiles of an exchange; a pass or resignation consumes none.
func usedTiles(mv HistoryMove) []string {
switch mv.Action {
case "play":
used := make([]string, len(mv.Tiles))
for i, t := range mv.Tiles {
if t.Blank {
used[i] = "?" // a placed blank leaves the rack as the blank marker
} else {
used[i] = t.Letter
}
}
return used
case "exchange":
return mv.Exchanged
}
return nil
}
// drawnTiles returns the tiles the mover drew from the bag: the post-move rack (after) minus
// the tiles kept (before minus used). It compares the racks as multisets, so duplicate
// letters are counted correctly.
func drawnTiles(before, after, used []string) []string {
kept := make(map[string]int, len(before))
for _, t := range before {
kept[t]++
}
for _, t := range used {
if kept[t] > 0 {
kept[t]--
}
}
var drawn []string
for _, t := range after {
if kept[t] > 0 {
kept[t]--
continue
}
drawn = append(drawn, t)
}
return drawn
}
@@ -0,0 +1,50 @@
package game
import (
"reflect"
"testing"
"scrabble/backend/internal/engine"
)
func TestUsedTiles(t *testing.T) {
tests := []struct {
name string
mv HistoryMove
want []string
}{
{"pass", HistoryMove{Action: "pass"}, nil},
{"resign", HistoryMove{Action: "resign"}, nil},
{"play with blank", HistoryMove{Action: "play", Tiles: []engine.TileRecord{{Letter: "a"}, {Letter: "b", Blank: true}}}, []string{"a", "?"}},
{"exchange", HistoryMove{Action: "exchange", Exchanged: []string{"a", "?"}}, []string{"a", "?"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := usedTiles(tt.mv); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("usedTiles = %v, want %v", got, tt.want)
}
})
}
}
func TestDrawnTiles(t *testing.T) {
tests := []struct {
name string
before, used []string
after []string
want []string
}{
{"play refill", []string{"a", "b", "c", "d"}, []string{"a", "b"}, []string{"c", "d", "e", "f"}, []string{"e", "f"}},
{"blank played", []string{"?", "a"}, []string{"?"}, []string{"a", "x"}, []string{"x"}},
{"pass keeps rack", []string{"a", "b"}, nil, []string{"a", "b"}, nil},
{"duplicate letters", []string{"e", "e", "e"}, []string{"e"}, []string{"e", "e", "q"}, []string{"q"}},
{"empty bag no refill", []string{"a", "b"}, []string{"a"}, []string{"b"}, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := drawnTiles(tt.before, tt.after, tt.used); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("drawnTiles = %v, want %v", got, tt.want)
}
})
}
}
+156
View File
@@ -0,0 +1,156 @@
package game
import (
"crypto/rand"
"fmt"
"math/big"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
// maxSeedingRounds caps the first-move draw's tie re-draws. With a real bag a tie
// breaks with positive probability each round, so this is reached only by a
// degenerate (e.g. test) entropy source that ties forever; the cap turns that into
// an error instead of an infinite loop.
const maxSeedingRounds = 1000
// SetupDraw is one recorded tile draw of the first-move seeding — one row of
// game_setup_draws (docs/ARCHITECTURE.md §6, §9): the round, the draw order within
// it, the seated account that drew, and the decoded tile with its rank. It is
// dictionary-independent: the letter, the blank flag and the numeric rank describe
// the draw without any alphabet table.
type SetupDraw struct {
Round int
PickNo int
Account uuid.UUID
Letter string
Blank bool
Rank int
}
// seedingResult is the outcome of the first-move seeding: the winning account, the
// seated accounts rotated so the winner leads (seat 0), and the full draw log to
// persist.
type seedingResult struct {
winner uuid.UUID
order []uuid.UUID
draws []SetupDraw
}
// drawIntn returns a uniformly random integer in [0, n) for n > 0. It is the
// entropy seam of the first-move seeding: production uses crypto/rand (cryptoIntn),
// so every draw is honestly random with no single seed; tests inject a
// deterministic source.
type drawIntn func(n int) (int, error)
// cryptoIntn draws a uniform integer in [0, n) from crypto/rand — the honest,
// seedless entropy the first-move draw requires.
func cryptoIntn(n int) (int, error) {
v, err := rand.Int(rand.Reader, big.NewInt(int64(n)))
if err != nil {
return 0, fmt.Errorf("game: first-move draw entropy: %w", err)
}
return int(v.Int64()), nil
}
// seedFirstMove runs the official first-move draw over accounts for variant v,
// drawing tiles with the entropy source intn. Each round every contender draws one
// tile (without replacement) from a fresh full bag; the tile closest to "A" wins, a
// blank beating every letter; contenders tied for the best tile re-draw in the next
// round until a single leader remains. It returns the leader, the rotation that
// seats the leader first (preserving the others' seating order), and every draw for
// the record. It performs no I/O beyond calling intn.
func seedFirstMove(v engine.Variant, accounts []uuid.UUID, intn drawIntn) (seedingResult, error) {
if len(accounts) < 2 {
return seedingResult{}, fmt.Errorf("game: first-move seeding needs at least 2 accounts, got %d", len(accounts))
}
full, err := engine.SetupBag(v)
if err != nil {
return seedingResult{}, err
}
contenders := append([]uuid.UUID(nil), accounts...)
var draws []SetupDraw
for round := 1; ; round++ {
if round > maxSeedingRounds {
return seedingResult{}, fmt.Errorf("game: first-move seeding unresolved after %d rounds", maxSeedingRounds)
}
bag := append([]engine.SetupTile(nil), full...)
picks := make([]engine.SetupTile, len(contenders))
for i, acc := range contenders {
tile, rest, err := drawSetupTile(bag, intn)
if err != nil {
return seedingResult{}, err
}
bag = rest
picks[i] = tile
draws = append(draws, SetupDraw{
Round: round, PickNo: i, Account: acc,
Letter: tile.Letter, Blank: tile.Blank, Rank: tile.Rank,
})
}
winners := bestContenders(contenders, picks)
if len(winners) == 1 {
return seedingResult{winner: winners[0], order: rotateToFirst(accounts, winners[0]), draws: draws}, nil
}
contenders = winners
}
}
// drawSetupTile removes one uniformly random tile from bag using the entropy source
// intn and returns it with the shrunk bag (a fresh slice, leaving bag untouched).
// It is the per-tile draw primitive — the seam a future manual "player N draws a
// tile" tournament API will drive, one call per external request.
func drawSetupTile(bag []engine.SetupTile, intn drawIntn) (engine.SetupTile, []engine.SetupTile, error) {
if len(bag) == 0 {
return engine.SetupTile{}, nil, fmt.Errorf("game: first-move draw from an empty bag")
}
i, err := intn(len(bag))
if err != nil {
return engine.SetupTile{}, nil, err
}
if i < 0 || i >= len(bag) {
return engine.SetupTile{}, nil, fmt.Errorf("game: first-move draw index %d out of range %d", i, len(bag))
}
tile := bag[i]
rest := make([]engine.SetupTile, 0, len(bag)-1)
rest = append(rest, bag[:i]...)
rest = append(rest, bag[i+1:]...)
return tile, rest, nil
}
// bestContenders returns the contenders whose drawn tile has the lowest (best)
// rank — the sole winner if one, else the tied set that re-draws. picks is aligned
// with contenders by index.
func bestContenders(contenders []uuid.UUID, picks []engine.SetupTile) []uuid.UUID {
best := picks[0].Rank
for _, p := range picks[1:] {
if p.Rank < best {
best = p.Rank
}
}
var winners []uuid.UUID
for i, p := range picks {
if p.Rank == best {
winners = append(winners, contenders[i])
}
}
return winners
}
// rotateToFirst returns accounts rotated cyclically so winner sits first (seat 0),
// preserving the seating order of the rest (docs/ARCHITECTURE.md §6). winner must be
// present in accounts.
func rotateToFirst(accounts []uuid.UUID, winner uuid.UUID) []uuid.UUID {
i := 0
for ; i < len(accounts); i++ {
if accounts[i] == winner {
break
}
}
out := make([]uuid.UUID, 0, len(accounts))
out = append(out, accounts[i:]...)
out = append(out, accounts[:i]...)
return out
}
+130
View File
@@ -0,0 +1,130 @@
package game
import (
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
// scriptIntn returns a drawIntn that yields the scripted indices in order, failing
// the test if the script is exhausted or an index is out of range. It lets a test
// drive the first-move draw deterministically (English SetupBag order: 9 'a' at
// 0..8, then 'b' …, blanks last).
func scriptIntn(t *testing.T, seq ...int) drawIntn {
t.Helper()
i := 0
return func(n int) (int, error) {
if i >= len(seq) {
t.Fatalf("intn script exhausted (asked for [0,%d))", n)
}
v := seq[i]
i++
if v < 0 || v >= n {
t.Fatalf("intn script value %d out of range [0,%d)", v, n)
}
return v, nil
}
}
func TestSeedFirstMoveDirectWinner(t *testing.T) {
a, b := uuid.New(), uuid.New()
// a draws bag[0]='a' (rank 0); b draws bag[8]='b' (rank 1) → a wins, no tie.
res, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{a, b}, scriptIntn(t, 0, 8))
if err != nil {
t.Fatalf("seedFirstMove: %v", err)
}
if res.winner != a {
t.Fatalf("winner = %v, want %v", res.winner, a)
}
if got := res.order; len(got) != 2 || got[0] != a || got[1] != b {
t.Fatalf("order = %v, want [a b]", got)
}
if len(res.draws) != 2 {
t.Fatalf("draws = %d, want 2", len(res.draws))
}
}
func TestSeedFirstMoveBlankSupersedes(t *testing.T) {
a, b := uuid.New(), uuid.New()
// a draws 'a' (rank 0); after the draw the two blanks sit at 97,98 — b draws
// bag[97], a blank, which beats every letter.
res, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{a, b}, scriptIntn(t, 0, 97))
if err != nil {
t.Fatalf("seedFirstMove: %v", err)
}
if res.winner != b {
t.Fatalf("winner = %v, want %v (blank supersedes)", res.winner, b)
}
last := res.draws[len(res.draws)-1]
if !last.Blank || last.Rank != engine.BlankRank || last.Letter != "?" {
t.Fatalf("winning draw = %+v, want a blank (rank %d, '?')", last, engine.BlankRank)
}
}
func TestSeedFirstMoveTieRedraw(t *testing.T) {
a, b, c := uuid.New(), uuid.New(), uuid.New()
// Round 1: a→bag[0]='a'(0), b→bag[0]='a'(0), c→bag[7]='b'(1) → a,b tie best.
// Round 2 (a,b only): a→bag[0]='a'(0), b→bag[8]='b'(1) → a wins.
res, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{a, b, c}, scriptIntn(t, 0, 0, 7, 0, 8))
if err != nil {
t.Fatalf("seedFirstMove: %v", err)
}
if res.winner != a {
t.Fatalf("winner = %v, want %v", res.winner, a)
}
if len(res.draws) != 5 {
t.Fatalf("draws = %d, want 5 (3 in round 1, 2 in round 2)", len(res.draws))
}
if res.draws[2].Round != 1 || res.draws[3].Round != 2 {
t.Fatalf("round boundaries wrong: %+v", res.draws)
}
// The full table keeps every account's seating order, winner first.
if got := res.order; got[0] != a || got[1] != b || got[2] != c {
t.Fatalf("order = %v, want [a b c]", got)
}
}
func TestSeedFirstMoveTooFewAccounts(t *testing.T) {
if _, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{uuid.New()}, scriptIntn(t)); err == nil {
t.Fatal("want error for a single account")
}
}
func TestSeedFirstMovePerpetualTieCapped(t *testing.T) {
a, b := uuid.New(), uuid.New()
// Always drawing bag[0] gives both players an 'a' every round — a tie that never
// resolves; the round cap must turn it into an error, not an infinite loop.
alwaysZero := drawIntn(func(int) (int, error) { return 0, nil })
if _, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{a, b}, alwaysZero); err == nil {
t.Fatal("want error when ties never resolve")
}
}
func TestRotateToFirst(t *testing.T) {
a, b, c, d := uuid.New(), uuid.New(), uuid.New(), uuid.New()
tests := []struct {
name string
accounts []uuid.UUID
winner uuid.UUID
want []uuid.UUID
}{
{"two winner second", []uuid.UUID{a, b}, b, []uuid.UUID{b, a}},
{"three winner first", []uuid.UUID{a, b, c}, a, []uuid.UUID{a, b, c}},
{"four winner third", []uuid.UUID{a, b, c, d}, c, []uuid.UUID{c, d, a, b}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := rotateToFirst(tt.accounts, tt.winner)
if len(got) != len(tt.want) {
t.Fatalf("len = %d, want %d", len(got), len(tt.want))
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("rotate = %v, want %v", got, tt.want)
}
}
})
}
}
+258 -83
View File
@@ -38,15 +38,24 @@ type Service struct {
version string
clock func() time.Time
rng func() int64
pub notify.Publisher
// firstMoveEntropy returns the entropy source for one game's first-move draw
// (docs/ARCHITECTURE.md §6). The default is crypto/rand — an honest, seedless draw;
// tests override it via SetFirstMoveEntropy for a deterministic turn order. It is a
// factory so a stateful test source restarts cleanly per game.
firstMoveEntropy func() drawIntn
pub notify.Publisher
// aiTrigger, when set, is called after an honest-AI game is created or advanced and
// is still on a robot's potential turn, so the robot replies at once instead of
// waiting for the next driver scan. It is fire-and-forget (the robot package wires
// its asynchronous TriggerMove); nil disables the fast path (the scan still covers
// these games). Kept as a func so the game package never imports the robot package.
aiTrigger func(gameID uuid.UUID)
metrics *gameMetrics
log *zap.Logger
// clearNudges, when set, marks the actor's pending nudges in a game read once they
// have committed a move (a nudge answered by moving stops counting as unread). It is
// best-effort and kept as a func so the game package never imports the social package.
clearNudges func(ctx context.Context, gameID, accountID uuid.UUID) error
metrics *gameMetrics
log *zap.Logger
}
// NewService constructs a Service. store and accounts wrap the same pool;
@@ -55,17 +64,18 @@ type Service struct {
func NewService(store *Store, accounts *account.Store, registry *engine.Registry, cfg Config, log *zap.Logger) *Service {
clock := func() time.Time { return time.Now().UTC() }
return &Service{
store: store,
accounts: accounts,
registry: registry,
cache: newGameCache(cfg.CacheTTL, clock),
locks: newKeyedMutex(),
version: cfg.DictVersion,
clock: clock,
rng: randomSeed,
pub: notify.Nop{},
metrics: defaultGameMetrics(),
log: log,
store: store,
accounts: accounts,
registry: registry,
cache: newGameCache(cfg.CacheTTL, clock),
locks: newKeyedMutex(),
version: cfg.DictVersion,
clock: clock,
rng: randomSeed,
firstMoveEntropy: func() drawIntn { return cryptoIntn },
pub: notify.Nop{},
metrics: defaultGameMetrics(),
log: log,
}
}
@@ -89,6 +99,26 @@ func (svc *Service) SetAITrigger(trigger func(gameID uuid.UUID)) {
svc.aiTrigger = trigger
}
// SetNudgeClearer installs the hook that marks a mover's pending nudges read after
// their move commits. It must be called during startup wiring; the default (nil)
// leaves nudges to be cleared only when the recipient opens the move history or chat.
// The social package wires its ClearNudges here.
func (svc *Service) SetNudgeClearer(fn func(ctx context.Context, gameID, accountID uuid.UUID) error) {
svc.clearNudges = fn
}
// SetFirstMoveEntropy overrides the entropy source for the first-move draw
// (docs/ARCHITECTURE.md §6). It must be called during wiring or test setup before any
// game is created; the production default is crypto/rand and is never overridden.
// factory returns a fresh draw function per game, so a stateful deterministic test
// source restarts cleanly for each game. It exists for deterministic tests.
func (svc *Service) SetFirstMoveEntropy(factory func() func(n int) (int, error)) {
if factory == nil {
return
}
svc.firstMoveEntropy = func() drawIntn { return drawIntn(factory()) }
}
// triggerAI fires the honest-AI fast-move hook for an active vs_ai game (best-effort,
// fire-and-forget). It is a no-op for non-AI games, finished games and when no hook is
// installed, so callers can invoke it unconditionally after a create or commit.
@@ -179,15 +209,32 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
}
}
seen := make(map[uuid.UUID]bool, len(params.Seats))
seats := make([]seatInsert, len(params.Seats))
for i, id := range params.Seats {
for _, id := range params.Seats {
if seen[id] {
return Game{}, fmt.Errorf("%w: account %s seated twice", ErrInvalidConfig, id)
}
seen[id] = true
// Snapshot each seat's display name at creation. For a vs-AI game this stamps the
// robot's seeded account name (unchanged behaviour); a disguised auto-match robot
// instead gets a fresh per-game name when the reaper attaches it (AttachRobot).
}
// Decide who moves first by the official draw (docs/ARCHITECTURE.md §6): each seated
// account draws a tile, the one closest to "A" leads (a blank supersedes all letters),
// ties re-drawing until a single leader remains. Each draw uses fresh entropy, not the
// game seed, so the recorded draws — persisted with the game — are the only account of
// the outcome. The winner takes seat 0; the rest keep their order.
seeding, err := seedFirstMove(params.Variant, params.Seats, svc.firstMoveEntropy())
if err != nil {
if errors.Is(err, engine.ErrUnknownVariant) {
return Game{}, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
}
return Game{}, err
}
// Build the seats in the drawn turn order, snapshotting each seat's display name. For a
// vs-AI game this stamps the robot's seeded account name (unchanged behaviour); a
// disguised auto-match robot instead gets a fresh per-game name when the reaper attaches
// it (AttachRobot).
seats := make([]seatInsert, len(seeding.order))
for i, id := range seeding.order {
acc, err := svc.accounts.GetByID(ctx, id)
if err != nil {
if errors.Is(err, account.ErrNotFound) {
@@ -237,15 +284,15 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
multipleWordsPerTurn: params.MultipleWordsPerTurn,
vsAI: params.VsAI,
}
if err := svc.store.CreateGame(ctx, ins, seats); err != nil {
if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil {
return Game{}, err
}
svc.cache.put(id, g, params.Variant.String())
svc.metrics.recordStarted(ctx, params.Variant, params.VsAI)
created, err := svc.store.GetGame(ctx, id)
if err != nil {
return Game{}, err
}
svc.cache.put(id, g, params.Variant.String(), created.Seats)
// Honest-AI game seated with a robot: if the robot moves first, reply at once
// (the periodic driver is the fallback). No-op for every human-only game.
svc.triggerAI(created)
@@ -254,14 +301,15 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
// OpenOrJoin enters accountID into auto-match for the variant and per-turn rule in
// params and returns the game they land in immediately: another waiting player's open
// game (joined=true), the caller's own still-open game on a re-enqueue, or a fresh open
// game seating only the caller with an empty opponent seat that a human or the reaper's
// robot fills later. openDeadline is when the reaper substitutes a robot into a freshly
// opened game (ignored when joining one). The bag seed defaults to random; params.Seed
// game (joined=true), or a fresh open game seating only the caller with an empty
// opponent seat that a human or the reaper's robot fills later. A re-enqueue while the
// caller is already waiting opens another game rather than returning their own.
// openDeadline is when the reaper substitutes a robot into a freshly opened game
// (ignored when joining one). The bag seed defaults to random; params.Seed
// pins it. First-move fairness comes from seating the caller at seat 0 or seat 1
// (derived from the seed): seated at seat 1, the still-empty seat 0 moves first, so the
// caller just waits for the opponent. It backs the lobby auto-match enqueue.
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time) (Game, bool, error) {
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time, exclude []uuid.UUID) (Game, bool, error) {
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
if errors.Is(err, account.ErrNotFound) {
@@ -299,14 +347,27 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
status: StatusOpen,
openDeadline: &deadline,
}
// Seat the caller at seat 0 or seat 1 (seat 0 always moves first), snapshotting their
// display name; the other seat is left empty (a zero seatInsert) for the opponent.
caller := seatInsert{accountID: accountID, displayName: acc.DisplayName}
seats := []seatInsert{caller, {}}
if seed&1 == 1 {
seats = []seatInsert{{}, caller}
// Decide the first move now by the official draw, with the not-yet-arrived opponent as a
// synthetic placeholder (uuid.Nil): the draw fixes who sits at seat 0 — and so moves
// first — before either player acts (docs/ARCHITECTURE.md §6). The caller takes their
// drawn seat; the opponent seat is left empty. The opponent's draw rows are recorded with
// a NULL account and back-filled when a real opponent joins. Each draw uses fresh entropy,
// not the game seed. seats/draws are used only when a fresh game is opened.
seeding, err := seedFirstMove(params.Variant, []uuid.UUID{accountID, uuid.Nil}, svc.firstMoveEntropy())
if err != nil {
if errors.Is(err, engine.ErrUnknownVariant) {
return Game{}, false, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
}
return Game{}, false, err
}
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats)
caller := seatInsert{accountID: accountID, displayName: acc.DisplayName}
seats := make([]seatInsert, len(seeding.order))
for seat, who := range seeding.order {
if who == accountID {
seats[seat] = caller // the empty opponent seat stays a zero seatInsert
}
}
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats, exclude, seeding.draws)
if err != nil {
return Game{}, false, err
}
@@ -493,16 +554,6 @@ func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.V
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) {
@@ -534,8 +585,9 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
return MoveResult{}, ErrNotAPlayer
}
// A move is allowed while the game is active or still open (the starter may move on
// their turn before an opponent joins); only a finished game rejects it. The turn
// check below keeps the starter off the still-empty opponent seat.
// their turn before an opponent joins; the first-move draw ran when the game opened, so
// the seats are already fixed); only a finished game rejects it. The turn check below
// keeps the starter off the still-empty opponent seat.
if pre.Status == StatusFinished {
return MoveResult{}, ErrFinished
}
@@ -567,6 +619,13 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
return MoveResult{}, err
}
svc.afterCommitDrafts(ctx, gameID, accountID, rec)
// A nudge the actor answered by moving stops counting as unread (best-effort; the
// move has committed, so a cleanup failure is logged, not surfaced).
if svc.clearNudges != nil {
if err := svc.clearNudges(ctx, gameID, accountID); err != nil {
svc.log.Warn("clear nudges after move", zap.Error(err))
}
}
// 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))
@@ -671,9 +730,6 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
}
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen))
}
// 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:
// Honest-AI games suppress your_turn: the robot replies instantly, so a "your turn"
@@ -688,7 +744,6 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
}
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:
@@ -700,7 +755,6 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
continue
}
over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary)
over.Language = lang
intents = append(intents, over)
}
}
@@ -836,26 +890,35 @@ func (svc *Service) timeoutGame(ctx context.Context, gameID uuid.UUID, now time.
// EvaluatePlay previews a tentative play for a seated player against the current
// board without committing it: whether it is legal and what it would score.
func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUID, tiles []engine.TileRecord) (EvalResult, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return EvalResult{}, err
}
if _, ok := pre.seatOf(accountID); !ok {
return EvalResult{}, ErrNotAPlayer
}
if pre.Status == StatusFinished {
return EvalResult{}, ErrFinished
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return EvalResult{}, err
// Hot path: an active game stays cached — the engine game is mutated in place across
// moves and evicted only when it finishes — so on a hit the cached live game and its
// immutable seat list answer the membership check and the score with no DB read. This
// preview is fired on every tile placement, the hottest gameplay call at scale.
g, seats, ok := svc.cache.get(gameID)
if !ok {
// Cold path: load and validate from the store, then replay into the cache.
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return EvalResult{}, err
}
if pre.Status == StatusFinished {
return EvalResult{}, ErrFinished
}
if g, err = svc.liveGame(ctx, pre); err != nil {
return EvalResult{}, err
}
seats = pre.Seats
}
if !seatedIn(seats, accountID) {
return EvalResult{}, ErrNotAPlayer
}
validateStart := time.Now()
rec, err := g.EvaluatePlay(tiles)
svc.metrics.recordValidate(ctx, pre.Variant, validateStart)
svc.metrics.recordValidate(ctx, g.Variant(), validateStart)
if err != nil {
if errors.Is(err, engine.ErrIllegalPlay) {
return EvalResult{Valid: false}, nil
@@ -1008,12 +1071,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
}
walletAfter := acc.HintBalance
if fromAllowance {
if err := svc.store.SpendHintAllowance(ctx, gameID, seat); err != nil {
return HintResult{}, err
}
used++
} else {
if !fromAllowance {
spent, err := svc.accounts.SpendHint(ctx, accountID)
if err != nil {
return HintResult{}, err
@@ -1023,7 +1081,14 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
}
walletAfter--
}
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter)}, nil
// hints_used is the per-game total (allowance + wallet): every hint increments it. The first
// HintsPerPlayer hints are the free allowance (so fromAllowance above stays correct); the rest
// are charged to the wallet. Counting all hints feeds the player's lifetime hint statistics.
if err := svc.store.IncHintsUsed(ctx, gameID, seat); err != nil {
return HintResult{}, err
}
used++
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil
}
// Candidates returns the to-move player's legal plays for a seated player on
@@ -1111,6 +1176,7 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
Rack: g.Hand(seat),
BagLen: g.BagLen(),
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
WalletBalance: acc.HintBalance,
}, nil
}
@@ -1171,13 +1237,45 @@ func (svc *Service) SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error
return svc.store.SharedGameExists(ctx, a, b)
}
// ListForAccount returns every game the account is seated in, newest first, for the
// lobby's active/finished lists. The live position is not loaded — the summaries come
// straight from the durable rows.
// ListForAccount returns every game the account is seated in, newest first — the full
// set behind the admin console, the account-merge count and the block-time forfeit
// sweep. The player's own lobby uses ListForLobby, which drops the honest-AI games the
// player left. The live position is not loaded — the summaries come straight from the
// durable rows.
func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]Game, error) {
return svc.store.ListGamesForAccount(ctx, accountID)
}
// ListForLobby returns the games shown in the account's own lobby: ListForAccount minus
// the honest-AI games the player left — by resigning or by abandoning to the inactivity
// timeout (games.end_reason 'resign'/'timeout') — which drop out of the finished list
// automatically. The robot never leaves (it moves at once, never sleeps, never resigns),
// so only a human's departure matches; the predicate is a game property, not a seat
// check, so it extends to any player should the robot ever resign. Admin and
// account-merge views keep using ListForAccount and see the full set.
func (svc *Service) ListForLobby(ctx context.Context, accountID uuid.UUID) ([]Game, error) {
games, err := svc.ListForAccount(ctx, accountID)
if err != nil {
return nil, err
}
kept := games[:0]
for _, g := range games {
if g.VsAI && (g.EndReason == "resign" || g.EndReason == "timeout") {
continue
}
kept = append(kept, g)
}
return kept, nil
}
// CountActiveQuickGames reports how many in-progress quick games the account holds —
// the count the simultaneous-game limit (MaxActiveQuickGames) is checked against. It
// counts active and still-open quick games (including honest-AI ones) and excludes
// friend games created by invitation and finished games. See Store.CountActiveQuickGames.
func (svc *Service) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
return svc.store.CountActiveQuickGames(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.
@@ -1232,6 +1330,14 @@ func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView,
return HistoryView{Game: g, Moves: moves}, nil
}
// SetupDraws returns a game's recorded first-move draws (docs/ARCHITECTURE.md §6), ordered by
// round then pick. It backs the admin console's first-move section. An auto-match opponent's
// draws carry uuid.Nil until a real opponent joins and back-fills them; an empty slice means
// the game predates the draw record.
func (svc *Service) SetupDraws(ctx context.Context, gameID uuid.UUID) ([]SetupDraw, error) {
return svc.store.SetupDraws(ctx, gameID)
}
// ExportGCG renders a game as GCG text from the journal alone (no dictionary). It
// is allowed only on a finished game: exporting an in-progress game would leak the
// full move journal mid-play, so an active game yields ErrGameActive.
@@ -1262,7 +1368,7 @@ func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, er
// liveGame returns the live engine.Game for pre, rebuilding it from the journal
// on a cache miss. Callers must hold the per-game lock.
func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error) {
if g, ok := svc.cache.get(pre.ID); ok {
if g, _, ok := svc.cache.get(pre.ID); ok {
return g, nil
}
g, err := svc.replay(ctx, pre)
@@ -1277,7 +1383,7 @@ func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error
}
}
if !g.Over() {
svc.cache.put(pre.ID, g, pre.Variant.String())
svc.cache.put(pre.ID, g, pre.Variant.String(), pre.Seats)
}
return g, nil
}
@@ -1368,19 +1474,45 @@ func replayMove(g *engine.Game, mv HistoryMove) error {
}
// buildStats derives each seat's statistics contribution from a finished game:
// win/loss/draw from the (resignation-aware) winner, the final score, and the
// best single-move score from the log.
// win/loss/draw from the (resignation-aware) winner, the final score, and the best
// single play from the log — its score and, for the per-variant breakdown, its main
// word as rendering tiles. Blank flags are taken from every blank ever placed (so a
// blank laid by an earlier move and embedded in the best word is honoured), which is
// equivalent to reading the final board since a placed tile never moves.
func buildStats(g *engine.Game, seats []Seat) []statDelta {
res := g.Result()
best := make(map[int]int)
bestRec := make(map[int]engine.MoveRecord)
blanks := make(map[[2]int]bool)
plays := make(map[int]int) // per player: count of plays (tile placements), for the "moves" stat
for _, rec := range g.Log() {
if rec.Action == engine.ActionPlay && rec.Score > best[rec.Player] {
best[rec.Player] = rec.Score
if rec.Action != engine.ActionPlay {
continue
}
plays[rec.Player]++
for _, t := range rec.Tiles {
if t.Blank {
blanks[[2]int{t.Row, t.Col}] = true
}
}
if cur, ok := bestRec[rec.Player]; !ok || rec.Score > cur.Score {
bestRec[rec.Player] = rec
}
}
variant := g.Variant().String()
values := letterValues(g.Variant())
out := make([]statDelta, 0, len(seats))
for _, s := range seats {
d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat), wordPoints: best[s.Seat]}
// moves counts the seat's plays; hintsUsed is the seat's total hints this game. Both are
// summed into account_stats so the screen can show the hint share (hints_used / moves).
d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat), moves: plays[s.Seat], hintsUsed: s.HintsUsed}
if rec, ok := bestRec[s.Seat]; ok {
d.wordPoints = rec.Score
if rec.Score > 0 {
d.bestVariant = variant
d.bestScore = rec.Score
d.bestTiles = mainWordTiles(rec, blanks, values)
}
}
switch {
case res.Winner < 0:
d.draws = 1
@@ -1394,6 +1526,49 @@ func buildStats(g *engine.Game, seats []Seat) []statDelta {
return out
}
// letterValues builds a lower-cased letter -> tile value lookup for a variant from the
// engine's alphabet table, so a best-move word can be rendered with per-tile values on a
// screen (statistics) that has not cached the variant's alphabet. It is empty for an
// unrecognised variant, leaving every value zero.
func letterValues(v engine.Variant) map[string]int {
table, err := engine.AlphabetTable(v)
if err != nil {
return nil
}
m := make(map[string]int, len(table))
for _, e := range table {
m[strings.ToLower(e.Letter)] = e.Value
}
return m
}
// mainWordTiles decodes a play's main word into rendering tiles: each letter with its
// tile value (0 for a blank) and blank flag. blanks is the set of board coordinates a
// blank was ever placed on; values maps a lower-cased letter to its tile value. It walks
// the word from its first-letter coordinate along the play's orientation.
func mainWordTiles(rec engine.MoveRecord, blanks map[[2]int]bool, values map[string]int) []account.BestMoveTile {
if len(rec.Words) == 0 {
return nil
}
dr, dc := 0, 1
if rec.Dir == engine.Vertical {
dr, dc = 1, 0
}
letters := []rune(rec.Words[0])
out := make([]account.BestMoveTile, len(letters))
for i, r := range letters {
row, col := rec.MainRow+i*dr, rec.MainCol+i*dc
blank := blanks[[2]int{row, col}]
letter := string(r)
value := 0
if !blank {
value = values[strings.ToLower(letter)]
}
out[i] = account.BestMoveTile{Letter: letter, Value: value, Blank: blank}
}
return out
}
// nonGuestSeats filters out guest seats so the finish-time statistics are
// recomputed for durable non-guest accounts only — guests never accrue
// statistics (docs/ARCHITECTURE.md §9). It is called once per game, on finish.
+50
View File
@@ -0,0 +1,50 @@
package game
import (
"reflect"
"testing"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
)
// TestMainWordTilesHorizontalWithBlank checks that a horizontal main word is decoded into
// tiles along its row, that the per-letter value is looked up from the value table, and that
// a blank cell (here the middle 'a') is rendered with a zero value regardless of its letter
// value — the blank flag is taken from the placed-blank set, not from this move's own tiles.
func TestMainWordTilesHorizontalWithBlank(t *testing.T) {
rec := engine.MoveRecord{Dir: engine.Horizontal, MainRow: 7, MainCol: 5, Words: []string{"cat"}}
blanks := map[[2]int]bool{{7, 6}: true}
values := map[string]int{"c": 3, "a": 1, "t": 1}
got := mainWordTiles(rec, blanks, values)
want := []account.BestMoveTile{
{Letter: "c", Value: 3, Blank: false},
{Letter: "a", Value: 0, Blank: true},
{Letter: "t", Value: 1, Blank: false},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("mainWordTiles = %+v, want %+v", got, want)
}
}
// TestMainWordTilesVerticalCyrillic checks vertical walk and multi-byte (Cyrillic) letters:
// the word must be split by rune, not byte, and laid down its column.
func TestMainWordTilesVerticalCyrillic(t *testing.T) {
rec := engine.MoveRecord{Dir: engine.Vertical, MainRow: 3, MainCol: 8, Words: []string{"съёмка"}}
values := map[string]int{"с": 1, "ъ": 10, "ё": 4, "м": 2, "к": 2, "а": 1}
got := mainWordTiles(rec, map[[2]int]bool{}, values)
want := []account.BestMoveTile{
{Letter: "с", Value: 1}, {Letter: "ъ", Value: 10}, {Letter: "ё", Value: 4},
{Letter: "м", Value: 2}, {Letter: "к", Value: 2}, {Letter: "а", Value: 1},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("mainWordTiles = %+v, want %+v", got, want)
}
}
// TestMainWordTilesNoWord checks the defensive nil for a record carrying no words.
func TestMainWordTilesNoWord(t *testing.T) {
if got := mainWordTiles(engine.MoveRecord{}, nil, nil); got != nil {
t.Errorf("mainWordTiles(no words) = %+v, want nil", got)
}
}
+290 -65
View File
@@ -3,6 +3,7 @@ package game
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
@@ -12,6 +13,7 @@ import (
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
@@ -53,13 +55,22 @@ type gameInsert struct {
}
// statDelta is one account's contribution to its statistics on a game finish.
// bestVariant/bestScore/bestTiles describe the game's best play for this account when
// it scored (bestVariant empty otherwise): the variant label, the play's total score and
// its main word as rendering tiles. They feed the per-variant account_best_move upsert,
// which keeps only the account's highest-scoring play per variant.
type statDelta struct {
accountID uuid.UUID
wins int
losses int
draws int
gamePoints int
wordPoints int
accountID uuid.UUID
wins int
losses int
draws int
gamePoints int
wordPoints int
moves int // plays this game (tile placements), summed into account_stats.moves
hintsUsed int // hints used this game (allowance + wallet), summed into account_stats.hints_used
bestVariant string
bestScore int
bestTiles []account.BestMoveTile
}
// commit is everything a single committed transition persists: the journal row,
@@ -116,11 +127,14 @@ type seatInsert struct {
displayName string
}
// CreateGame inserts the games row and one game_players row per seat (seat 0
// first) inside a single transaction.
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInsert) error {
// CreateGame inserts the games row, one game_players row per seat (seat 0 first) and
// the first-move seeding draws inside a single transaction.
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInsert, draws []SetupDraw) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error {
return insertGameTx(ctx, tx, ins, seats)
if err := insertGameTx(ctx, tx, ins, seats); err != nil {
return err
}
return insertSetupDrawsTx(ctx, tx, ins.id, draws)
})
}
@@ -162,6 +176,30 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatI
return nil
}
// insertSetupDrawsTx appends the first-move seeding draws for game gameID on tx —
// the dictionary-independent record of how the first player was chosen
// (docs/ARCHITECTURE.md §6). The games row must already exist on tx (the foreign
// key), so it runs after insertGameTx. An empty draws slice is a no-op.
func insertSetupDrawsTx(ctx context.Context, tx *sql.Tx, gameID uuid.UUID, draws []SetupDraw) error {
for _, d := range draws {
// A uuid.Nil account marks the synthetic opponent of an auto-match draw, persisted as
// a NULL account_id and back-filled when a real opponent joins.
var acc any = d.Account
if d.Account == uuid.Nil {
acc = postgres.NULL
}
di := table.GameSetupDraws.INSERT(
table.GameSetupDraws.GameID, table.GameSetupDraws.Round, table.GameSetupDraws.PickNo,
table.GameSetupDraws.AccountID, table.GameSetupDraws.Letter, table.GameSetupDraws.IsBlank,
table.GameSetupDraws.DrawRank,
).VALUES(gameID, d.Round, d.PickNo, acc, d.Letter, d.Blank, d.Rank)
if _, err := di.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert setup draw round %d pick %d: %w", d.Round, d.PickNo, err)
}
}
return nil
}
// openMatchKey hashes an auto-match bucket (variant + per-turn word rule) into the
// advisory-lock key that serialises concurrent enqueues for that bucket, so two
// players never both open a game instead of pairing.
@@ -177,48 +215,42 @@ func openMatchKey(variant string, multipleWords bool) int64 {
}
// OpenOrJoin atomically resolves an auto-match enqueue for accountID into the game it
// lands in: it re-uses the caller's own still-open game (joined=false, created=false,
// a re-enqueue is idempotent), joins another player's waiting open game and flips it
// active (joined=true), or opens a fresh game seating the caller with an empty
// opponent seat (created=true). ins supplies the new game's immutable fields and is
// used only when a game is created. A transaction-scoped advisory lock on the
// (variant, rule) bucket serialises concurrent enqueues so two callers pair rather
// than each opening a game. seats is the two-seat arrangement (the caller and uuid.Nil
// for the still-empty opponent, in the chosen order) used only when a game is created;
// callerName is the caller's display-name snapshot, stamped on their seat whether they
// open a fresh game or fill another player's open one.
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert) (gameID uuid.UUID, joined, created bool, err error) {
// lands in: it joins another player's waiting open game and flips it active
// (joined=true), or opens a fresh game seating the caller with an empty opponent seat
// (created=true). A re-enqueue while the caller already has an open game in the bucket
// opens another fresh game (or joins a different player's) rather than returning the
// caller's own, so tapping "random opponent" again always starts a new search. ins
// supplies the new game's immutable fields and is used only when a game is created. A
// transaction-scoped advisory lock on the (variant, rule) bucket serialises concurrent
// enqueues so two callers pair rather than each opening a game. seats is the two-seat
// arrangement (the caller and uuid.Nil for the still-empty opponent, in the chosen
// order) used only when a game is created; callerName is the caller's display-name
// snapshot, stamped on their seat whether they open a fresh game or fill another
// player's open one. seats and draws (the first-move draw recorded against the synthetic
// opponent, docs/ARCHITECTURE.md §6) are used only when a game is created.
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert, exclude []uuid.UUID, draws []SetupDraw) (gameID uuid.UUID, joined, created bool, err error) {
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`,
openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil {
return fmt.Errorf("open match lock: %w", e)
}
// 1. The caller's own still-open game for this bucket — a re-enqueue is idempotent.
var own uuid.UUID
switch e := tx.QueryRowContext(ctx,
`SELECT g.game_id FROM backend.games g
JOIN backend.game_players p ON p.game_id = g.game_id
WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2 AND p.account_id = $3
LIMIT 1`,
ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&own); {
case e == nil:
gameID = own
return nil
case !errors.Is(e, sql.ErrNoRows):
return fmt.Errorf("find own open game: %w", e)
}
// 2. Another player's open game waiting for an opponent — fill its seat and start it.
// 1. Another player's open game waiting for an opponent — fill its seat and start it.
// A game whose waiting player is in exclude (the caller's per-user block set, either
// direction) is skipped, so a block keeps the pair out of the same anonymous game;
// an empty exclude (the "{}" literal) excludes nothing.
var other uuid.UUID
switch e := tx.QueryRowContext(ctx,
`SELECT g.game_id FROM backend.games g
WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2
AND NOT EXISTS (SELECT 1 FROM backend.game_players p
WHERE p.game_id = g.game_id AND p.account_id = $3)
AND NOT EXISTS (SELECT 1 FROM backend.game_players b
WHERE b.game_id = g.game_id AND b.account_id = ANY($4::uuid[]))
ORDER BY g.created_at
LIMIT 1 FOR UPDATE SKIP LOCKED`,
ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&other); {
ins.variant, ins.multipleWordsPerTurn, accountID, uuidArrayLiteral(exclude)).Scan(&other); {
case e == nil:
if er := fillOpenSeat(ctx, tx, other, accountID, callerName); er != nil {
if er := fillAndActivate(ctx, tx, other, accountID, callerName); er != nil {
return er
}
gameID, joined = other, true
@@ -226,10 +258,15 @@ func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName
case !errors.Is(e, sql.ErrNoRows):
return fmt.Errorf("find open game: %w", e)
}
// 3. None waiting — open a fresh game seating the caller (the other seat empty).
// 2. None waiting — open a fresh game seating the caller (the other seat empty) and
// recording the first-move draw; the synthetic opponent's rows carry a NULL account
// until a real opponent joins and back-fills them.
if e := insertGameTx(ctx, tx, ins, seats); e != nil {
return e
}
if e := insertSetupDrawsTx(ctx, tx, ins.id, draws); e != nil {
return e
}
gameID, created = ins.id, true
return nil
})
@@ -254,7 +291,7 @@ func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, disp
if status != StatusOpen {
return nil
}
if e := fillOpenSeat(ctx, tx, gameID, robotID, displayName); e != nil {
if e := fillAndActivate(ctx, tx, gameID, robotID, displayName); e != nil {
return e
}
attached = true
@@ -263,15 +300,23 @@ func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, disp
return attached, err
}
// fillOpenSeat seats accountID in an open game's empty opponent seat — stamping
// displayName as the seat's display-name snapshot — and flips the game to active with a
// fresh turn clock. The caller holds the game row.
func fillOpenSeat(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID, displayName string) error {
// fillAndActivate seats accountID in an open game's empty opponent seat — stamping
// displayName as the seat's snapshot — back-fills the first-move draw rows the open game
// recorded for the then-unknown opponent (docs/ARCHITECTURE.md §6), and flips the game to
// active with a fresh turn clock. The seat the joiner takes was already fixed by the draw at
// open time, so the journal (any opening move the starter made while waiting) is never
// disturbed. The caller holds the game row.
func fillAndActivate(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID, displayName string) error {
if _, err := tx.ExecContext(ctx,
`UPDATE backend.game_players SET account_id = $2, display_name = $3 WHERE game_id = $1 AND account_id IS NULL`,
gameID, accountID, displayName); err != nil {
return fmt.Errorf("fill opponent seat: %w", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE backend.game_setup_draws SET account_id = $2 WHERE game_id = $1 AND account_id IS NULL`,
gameID, accountID); err != nil {
return fmt.Errorf("back-fill opponent draws: %w", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE backend.games SET status = 'active', open_deadline_at = NULL, turn_started_at = now(), updated_at = now()
WHERE game_id = $1`, gameID); err != nil {
@@ -310,27 +355,33 @@ func (s *Store) ExpiredOpen(ctx context.Context, now time.Time) ([]OpenGame, err
// GetGame loads the games row joined with its seats (ordered by seat), or
// ErrNotFound.
func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) {
gstmt := postgres.SELECT(table.Games.AllColumns).
FROM(table.Games).
// One round-trip: the game joined with its seats. A LEFT JOIN keeps a (would-be)
// seatless game returning the game with no seats, exactly as the prior two-query
// version did; ORDER BY seat preserves seat order. The games columns repeat per seat
// row — cheap at 2-4 seats, and one round-trip instead of two, which matters because
// GetGame is the universal "load the game" step on every game operation.
stmt := postgres.SELECT(table.Games.AllColumns, table.GamePlayers.AllColumns).
FROM(table.Games.LEFT_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID))).
WHERE(table.Games.GameID.EQ(postgres.UUID(id))).
LIMIT(1)
var grow model.Games
if err := gstmt.QueryContext(ctx, s.db, &grow); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Game{}, ErrNotFound
}
ORDER_BY(table.GamePlayers.Seat.ASC())
var rows []struct {
model.Games
model.GamePlayers
}
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return Game{}, fmt.Errorf("game: get %s: %w", id, err)
}
sstmt := postgres.SELECT(table.GamePlayers.AllColumns).
FROM(table.GamePlayers).
WHERE(table.GamePlayers.GameID.EQ(postgres.UUID(id))).
ORDER_BY(table.GamePlayers.Seat.ASC())
var srows []model.GamePlayers
if err := sstmt.QueryContext(ctx, s.db, &srows); err != nil {
return Game{}, fmt.Errorf("game: get seats %s: %w", id, err)
if len(rows) == 0 {
return Game{}, ErrNotFound
}
return projectGame(grow, srows)
seats := make([]model.GamePlayers, 0, len(rows))
for i := range rows {
// Skip the phantom all-NULL seat row a LEFT JOIN yields for a seatless game.
if rows[i].GamePlayers.GameID == id {
seats = append(seats, rows[i].GamePlayers)
}
}
return projectGame(rows[0].Games, seats)
}
// GetGameVariant reads just a game's variant — a cheap single-column lookup the edge uses
@@ -450,6 +501,28 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
return out, nil
}
// CountActiveQuickGames counts the account's in-progress quick games — the ones the
// simultaneous-game limit (MaxActiveQuickGames) is checked against. It includes both
// active and still-open (awaiting-opponent) games, the honest-AI ones among them, and
// excludes friend games (those linked to a game_invitations row) and finished games.
// A hidden game still occupies a slot, so this is a dedicated count rather than a
// filter over ListGamesForAccount (which drops hidden games). Joining on the account's
// own seat counts each game once (an open game's empty opponent seat has no account).
func (s *Store) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
// The status literals are game.StatusActive / game.StatusOpen, matching the
// games.status CHECK in the baseline migration.
const q = `
SELECT COUNT(*) FROM backend.games g
JOIN backend.game_players gp ON gp.game_id = g.game_id
LEFT JOIN backend.game_invitations gi ON gi.game_id = g.game_id
WHERE gp.account_id = $1 AND g.status IN ('active', 'open') AND gi.game_id IS NULL`
var n int
if err := s.db.QueryRowContext(ctx, q, accountID).Scan(&n); err != nil {
return 0, fmt.Errorf("game: count active quick games: %w", err)
}
return n, 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 {
@@ -561,6 +634,37 @@ func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, er
return out, nil
}
// SetupDraws loads the ordered first-move seeding draws for a game (round then pick
// order), or an empty slice for a game created before the draw was recorded. It
// backs the admin console's first-move section.
func (s *Store) SetupDraws(ctx context.Context, id uuid.UUID) ([]SetupDraw, error) {
stmt := postgres.SELECT(table.GameSetupDraws.AllColumns).
FROM(table.GameSetupDraws).
WHERE(table.GameSetupDraws.GameID.EQ(postgres.UUID(id))).
ORDER_BY(table.GameSetupDraws.Round.ASC(), table.GameSetupDraws.PickNo.ASC())
var rows []model.GameSetupDraws
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("game: get setup draws %s: %w", id, err)
}
out := make([]SetupDraw, len(rows))
for i, r := range rows {
// A NULL account is the synthetic opponent of an auto-match draw not yet back-filled.
acc := uuid.Nil
if r.AccountID != nil {
acc = *r.AccountID
}
out[i] = SetupDraw{
Round: int(r.Round),
PickNo: int(r.PickNo),
Account: acc,
Letter: r.Letter,
Blank: r.IsBlank,
Rank: int(r.DrawRank),
}
}
return out, nil
}
// CommitMove appends the move and applies the post-move game state — the turn
// cursor and per-seat scores, plus the finish stamp and statistics when the move
// ended the game — in one transaction.
@@ -611,6 +715,9 @@ func (s *Store) CommitMove(ctx context.Context, c commit) error {
if err := upsertStats(ctx, tx, d, c.now); err != nil {
return err
}
if err := upsertBestMove(ctx, tx, d, c.now); err != nil {
return err
}
}
}
return nil
@@ -640,6 +747,9 @@ func (s *Store) VoidGame(ctx context.Context, v voidCommit) error {
if err := upsertStats(ctx, tx, d, v.now); err != nil {
return err
}
if err := upsertBestMove(ctx, tx, d, v.now); err != nil {
return err
}
}
return nil
})
@@ -692,13 +802,17 @@ func upsertStats(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) er
draws := row.Draws + int32(d.draws)
maxGame := max(row.MaxGamePoints, int32(d.gamePoints))
maxWord := max(row.MaxWordPoints, int32(d.wordPoints))
moves := row.Moves + int32(d.moves)
hintsUsed := row.HintsUsed + int32(d.hintsUsed)
upd := table.AccountStats.UPDATE(
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
table.AccountStats.Moves, table.AccountStats.HintsUsed,
).SET(
postgres.Int(int64(wins)), postgres.Int(int64(losses)), postgres.Int(int64(draws)),
postgres.Int(int64(maxGame)), postgres.Int(int64(maxWord)), postgres.TimestampzT(now),
postgres.Int(int64(moves)), postgres.Int(int64(hintsUsed)),
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(d.accountID)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("update stats %s: %w", d.accountID, err)
@@ -706,8 +820,41 @@ func upsertStats(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) er
return nil
}
// SpendHintAllowance increments a seat's per-game hint counter by one.
func (s *Store) SpendHintAllowance(ctx context.Context, gameID uuid.UUID, seat int) error {
// upsertBestMove records the account's best play for a variant, keeping only the
// highest-scoring one: a first play inserts, a later one replaces it only when it scored
// strictly higher (the conditional DO UPDATE makes the upsert atomic under concurrent
// finishes without a separate lock). It is a no-op when the finish carries no scoring play
// for the account (a draw with no plays, or an exchange/pass-only game).
func upsertBestMove(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) error {
if d.bestVariant == "" || len(d.bestTiles) == 0 {
return nil
}
tiles, err := json.Marshal(d.bestTiles)
if err != nil {
return fmt.Errorf("marshal best move %s/%s: %w", d.accountID, d.bestVariant, err)
}
stmt := table.AccountBestMove.
INSERT(
table.AccountBestMove.AccountID, table.AccountBestMove.Variant,
table.AccountBestMove.Score, table.AccountBestMove.Tiles, table.AccountBestMove.UpdatedAt,
).
VALUES(d.accountID, d.bestVariant, d.bestScore, string(tiles), postgres.TimestampzT(now)).
ON_CONFLICT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant).
DO_UPDATE(postgres.SET(
table.AccountBestMove.Score.SET(table.AccountBestMove.EXCLUDED.Score),
table.AccountBestMove.Tiles.SET(table.AccountBestMove.EXCLUDED.Tiles),
table.AccountBestMove.UpdatedAt.SET(table.AccountBestMove.EXCLUDED.UpdatedAt),
).WHERE(table.AccountBestMove.EXCLUDED.Score.GT(table.AccountBestMove.Score)))
if _, err := stmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("upsert best move %s/%s: %w", d.accountID, d.bestVariant, err)
}
return nil
}
// IncHintsUsed increments a seat's per-game hints-used counter by one. It is called for
// every hint — both the free per-game allowance and the wallet-charged ones — so the counter
// is the seat's total hints used this game (the first HintsPerPlayer being the allowance).
func (s *Store) IncHintsUsed(ctx context.Context, gameID uuid.UUID, seat int) error {
stmt := table.GamePlayers.
UPDATE(table.GamePlayers.HintsUsed).
SET(table.GamePlayers.HintsUsed.ADD(postgres.Int(1))).
@@ -716,7 +863,7 @@ func (s *Store) SpendHintAllowance(ctx context.Context, gameID uuid.UUID, seat i
AND(table.GamePlayers.Seat.EQ(postgres.Int(int64(seat)))),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("game: spend hint allowance: %w", err)
return fmt.Errorf("game: increment hints used: %w", err)
}
return nil
}
@@ -951,13 +1098,18 @@ func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, e
for _, r := range rows {
out = append(out, robotTurnFrom(r.Games, r.GamePlayers))
}
if err := s.fillEndgamePass(ctx, out); err != nil {
return nil, err
}
return out, nil
}
// RobotTurnByGame returns the robot turn for a single active game — the seat held by
// one of ids (the robot pool) — and true, or false when the game is not active, holds
// no pooled robot, or is gone. It backs the honest-AI after-commit trigger, which
// drives one game at once rather than scanning the whole pool (RobotTurns).
// drives one game at once rather than scanning the whole pool (RobotTurns). It leaves
// EndgamePass false: honest-AI games move at once, so the endgame think-time shrink is a
// human-mimicry concern computed only on the RobotTurns scan.
func (s *Store) RobotTurnByGame(ctx context.Context, gameID uuid.UUID, ids []uuid.UUID) (RobotTurn, bool, error) {
if len(ids) == 0 {
return RobotTurn{}, false, nil
@@ -1010,6 +1162,79 @@ func robotTurnFrom(g model.Games, p model.GamePlayers) RobotTurn {
}
}
// fillEndgamePass marks the turns whose game is a dead-drawn endgame — its two most
// recent committed moves are both passes, so the board and racks are frozen and the
// seated robot is bound to pass again — setting EndgamePass and OppLastMove from the
// move journal so the driver can shorten the robot's think time. Turns whose game is
// not in that state are left unchanged. A nil or empty slice is a no-op. It runs one
// batched journal query for the whole scan, so it adds no per-game round trip.
func (s *Store) fillEndgamePass(ctx context.Context, turns []RobotTurn) error {
if len(turns) == 0 {
return nil
}
ids := make([]uuid.UUID, len(turns))
for i := range turns {
ids[i] = turns[i].GameID
}
info, err := s.endgamePassInfo(ctx, ids)
if err != nil {
return err
}
for i := range turns {
if d, ok := info[turns[i].GameID]; ok {
turns[i].EndgamePass = true
turns[i].OppLastMove = d
}
}
return nil
}
// endgamePassInfo returns, for each of ids whose two most recent committed moves are
// both passes, the human's think time on the most recent of them (the gap between the
// last two journal entries' created_at). Games with fewer than two moves, or whose last
// two are not both passes, are absent from the map. It reads the move journal only — no
// schema change — mirroring the analytics.go duration reports. A negative gap (clock
// skew) is floored to zero.
func (s *Store) endgamePassInfo(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]time.Duration, error) {
if len(ids) == 0 {
return map[uuid.UUID]time.Duration{}, nil
}
const q = `
SELECT q.game_id, q.secs FROM (
SELECT t.game_id,
bool_and(t.action = 'pass') AS both_pass,
COUNT(*) AS n,
EXTRACT(EPOCH FROM (MAX(t.created_at) - MIN(t.created_at))) AS secs
FROM (
SELECT m.game_id, m.action, m.created_at,
ROW_NUMBER() OVER (PARTITION BY m.game_id ORDER BY m.seq DESC) AS rn
FROM backend.game_moves m
WHERE m.game_id = ANY($1::uuid[])
) t
WHERE t.rn <= 2
GROUP BY t.game_id
) q
WHERE q.n = 2 AND q.both_pass`
rows, err := s.db.QueryContext(ctx, q, uuidArrayLiteral(ids))
if err != nil {
return nil, fmt.Errorf("game: endgame pass info: %w", err)
}
defer rows.Close()
out := make(map[uuid.UUID]time.Duration, len(ids))
for rows.Next() {
var id uuid.UUID
var secs float64
if err := rows.Scan(&id, &secs); err != nil {
return nil, fmt.Errorf("game: scan endgame pass info: %w", err)
}
if secs < 0 {
secs = 0
}
out[id] = time.Duration(secs * float64(time.Second))
}
return out, rows.Err()
}
// GameVsAI reports whether a game is an honest-AI game (games.vs_ai) — a cheap
// single-column read for the social chat/nudge gate, which must reject both in an
// AI game even though it reports status 'active'. ErrNotFound when the game is gone.
+42 -1
View File
@@ -65,6 +65,9 @@ var (
ErrNoHintsLeft = errors.New("game: no hints remaining")
// ErrNoHintAvailable is returned when the player has no legal play to reveal.
ErrNoHintAvailable = errors.New("game: no legal move to suggest")
// ErrGameLimitReached is returned when an account already holds MaxActiveQuickGames
// simultaneous quick games and tries to create another game (quick or by invitation).
ErrGameLimitReached = errors.New("game: simultaneous game limit reached")
)
// AllowedTurnTimeouts is the set of per-game move clocks a game may be created
@@ -87,6 +90,15 @@ const DefaultTurnTimeout = 24 * time.Hour
// one of AllowedTurnTimeouts (never offered in the creation UI).
const AIInactivityTimeout = 7 * 24 * time.Hour
// MaxActiveQuickGames is the cap on a player's simultaneous quick games (human
// auto-match and honest-AI), counting both in-progress (StatusActive) and
// still-open, awaiting-opponent (StatusOpen) games. Friend games created by
// invitation are not counted. At the cap the backend refuses to create a new game
// of any kind — quick or by invitation — and the lobby disables "New Game";
// accepting an incoming invitation is always allowed. See
// Store.CountActiveQuickGames.
const MaxActiveQuickGames = 10
// aiPlayerName labels the robot seat in an honest-AI game's GCG export, so a downloaded
// game file shows a clean "AI" rather than the robot's human-like pool name (the in-app
// UI shows 🤖 from the game's vs_ai flag).
@@ -142,6 +154,8 @@ type Seat struct {
Seat int
AccountID uuid.UUID
Score int
// HintsUsed is the total hints the seat used this game — both the free per-game allowance
// and the wallet-charged ones (the first HintsPerPlayer being the allowance).
HintsUsed int
IsWinner bool
// DisplayName is the seat's display-name snapshot, captured when the seat was taken
@@ -170,6 +184,18 @@ func (g Game) seatOf(accountID uuid.UUID) (int, bool) {
return 0, false
}
// seatedIn reports whether accountID holds a seat in seats. It backs the read-side
// membership check against the cached, immutable seat list, so a hot read can skip
// loading the game from the store.
func seatedIn(seats []Seat, accountID uuid.UUID) bool {
for _, s := range seats {
if s.AccountID == accountID {
return true
}
}
return false
}
// MoveResult is the outcome of a committed transition: the decoded move and the
// 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
@@ -182,10 +208,13 @@ type MoveResult struct {
}
// HintResult is a revealed hint and the requesting player's remaining hint
// budget (per-seat allowance plus profile wallet) after spending one.
// budget (per-seat allowance plus profile wallet) after spending one. WalletBalance is
// the global wallet alone, so the client can keep its live wallet authoritative and
// re-derive the per-game allowance (HintsRemaining - WalletBalance).
type HintResult struct {
Move engine.MoveRecord
HintsRemaining int
WalletBalance int
}
// EvalResult previews a tentative play without committing it. Dir is the
@@ -208,6 +237,9 @@ type StateView struct {
Rack []string
BagLen int
HintsRemaining int
// WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds
// it in with the per-game allowance), so the client keeps the wallet live across games.
WalletBalance int
}
// HistoryMove is one decoded journal row, independent of any dictionary.
@@ -248,6 +280,15 @@ type RobotTurn struct {
// VsAI is true when the game is an honest-AI game: the driver then makes the
// robot move immediately, with no sleep window and no proactive nudge.
VsAI bool
// EndgamePass is true when the two most recent committed moves are both passes, so
// the board and racks are frozen and the robot is bound to pass again. The driver
// then shortens the robot's think time (see robot.endgamePassDelay) so a decided
// game is not dragged out. It is false until at least two moves exist.
EndgamePass bool
// OppLastMove is the human's think time on the most recent move — the gap between
// the last two journal entries — used to scale the shortened endgame think time. It
// is meaningful only when EndgamePass is true (zero otherwise).
OppLastMove time.Duration
}
// Complaint is a word-check complaint in the admin review queue. It is filed
+15 -47
View File
@@ -89,7 +89,8 @@ func TestGetStatsZeroForFreshAccount(t *testing.T) {
if err != nil {
t.Fatalf("get stats: %v", err)
}
if (st != account.Stats{}) {
zero := st.Wins == 0 && st.Losses == 0 && st.Draws == 0 && st.MaxGamePoints == 0 && st.MaxWordPoints == 0
if !zero || len(st.BestMoves) != 0 {
t.Fatalf("fresh stats = %+v, want zero", st)
}
}
@@ -117,10 +118,13 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
store := account.NewStore(testDB)
ext := "tg-" + uuid.NewString()
acc, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван")
acc, created, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
if !created {
t.Error("created = false on first contact, want true")
}
if acc.PreferredLanguage != "ru" {
t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage)
}
@@ -132,10 +136,13 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
}
// A later login with different fields returns the same account, unchanged.
again, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other")
again, created, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other")
if err != nil {
t.Fatalf("re-provision telegram: %v", err)
}
if created {
t.Error("created = true on a repeat login, want false")
}
if again.ID != acc.ID {
t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
}
@@ -149,7 +156,7 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
// language CHECK.
func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
ctx := context.Background()
acc, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "")
acc, _, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
@@ -158,46 +165,6 @@ 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
@@ -205,7 +172,7 @@ func TestServiceLanguageRoundTrip(t *testing.T) {
func TestHighRateFlagRoundTrip(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
@@ -261,7 +228,7 @@ func TestIdentityExternalID(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
ext := "tg-" + uuid.NewString()
acc, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User")
acc, _, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
@@ -286,7 +253,7 @@ func TestIdentityExternalID(t *testing.T) {
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
@@ -298,6 +265,7 @@ func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
PreferredLanguage: "en",
TimeZone: "UTC",
NotificationsInAppOnly: false,
VariantPreferences: []string{"erudit_ru"},
})
if err != nil {
t.Fatalf("update profile: %v", err)
+7 -1
View File
@@ -206,6 +206,12 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) {
if !strings.Contains(body, "~40%") {
t.Error("robot play-to-win target caption missing")
}
if !strings.Contains(body, "First-move draw") {
t.Error("first-move draw section missing from the game detail")
}
if !strings.Contains(body, `id="replay-board"`) {
t.Error("replay board container missing from the game detail")
}
}
// TestConsoleThrottledViewAndFlagClear drives the rate-limit surface end to
@@ -216,7 +222,7 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) {
func TestConsoleThrottledViewAndFlagClear(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
acc, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player")
acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player")
if err != nil {
t.Fatalf("provision: %v", err)
}
@@ -256,7 +256,8 @@ func TestBannerSurvivesProfileUpdate(t *testing.T) {
id := provisionAccount(t)
body := `{"display_name":"Tester","preferred_language":"ru","time_zone":"UTC","away_start":"00:00",` +
`"away_end":"00:00","block_chat":false,"block_friend_requests":false,"notifications_in_app_only":true}`
`"away_end":"00:00","block_chat":false,"block_friend_requests":false,"notifications_in_app_only":true,` +
`"variant_preferences":["erudit_ru"]}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/v1/user/profile", strings.NewReader(body))
req.Header.Set("X-User-ID", id.String())
+322
View File
@@ -0,0 +1,322 @@
//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"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/social"
)
// TestBlockInstantReadsExistingUnread checks that blocking marks read any chat the blocked
// user had left unread for the blocker in their shared games, so no stale unread badge
// lingers for someone the blocker no longer sees.
func TestBlockInstantReadsExistingUnread(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2)
sender, viewer := seats[0], seats[1] // seat 0 moves first, so it may chat
if _, err := svc.PostMessage(ctx, gameID, sender, "good luck", ""); err != nil {
t.Fatalf("post: %v", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, viewer); !unread {
t.Fatal("viewer should have the message unread before blocking")
}
if err := svc.Block(ctx, viewer, sender); err != nil {
t.Fatalf("block: %v", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, viewer); unread {
t.Error("blocking should instant-read the blocked sender's prior unread message")
}
}
// TestChatFromBlockedIsBornReadAndNotDelivered checks the store-but-hide path for chat: a
// message the blocked user sends is stored and visible to themselves, but is born read for
// the blocker, never delivered to them, and hidden from their view.
func TestChatFromBlockedIsBornReadAndNotDelivered(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
gameID, seats := newGameWithSeats(t, 2)
blocked, blocker := seats[0], seats[1] // seat 0 moves first, so the blocked user may chat
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.PostMessage(ctx, gameID, blocked, "hello there", ""); err != nil {
t.Fatalf("blocked user's post = %v, want nil (it must look ordinary to them)", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, blocker); unread {
t.Error("the blocked sender's message must be born read for the blocker")
}
if pub.delivered(blocker, notify.KindChatMessage) {
t.Error("the blocked sender's message must not be delivered to the blocker")
}
if msgs, _ := svc.Messages(ctx, gameID, blocker); len(msgs) != 0 {
t.Errorf("blocker still sees the blocked sender's message: %+v", msgs)
}
if msgs, _ := svc.Messages(ctx, gameID, blocked); len(msgs) != 1 {
t.Errorf("the blocked sender should see their own message, got %+v", msgs)
}
}
// TestChatToBlockedIsRejected checks the blocker-side guard: a player cannot post when their
// only opponent is someone they have blocked (the composer is hidden client-side too).
func TestChatToBlockedIsRejected(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2)
blocker, blocked := seats[0], seats[1] // blocker moves first, so it is the one trying to chat
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.PostMessage(ctx, gameID, blocker, "hi", ""); !errors.Is(err, social.ErrRecipientBlocked) {
t.Fatalf("chat to blocked opponent = %v, want ErrRecipientBlocked", err)
}
}
// TestNudgeFromBlockedIsBornReadAndNotDelivered checks the store-but-hide path for nudge: a
// nudge the blocked user sends is recorded (so their once-per-hour cooldown applies, looking
// ordinary to them) but is born read and never delivered to the blocker.
func TestNudgeFromBlockedIsBornReadAndNotDelivered(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
gameID, seats := newGameWithSeats(t, 2)
blocker, blocked := seats[0], seats[1] // seat 0 awaited; the blocked seat-1 player nudges it
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.Nudge(ctx, gameID, blocked); err != nil {
t.Fatalf("blocked user's nudge = %v, want nil (it must look ordinary to them)", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, blocker); unread {
t.Error("the blocked sender's nudge must be born read for the blocker")
}
if pub.delivered(blocker, notify.KindNudge) {
t.Error("the blocked sender's nudge must not be delivered to the blocker")
}
// The nudge is still recorded, so the cooldown applies (the blocked user notices nothing).
if _, ok, _ := svc.LastNudgeAt(ctx, gameID, blocked); !ok {
t.Error("the suppressed nudge should still be recorded")
}
}
// TestNudgeToBlockedIsRejected checks the blocker-side guard: a player cannot nudge the
// awaited opponent when they have blocked them.
func TestNudgeToBlockedIsRejected(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2)
blocked, blocker := seats[0], seats[1] // seat 0 awaited; the blocker (seat 1) tries to nudge it
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.Nudge(ctx, gameID, blocker); !errors.Is(err, social.ErrRecipientBlocked) {
t.Fatalf("nudge to blocked opponent = %v, want ErrRecipientBlocked", err)
}
}
// TestBlockPublishesToBlockerOnly checks that block and unblock confirm to the blocker (so
// their other sessions update in place) and never reach the blocked user.
func TestBlockPublishesToBlockerOnly(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
blocker, blocked := provisionAccount(t), provisionAccount(t)
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if !pub.notified(blocker, notify.NotifyUserBlocked) {
t.Error("the blocker should receive a user_blocked event")
}
if pub.notified(blocked, notify.NotifyUserBlocked) {
t.Error("the blocked user must never receive a user_blocked event")
}
if err := svc.Unblock(ctx, blocker, blocked); err != nil {
t.Fatalf("unblock: %v", err)
}
if !pub.notified(blocker, notify.NotifyUserUnblocked) {
t.Error("the blocker should receive a user_unblocked event")
}
if pub.notified(blocked, notify.NotifyUserUnblocked) {
t.Error("the blocked user must never receive a user_unblocked event")
}
}
// TestMatchmakingExcludesBlockedPlayers checks auto-match never pairs two players with a
// block between them (either direction), while an unblocked third player still joins.
func TestMatchmakingExcludesBlockedPlayers(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second)
soc := newSocialService()
mm.SetBlocker(soc)
a, b, c := provisionAccount(t), provisionAccount(t), provisionAccount(t)
if err := soc.Block(ctx, a, b); err != nil {
t.Fatalf("block: %v", err)
}
// The block is seen from both sides — the exclusion set unions both directions.
if !contains(blockedWith(t, soc, a), b) || !contains(blockedWith(t, soc, b), a) {
t.Fatal("a block must appear in BlockedWith for both the blocker and the blocked")
}
// b opens a game awaiting an opponent.
r1, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue b: %v", err)
}
if r1.Matched {
t.Fatal("first enqueue must open a game, not match")
}
// a must not join b's game (a blocked b): it opens its own instead.
r2, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue a: %v", err)
}
if r2.Matched || r2.Game.ID == r1.Game.ID {
t.Fatalf("a joined the blocked player's game %s (matched %v), want a fresh game", r1.Game.ID, r2.Matched)
}
// A third, unblocked player joins b's still-open game (the oldest), proving the exclusion
// is specific to the blocked pair, not a blanket refusal.
r3, err := mm.Enqueue(ctx, c, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue c: %v", err)
}
if !r3.Matched || r3.Game.ID != r1.Game.ID {
t.Fatalf("unblocked c = (game %s, matched %v), want it to join b's open game %s", r3.Game.ID, r3.Matched, r1.Game.ID)
}
}
// TestAdminSocialLists checks the admin user card's blocks / blocked-by / friends queries
// return the full truth in both directions, including a friendship that a block overrides but
// does not delete.
func TestAdminSocialLists(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
_, seats := newGameWithSeats(t, 2)
a, b := seats[0], seats[1]
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
t.Fatalf("request: %v", err)
}
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
t.Fatalf("accept: %v", err)
}
if err := svc.Block(ctx, a, b); err != nil {
t.Fatalf("block: %v", err)
}
blocks, err := svc.AdminBlocksBy(ctx, a)
if err != nil {
t.Fatalf("admin blocks by: %v", err)
}
if len(blocks) != 1 || blocks[0].AccountID != b || blocks[0].At.IsZero() {
t.Errorf("a's blocks = %v, want [b] with a date", blocks)
}
blockedBy, err := svc.AdminBlockedBy(ctx, b)
if err != nil {
t.Fatalf("admin blocked by: %v", err)
}
if len(blockedBy) != 1 || blockedBy[0].AccountID != a {
t.Errorf("b's blocked-by = %v, want [a]", blockedBy)
}
// The friendship survives the block, so it still shows on both admin friend lists.
friendsA, err := svc.AdminFriends(ctx, a)
if err != nil {
t.Fatalf("admin friends: %v", err)
}
if len(friendsA) != 1 || friendsA[0].AccountID != b || friendsA[0].At.IsZero() {
t.Errorf("a's admin friends = %v, want [b] with a date", friendsA)
}
if friendsB, err := svc.AdminFriends(ctx, b); err != nil || len(friendsB) != 1 || friendsB[0].AccountID != a {
t.Errorf("b's admin friends = %v (err %v), want [a]", friendsB, err)
}
}
// TestRobotBlockIsPerGameAndMatchmakerImmune checks that blocking a disguised-robot opponent
// is recorded per-game in robot_blocks — never in the blocks table or against the shared robot
// account — so the matchmaker keeps the robot free, and that unblocking by the row id removes it.
func TestRobotBlockIsPerGameAndMatchmakerImmune(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
accs := account.NewStore(testDB)
human := provisionAccount(t)
robot, err := accs.ProvisionRobot(ctx, "robot-block-"+uuid.NewString(), "Robbie")
if err != nil {
t.Fatalf("provision robot: %v", err)
}
g, err := newGameService().Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID},
TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create game: %v", err)
}
if err := svc.BlockInGame(ctx, human, robot.ID, g.ID); err != nil {
t.Fatalf("block robot: %v", err)
}
rbs, err := svc.ListRobotBlocks(ctx, human)
if err != nil {
t.Fatalf("list robot blocks: %v", err)
}
if len(rbs) != 1 || rbs[0].GameID != g.ID {
t.Fatalf("robot blocks = %v, want one for game %s", rbs, g.ID)
}
if bl, _ := svc.ListBlocks(ctx, human); len(bl) != 0 {
t.Errorf("blocks table must stay empty for a robot block, got %v", bl)
}
if yes, _ := svc.IsBlocked(ctx, human, robot.ID); yes {
t.Error("the shared robot account must never be blocked")
}
// Matchmaking immunity: the robot is never in the caller's exclusion set, so the matchmaker
// keeps giving robots and the player can never be starved by blocking them.
if with, _ := svc.BlockedWith(ctx, human); contains(with, robot.ID) {
t.Error("a robot block must not put the robot in BlockedWith")
}
// Unblock by the robot_blocks row id removes it.
if err := svc.Unblock(ctx, human, rbs[0].ID); err != nil {
t.Fatalf("unblock robot: %v", err)
}
if rbs, _ := svc.ListRobotBlocks(ctx, human); len(rbs) != 0 {
t.Errorf("robot block not removed by unblock: %v", rbs)
}
}
// blockedWith reads the both-direction block set for id.
func blockedWith(t *testing.T, soc *social.Service, id uuid.UUID) []uuid.UUID {
t.Helper()
ids, err := soc.BlockedWith(context.Background(), id)
if err != nil {
t.Fatalf("blocked with: %v", err)
}
return ids
}
// contains reports whether ids includes want.
func contains(ids []uuid.UUID, want uuid.UUID) bool {
for _, id := range ids {
if id == want {
return true
}
}
return false
}
@@ -0,0 +1,315 @@
//go:build integration
package inttest
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap/zaptest"
"scrabble/backend/internal/account"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/server"
"scrabble/backend/internal/session"
)
// chatAccessBody mirrors the backend's /internal/chat-access JSON for the test.
type chatAccessBody struct {
ExternalID string `json:"external_id"`
Registered bool `json:"registered"`
Eligible bool `json:"eligible"`
}
// chatAccess issues the gateway-internal chat-access query and asserts a 200.
func chatAccess(t *testing.T, srv *server.Server, body string) chatAccessBody {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/chat-access", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("chat-access %s = %d: %s", body, rec.Code, rec.Body.String())
}
var b chatAccessBody
if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil {
t.Fatalf("decode chat-access: %v", err)
}
return b
}
// TestChatAccessResolver drives the gateway-internal eligibility resolver over HTTP:
// the registered/suspended/chat_muted truth table by Telegram identity and by account
// id, the suspension dominating the chat_muted role, an unknown identity reported
// unregistered, and an account with no Telegram identity carrying an empty external_id.
func TestChatAccessResolver(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts})
ext := "tg-" + uuid.NewString()
acc, _, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter")
if err != nil {
t.Fatalf("provision: %v", err)
}
id := acc.ID
byExt := func() chatAccessBody { return chatAccess(t, srv, `{"external_id":"`+ext+`"}`) }
byUser := func() chatAccessBody { return chatAccess(t, srv, `{"user_id":"`+id.String()+`"}`) }
// A registered, unsuspended, unmuted account is eligible by either address, and the
// account-id query resolves back to its Telegram identity.
if b := byExt(); !b.Registered || !b.Eligible || b.ExternalID != ext {
t.Fatalf("fresh by external_id = %+v, want registered+eligible+ext", b)
}
if b := byUser(); !b.Registered || !b.Eligible || b.ExternalID != ext {
t.Fatalf("fresh by user_id = %+v, want registered+eligible+ext", b)
}
// A suspension mutes; a lift restores.
if _, err := accounts.Suspend(ctx, id, nil, "", "", nil); err != nil {
t.Fatalf("suspend: %v", err)
}
if b := byExt(); !b.Registered || b.Eligible {
t.Fatalf("suspended = %+v, want registered but not eligible", b)
}
if err := accounts.LiftSuspension(ctx, id); err != nil {
t.Fatalf("lift: %v", err)
}
if b := byExt(); !b.Eligible {
t.Fatalf("after lift = %+v, want eligible", b)
}
// The chat_muted role mutes independently; a revoke restores.
if err := accounts.GrantRole(ctx, id, account.RoleChatMuted); err != nil {
t.Fatalf("grant chat_muted: %v", err)
}
if b := byExt(); !b.Registered || b.Eligible {
t.Fatalf("chat_muted = %+v, want registered but not eligible", b)
}
// Suspension dominates: while chat_muted is set, lifting a concurrent suspension
// must not re-grant chat (the role still mutes).
if _, err := accounts.Suspend(ctx, id, nil, "", "", nil); err != nil {
t.Fatalf("suspend over mute: %v", err)
}
if b := byExt(); b.Eligible {
t.Fatalf("suspended+muted = %+v, want not eligible", b)
}
if err := accounts.LiftSuspension(ctx, id); err != nil {
t.Fatalf("lift over mute: %v", err)
}
if b := byExt(); b.Eligible {
t.Fatalf("lifted but still muted = %+v, want not eligible", b)
}
if err := accounts.RevokeRole(ctx, id, account.RoleChatMuted); err != nil {
t.Fatalf("revoke chat_muted: %v", err)
}
if b := byExt(); !b.Eligible {
t.Fatalf("after revoke = %+v, want eligible", b)
}
// An unknown Telegram identity is unregistered (and thus left muted).
if b := chatAccess(t, srv, `{"external_id":"tg-missing-`+uuid.NewString()+`"}`); b.Registered || b.Eligible {
t.Fatalf("unknown identity = %+v, want neither registered nor eligible", b)
}
// An account with no Telegram identity (a guest) carries an empty external_id, so
// the gateway has nothing to gate.
guest := provisionGuest(t)
if b := chatAccess(t, srv, `{"user_id":"`+guest.String()+`"}`); b.ExternalID != "" || b.Registered {
t.Fatalf("guest by user_id = %+v, want empty external_id and not registered", b)
}
// A request naming neither address is a bad request.
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/chat-access", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("empty query = %d, want 400", rec.Code)
}
}
// captureNotifier records every published intent so a test can assert which live
// events a console action emitted.
type captureNotifier struct {
mu sync.Mutex
intents []notify.Intent
}
func (c *captureNotifier) Publish(in ...notify.Intent) {
c.mu.Lock()
defer c.mu.Unlock()
c.intents = append(c.intents, in...)
}
// count returns how many intents of kind addressed to user were captured.
func (c *captureNotifier) count(user uuid.UUID, kind string) int {
c.mu.Lock()
defer c.mu.Unlock()
n := 0
for _, in := range c.intents {
if in.UserID == user && in.Kind == kind {
n++
}
}
return n
}
// TestChatAccessPublishedOnModeration drives the admin console and asserts each
// moderation action that can change chat eligibility — block, unblock, and the
// chat_muted role grant/revoke — emits the chat_access_changed signal the gateway
// turns into a chat-gate command.
func TestChatAccessPublishedOnModeration(t *testing.T) {
notifier := &captureNotifier{}
srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
DB: testDB,
Accounts: account.NewStore(testDB),
Games: newGameService(),
Registry: testRegistry,
DictDir: dictDir(),
Notifier: notifier,
})
h := srv.Handler()
id := provisionAccount(t)
base := "http://admin.test/_gm/users/" + id.String()
const origin = "http://admin.test"
steps := []struct {
name, path, body string
want string
}{
{"block", "/block", "duration=permanent", "Blocked"},
{"unblock", "/unblock", "", "Unblocked"},
{"grant chat_muted", "/grant-role", "role=chat_muted", "Role granted"},
{"revoke chat_muted", "/revoke-role", "role=chat_muted", "Role revoked"},
}
for i, s := range steps {
code, body := consoleDo(h, http.MethodPost, base+s.path, s.body, origin)
if code != http.StatusOK || !strings.Contains(body, s.want) {
t.Fatalf("%s = %d, has %q = %v", s.name, code, s.want, strings.Contains(body, s.want))
}
if got := notifier.count(id, notify.KindChatAccessChanged); got != i+1 {
t.Fatalf("after %s: chat_access_changed count = %d, want %d", s.name, got, i+1)
}
}
}
// TestChatAccessPublishedOnFirstRegistration checks that a Telegram first contact
// (the sessions/telegram endpoint creating the account) emits chat_access_changed —
// the re-grant for a user who joined the moderated chat before registering — and that
// a repeat login does not re-emit.
func TestChatAccessPublishedOnFirstRegistration(t *testing.T) {
notifier := &captureNotifier{}
srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
DB: testDB,
Accounts: account.NewStore(testDB),
Sessions: session.NewService(session.NewStore(testDB), session.NewCache()),
Notifier: notifier,
})
h := srv.Handler()
ext := "tg-" + uuid.NewString()
post := func() {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/sessions/telegram",
strings.NewReader(`{"external_id":"`+ext+`","language_code":"en","first_name":"Reg"}`))
req.Header.Set("Content-Type", "application/json")
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("telegram auth = %d: %s", rec.Code, rec.Body.String())
}
}
post()
acc, err := account.NewStore(testDB).AccountByIdentity(context.Background(), account.KindTelegram, ext)
if err != nil {
t.Fatalf("lookup: %v", err)
}
if got := notifier.count(acc.ID, notify.KindChatAccessChanged); got != 1 {
t.Fatalf("first registration: chat_access_changed count = %d, want 1", got)
}
// A repeat login (the account already exists) must not re-emit.
post()
if got := notifier.count(acc.ID, notify.KindChatAccessChanged); got != 1 {
t.Fatalf("repeat login: chat_access_changed count = %d, want still 1", got)
}
}
// TestSuspensionsExpiredBetween checks the sweeper's window query: a non-lifted
// temporary block whose expiry falls in the window is returned, while one outside the
// window, a permanent block, and a lifted block are not.
func TestSuspensionsExpiredBetween(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
// A temporary block whose expiry already lapsed at a known instant.
tempID := provisionAccount(t)
expiry := time.Now().Add(-time.Hour).Truncate(time.Second)
if _, err := accounts.Suspend(ctx, tempID, &expiry, "", "", nil); err != nil {
t.Fatalf("suspend temp: %v", err)
}
contains := func(ids []uuid.UUID, want uuid.UUID) bool {
for _, id := range ids {
if id == want {
return true
}
}
return false
}
// A window straddling the expiry returns the account.
got, err := accounts.SuspensionsExpiredBetween(ctx, expiry.Add(-time.Minute), expiry.Add(time.Minute))
if err != nil {
t.Fatalf("expired between: %v", err)
}
if !contains(got, tempID) {
t.Fatalf("window over expiry missing the lapsed block %s", tempID)
}
// A window entirely after the expiry does not.
got, err = accounts.SuspensionsExpiredBetween(ctx, expiry.Add(time.Minute), expiry.Add(2*time.Minute))
if err != nil {
t.Fatalf("expired between (after): %v", err)
}
if contains(got, tempID) {
t.Fatalf("window after expiry should not return %s", tempID)
}
// A permanent block never appears, even in a wide window.
permID := provisionAccount(t)
if _, err := accounts.Suspend(ctx, permID, nil, "", "", nil); err != nil {
t.Fatalf("suspend perm: %v", err)
}
// A lifted block does not appear either. The block must still be in force when lifted
// (LiftSuspension only lifts in-force blocks), so its expiry is in the future and the
// wide window below still covers it — yet lifted_at excludes it.
liftID := provisionAccount(t)
liftExpiry := time.Now().Add(30 * time.Minute).Truncate(time.Second)
if _, err := accounts.Suspend(ctx, liftID, &liftExpiry, "", "", nil); err != nil {
t.Fatalf("suspend lift: %v", err)
}
if err := accounts.LiftSuspension(ctx, liftID); err != nil {
t.Fatalf("lift: %v", err)
}
wide, err := accounts.SuspensionsExpiredBetween(ctx, time.Now().Add(-2*time.Hour), time.Now().Add(time.Hour))
if err != nil {
t.Fatalf("expired between (wide): %v", err)
}
if contains(wide, permID) {
t.Fatalf("permanent block %s must not be reported as expired", permID)
}
if contains(wide, liftID) {
t.Fatalf("lifted block %s must not be reported as expired", liftID)
}
}
+227
View File
@@ -0,0 +1,227 @@
//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"
"scrabble/backend/internal/social"
)
// TestChatUnreadAndMarkRead checks a text message marks every recipient seat unread (not the
// sender), surfaces through HasUnread / UnreadGames and the message-level HasUnreadMessage /
// UnreadMessageGames (it is a real message, not a nudge), and that MarkRead clears the reader's bit.
func TestChatUnreadAndMarkRead(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", ""); err != nil {
t.Fatalf("post: %v", err)
}
// The opponent has it unread; the sender does not.
if unread, _ := svc.HasUnread(ctx, gameID, seats[1]); !unread {
t.Error("recipient should have the message unread")
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); unread {
t.Error("sender should not have their own message unread")
}
if set, err := svc.UnreadGames(ctx, seats[1]); err != nil {
t.Fatalf("unread games: %v", err)
} else if !set[gameID] {
t.Error("UnreadGames should include the game for the recipient")
}
// A text message also raises the message-level flags (it is not just a nudge).
if msg, _ := svc.HasUnreadMessage(ctx, gameID, seats[1]); !msg {
t.Error("recipient should have the message flagged as an unread message")
}
if set, err := svc.UnreadMessageGames(ctx, seats[1]); err != nil {
t.Fatalf("unread message games: %v", err)
} else if !set[gameID] {
t.Error("UnreadMessageGames should include the game for a text message")
}
// Reading it clears the recipient's bit and reports one entry marked.
if n, err := svc.MarkRead(ctx, gameID, seats[1]); err != nil {
t.Fatalf("mark read: %v", err)
} else if n != 1 {
t.Errorf("marked %d, want 1", n)
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[1]); unread {
t.Error("message should be read after MarkRead")
}
// A second MarkRead is a no-op — nothing is left unread.
if n, _ := svc.MarkRead(ctx, gameID, seats[1]); n != 0 {
t.Errorf("second mark read marked %d, want 0", n)
}
}
// TestNudgeUnreadTargetsOnlyToMove checks, in a three-player game, that a nudge marks ONLY the
// awaited (to-move) seat unread — never the other waiting players. This is the owner's nudge
// targeting invariant, now observable through the unread bitmask. It also checks a nudge raises
// HasUnread but not the message-level flags, so the badge can colour it apart from a real message.
func TestNudgeUnreadTargetsOnlyToMove(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 3) // seat 0 is to move
// A waiting player nudges; only the awaited seat 0 is targeted.
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
t.Fatalf("nudge: %v", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); !unread {
t.Error("the awaited seat should have the nudge unread")
}
// A nudge is not a message: it must not raise the message-level flags.
if msg, _ := svc.HasUnreadMessage(ctx, gameID, seats[0]); msg {
t.Error("a nudge must not be flagged as an unread message")
}
if set, _ := svc.UnreadMessageGames(ctx, seats[0]); set[gameID] {
t.Error("UnreadMessageGames must not include a game whose only unread entry is a nudge")
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[2]); unread {
t.Error("a non-awaited waiting seat must not receive the nudge")
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[1]); unread {
t.Error("the nudging sender must not have it unread")
}
}
// TestTextMessageUnreadReachesAllOpponents checks a text message in a three-player game marks both
// opponents (every seated player but the sender) unread.
func TestTextMessageUnreadReachesAllOpponents(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 3) // seat 0 is to move and may chat
if _, err := svc.PostMessage(ctx, gameID, seats[0], "hello all", ""); err != nil {
t.Fatalf("post: %v", err)
}
for _, s := range []int{1, 2} {
if unread, _ := svc.HasUnread(ctx, gameID, seats[s]); !unread {
t.Errorf("seat %d should have the text message unread", s)
}
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); unread {
t.Error("the sender should not have their own message unread")
}
}
// TestNudgeClearedByMove checks the move path clears a nudge the actor answered by moving: the
// game service's wired NudgeClearer flips the awaited seat's nudge to read on commit.
func TestNudgeClearedByMove(t *testing.T) {
ctx := context.Background()
gameSvc, gameID, seats, play := newDraftGame(t)
socialSvc := newSocialService()
gameSvc.SetNudgeClearer(socialSvc.ClearNudges)
// The waiting seat 1 nudges the to-move seat 0.
if _, err := socialSvc.Nudge(ctx, gameID, seats[1]); err != nil {
t.Fatalf("nudge: %v", err)
}
if unread, _ := socialSvc.HasUnread(ctx, gameID, seats[0]); !unread {
t.Fatal("seat 0 should have the nudge unread before moving")
}
// Seat 0 answers by moving — the commit clears its pending nudge.
if _, err := gameSvc.SubmitPlay(ctx, gameID, seats[0], play.Tiles); err != nil {
t.Fatalf("submit play: %v", err)
}
if unread, _ := socialSvc.HasUnread(ctx, gameID, seats[0]); unread {
t.Error("the nudge should be cleared once the awaited player has moved")
}
}
// TestChatToRobotIsBornRead checks a text message to a disguised robot opponent (a pooled
// robot substituted into an ordinary, non-AI game) is born read: the robot never opens the
// chat, so the message must not linger unread (skewing the count and the read metric).
func TestChatToRobotIsBornRead(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
human := provisionAccount(t)
robot, err := account.NewStore(testDB).ProvisionRobot(ctx, "robot-chat-"+uuid.NewString(), "Robbie")
if err != nil {
t.Fatalf("provision robot: %v", err)
}
g, err := newGameService().Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID},
TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create game: %v", err)
}
// The human (seat 0, to move) chats the disguised robot; the message is born read.
msg, err := svc.PostMessage(ctx, g.ID, human, "good luck", "")
if err != nil {
t.Fatalf("post: %v", err)
}
if items, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: g.ID, UnreadOnly: true}, 50, 0); len(items) != 0 {
t.Errorf("a message to a robot should be born read, but the unread filter listed %d", len(items))
}
card, err := svc.AdminMessageDetail(ctx, msg.ID)
if err != nil {
t.Fatalf("detail: %v", err)
}
for _, s := range card.Seats {
if s.AccountID == robot.ID && s.Role != "read" {
t.Errorf("robot seat role = %q, want read", s.Role)
}
}
}
// TestAdminChatUnreadFilterAndDetail checks the admin unread-only filter and the per-seat read
// breakdown of the message card.
func TestAdminChatUnreadFilterAndDetail(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2)
msg, err := svc.PostMessage(ctx, gameID, seats[0], "good luck", "")
if err != nil {
t.Fatalf("post: %v", err)
}
// The unread-only filter (scoped to the game) lists the still-unread message.
if items, err := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, UnreadOnly: true}, 50, 0); err != nil {
t.Fatalf("admin list: %v", err)
} else if len(items) != 1 || items[0].ID != msg.ID {
t.Fatalf("unread filter = %+v, want the one message", items)
}
// The detail card marks the sender 'sender' and the recipient 'unread'.
card, err := svc.AdminMessageDetail(ctx, msg.ID)
if err != nil {
t.Fatalf("detail: %v", err)
}
roles := map[uuid.UUID]string{}
for _, s := range card.Seats {
roles[s.AccountID] = s.Role
}
if roles[seats[0]] != "sender" {
t.Errorf("sender role = %q, want sender", roles[seats[0]])
}
if roles[seats[1]] != "unread" {
t.Errorf("recipient role = %q, want unread", roles[seats[1]])
}
// After the recipient reads, the filter drops it and the seat flips to 'read'.
if _, err := svc.MarkRead(ctx, gameID, seats[1]); err != nil {
t.Fatalf("mark read: %v", err)
}
if items, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, UnreadOnly: true}, 50, 0); len(items) != 0 {
t.Errorf("unread filter should be empty after read, got %d", len(items))
}
card2, err := svc.AdminMessageDetail(ctx, msg.ID)
if err != nil {
t.Fatalf("detail after read: %v", err)
}
for _, s := range card2.Seats {
if s.AccountID == seats[1] && s.Role != "read" {
t.Errorf("recipient role after read = %q, want read", s.Role)
}
}
}
@@ -134,11 +134,13 @@ func TestDictionaryUpdateFlow(t *testing.T) {
// newGameServiceOn builds a game service over the shared pool but a caller-supplied
// dictionary directory, version and registry, for the isolated dictionary tests.
func newGameServiceOn(dir, version string, reg *engine.Registry) *game.Service {
return game.NewService(
svc := game.NewService(
game.NewStore(testDB), account.NewStore(testDB), reg,
game.Config{DictDir: dir, DictVersion: version, TimeoutSweepInterval: time.Minute, CacheTTL: time.Hour},
zap.NewNop(),
)
svc.SetFirstMoveEntropy(seatZeroFirstMove)
return svc
}
// seedDawgs copies the committed DAWG of every variant from src into dst (flat).
+17 -3
View File
@@ -6,6 +6,7 @@ import (
"context"
"errors"
"regexp"
"slices"
"testing"
"time"
@@ -134,12 +135,20 @@ func TestUpdateProfilePersists(t *testing.T) {
store := account.NewStore(testDB)
acc := provisionAccount(t)
// A fresh account defaults to Erudit only (the DB-level column default).
if def, err := store.GetByID(ctx, acc); err != nil {
t.Fatalf("get default: %v", err)
} else if want := []string{"erudit_ru"}; !slices.Equal(def.VariantPreferences, want) {
t.Errorf("default variant preferences = %v, want %v", def.VariantPreferences, want)
}
updated, err := store.UpdateProfile(ctx, acc, account.ProfileUpdate{
DisplayName: "Kaya",
PreferredLanguage: "ru",
TimeZone: "Europe/Moscow",
BlockChat: true,
BlockFriendRequests: true,
VariantPreferences: []string{"scrabble_en", "erudit_ru"},
})
if err != nil {
t.Fatalf("update profile: %v", err)
@@ -157,6 +166,10 @@ func TestUpdateProfilePersists(t *testing.T) {
if reloaded.TimeZone != "Europe/Moscow" || !reloaded.BlockChat {
t.Errorf("profile did not persist: %+v", reloaded)
}
// The text[] column round-trips and is stored canonically (Erudit-first).
if want := []string{"erudit_ru", "scrabble_en"}; !slices.Equal(reloaded.VariantPreferences, want) {
t.Errorf("variant preferences = %v, want %v", reloaded.VariantPreferences, want)
}
}
// TestUpdateProfileOffsetTimezone checks the UTC-offset timezone: it is
@@ -167,9 +180,10 @@ func TestUpdateProfileOffsetTimezone(t *testing.T) {
acc := provisionAccount(t)
updated, err := store.UpdateProfile(ctx, acc, account.ProfileUpdate{
DisplayName: "Kaya",
PreferredLanguage: "en",
TimeZone: "+03:00",
DisplayName: "Kaya",
PreferredLanguage: "en",
TimeZone: "+03:00",
VariantPreferences: []string{"erudit_ru"},
})
if err != nil {
t.Fatalf("update with offset timezone: %v", err)
+10 -25
View File
@@ -145,48 +145,33 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
}
}
func TestFeedbackSnapshotsLanguages(t *testing.T) {
func TestFeedbackSnapshotsLanguage(t *testing.T) {
ctx := context.Background()
svc := newFeedbackService()
acc := provisionAccount(t)
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.accounts SET preferred_language = 'en', service_language = 'ru' WHERE account_id = $1`, acc); err != nil {
t.Fatalf("set languages: %v", err)
`UPDATE backend.accounts SET preferred_language = 'en' WHERE account_id = $1`, acc); err != nil {
t.Fatalf("set language: %v", err)
}
// A Telegram (connector) message snapshots both the interface language and the bot.
// A message snapshots the sender's interface language at submit time.
if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", ""); err != nil {
t.Fatalf("submit: %v", err)
}
id := latestFeedbackID(t, svc, acc)
if m, err := svc.AdminGet(ctx, id); err != nil {
t.Fatalf("admin get: %v", err)
} else if m.Lang != "en" || m.ChannelLang != "ru" {
t.Fatalf("snapshot = lang %q / channel_lang %q, want en / ru", m.Lang, m.ChannelLang)
} else if m.Lang != "en" {
t.Fatalf("snapshot = lang %q, want en", m.Lang)
}
// Changing the account afterwards must not change the stored snapshot.
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.accounts SET preferred_language = 'ru', service_language = 'en' WHERE account_id = $1`, acc); err != nil {
t.Fatalf("change languages: %v", err)
`UPDATE backend.accounts SET preferred_language = 'ru' WHERE account_id = $1`, acc); err != nil {
t.Fatalf("change language: %v", err)
}
if m, err := svc.AdminGet(ctx, id); err != nil {
t.Fatal(err)
} else if m.Lang != "en" || m.ChannelLang != "ru" {
t.Fatalf("snapshot drifted after account change = lang %q / channel_lang %q", m.Lang, m.ChannelLang)
}
// A non-connector channel records no bot language even when the account has one.
acc2 := provisionAccount(t)
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.accounts SET preferred_language = 'en', service_language = 'ru' WHERE account_id = $1`, acc2); err != nil {
t.Fatalf("set languages 2: %v", err)
}
if err := svc.Submit(ctx, acc2, "from web", nil, "", "web", ""); err != nil {
t.Fatalf("submit web: %v", err)
}
if m, err := svc.AdminGet(ctx, latestFeedbackID(t, svc, acc2)); err != nil {
t.Fatal(err)
} else if m.Lang != "en" || m.ChannelLang != "" {
t.Fatalf("web snapshot = lang %q / channel_lang %q, want en / empty", m.Lang, m.ChannelLang)
} else if m.Lang != "en" {
t.Fatalf("snapshot drifted after account change = lang %q, want en", m.Lang)
}
}
@@ -0,0 +1,154 @@
//go:build integration
package inttest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// The first-move-draw suite covers the official seeding (docs/ARCHITECTURE.md §6): the draw is
// recorded with the game, decides seat 0 (the first mover), and — in auto-match — runs against
// a synthetic opponent at open time whose draw rows are back-filled when a real opponent joins.
// TestCreateRecordsFirstMoveDraws checks a directly-seated game records the draw against the
// real seated accounts and seats the drawn leader (the suite's deterministic draw elects the
// first-listed account) at seat 0.
func TestCreateRecordsFirstMoveDraws(t *testing.T) {
ctx := context.Background()
svc := newGameService()
a := provisionAccount(t)
b := provisionAccount(t)
g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: []uuid.UUID{a, b}, TurnTimeout: 24 * time.Hour, Seed: 1})
if err != nil {
t.Fatalf("create: %v", err)
}
if g.Seats[0].AccountID != a || g.Seats[1].AccountID != b {
t.Fatalf("seats = [%s %s], want [a b]", g.Seats[0].AccountID, g.Seats[1].AccountID)
}
draws, err := svc.SetupDraws(ctx, g.ID)
if err != nil {
t.Fatalf("setup draws: %v", err)
}
// seatZeroFirstMove resolves in one round: the first contender (a) draws a blank and wins.
if len(draws) != 2 {
t.Fatalf("draws = %d, want 2 (one round, two players)", len(draws))
}
if draws[0].Account != a || !draws[0].Blank {
t.Fatalf("draw[0] = %+v, want a having drawn a blank", draws[0])
}
if draws[1].Account != b {
t.Fatalf("draw[1] account = %s, want b", draws[1].Account)
}
}
// TestAutoMatchDrawBackfilledOnJoin checks an open auto-match game records the draw at open
// time against the synthetic opponent (a NULL account) and back-fills those rows to the real
// opponent when they join.
func TestAutoMatchDrawBackfilledOnJoin(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
starter := provisionAccount(t)
g := openGame(t, svc, starter, evenOpeningSeed(t))
draws, err := svc.SetupDraws(ctx, g.ID)
if err != nil {
t.Fatalf("setup draws: %v", err)
}
if len(draws) != 2 {
t.Fatalf("open draws = %d, want 2", len(draws))
}
var nullSeen, starterSeen bool
for _, d := range draws {
switch d.Account {
case uuid.Nil:
nullSeen = true
case starter:
starterSeen = true
}
}
if !nullSeen || !starterSeen {
t.Fatalf("open draws = %+v, want the starter and a NULL synthetic opponent", draws)
}
joiner := provisionAccount(t)
if _, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute), nil); err != nil || !joined {
t.Fatalf("join = (joined %v, err %v), want joined", joined, err)
}
after, err := svc.SetupDraws(ctx, g.ID)
if err != nil {
t.Fatalf("setup draws after join: %v", err)
}
for _, d := range after {
if d.Account == uuid.Nil {
t.Fatalf("draw still NULL after join: %+v", d)
}
if d.Account != starter && d.Account != joiner {
t.Fatalf("draw account %s is neither player", d.Account)
}
}
}
// TestReplayTimeline checks the admin replay timeline reconstructs the dealt racks and one
// played move: the step count, the drawn tiles, the bag remainder, the running score and the
// turn cursor.
func TestReplayTimeline(t *testing.T) {
ctx := context.Background()
svc := newGameService()
a := provisionAccount(t)
b := provisionAccount(t)
seed := evenOpeningSeed(t)
g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: []uuid.UUID{a, b}, 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 for the seed")
}
if _, err := svc.SubmitPlay(ctx, g.ID, a, hint.Tiles); err != nil {
t.Fatalf("play: %v", err)
}
tl, err := svc.ReplayTimeline(ctx, g.ID)
if err != nil {
t.Fatalf("replay timeline: %v", err)
}
if len(tl.Steps) != 2 {
t.Fatalf("steps = %d, want 2 (deal + one move)", len(tl.Steps))
}
deal := tl.Steps[0]
if deal.Move != nil {
t.Fatalf("step 0 must be the deal, got move %+v", deal.Move)
}
rackSize := len(deal.Racks[0])
if rackSize == 0 || len(deal.Racks) != 2 || len(deal.Racks[1]) != rackSize {
t.Fatalf("deal racks = %v, want two equal full racks", deal.Racks)
}
move := tl.Steps[1]
if move.Move == nil || move.Move.Action != "play" {
t.Fatalf("step 1 move = %+v, want a play", move.Move)
}
if len(move.Drawn) != len(hint.Tiles) {
t.Fatalf("drawn = %v, want %d refilled tiles", move.Drawn, len(hint.Tiles))
}
if move.BagLen != deal.BagLen-len(hint.Tiles) {
t.Fatalf("bag after play = %d, want %d", move.BagLen, deal.BagLen-len(hint.Tiles))
}
if len(move.Racks[0]) != rackSize {
t.Fatalf("mover rack after refill = %d, want %d", len(move.Racks[0]), rackSize)
}
if move.Scores[0] <= 0 {
t.Fatalf("seat 0 score after play = %d, want > 0", move.Scores[0])
}
if move.ToMove != 1 {
t.Fatalf("to_move after seat 0 play = %d, want 1", move.ToMove)
}
}
+190
View File
@@ -0,0 +1,190 @@
//go:build integration
package inttest
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap/zaptest"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/server"
)
// The game-limit suite covers the simultaneous quick-game cap (game.MaxActiveQuickGames):
// the counting rule (Store/Service.CountActiveQuickGames) and the HTTP gate that refuses a
// new game once the cap is reached, while accepting an incoming invitation stays allowed.
// TestCountActiveQuickGames checks the count includes active and open quick games (the
// honest-AI ones among them) and excludes finished games, friend games (created by
// invitation) and games the account is not seated in.
func TestCountActiveQuickGames(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
games := newGameService()
human := provisionAccount(t)
opp := provisionAccount(t)
if n := mustCount(t, games, human); n != 0 {
t.Fatalf("fresh account count = %d, want 0", n)
}
// An open (awaiting-opponent) quick game counts.
if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{
Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour,
}, time.Now().Add(time.Minute), nil); err != nil {
t.Fatalf("open quick game: %v", err)
}
// An active quick game and an honest-AI quick game both count (neither has an invitation row).
mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 1)
mustCreateQuick(t, games, []uuid.UUID{human, opp}, true, 2)
// A finished quick game does NOT count.
fin := mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 3)
if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET status='finished' WHERE game_id=$1`, fin); err != nil {
t.Fatalf("finish game: %v", err)
}
// A game the human is not seated in does NOT count.
mustCreateQuick(t, games, []uuid.UUID{opp, provisionAccount(t)}, false, 4)
// A friend game (created by invitation) does NOT count, even though it is active.
startFriendGame(t, human)
if n := mustCount(t, games, human); n != 3 {
t.Fatalf("active quick count = %d, want 3 (active + AI + open)", n)
}
}
// TestGameLimitGate drives the cap through the assembled HTTP server: under the cap the lobby
// reports at_game_limit false; at the cap it flips to true and both new-game endpoints (quick
// enqueue and invitation creation) are refused with 409 game_limit_reached, while accepting an
// incoming invitation is still allowed.
func TestGameLimitGate(t *testing.T) {
ctx := context.Background()
games := newGameService()
srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
DB: testDB,
Accounts: account.NewStore(testDB),
Games: games,
Matchmaker: newMatchmaker(t, newRobotService(t, newGameService()), time.Minute, 0),
Invitations: newInvitationService(),
})
human := provisionAccount(t)
opp := provisionAccount(t)
// Under the cap: the lobby reports the player is not limited.
if gamesListAtLimit(t, srv, human) {
t.Fatal("a fresh account must be under the game limit")
}
// Reach the cap with active quick games seating the human (no invitation row → quick).
for i := 0; i < game.MaxActiveQuickGames; i++ {
mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, int64(i+1))
}
// At the cap: the lobby flags it and both create paths are refused with the stable code.
if !gamesListAtLimit(t, srv, human) {
t.Fatalf("at %d games at_game_limit must be true", game.MaxActiveQuickGames)
}
// erudit_ru is in the default variant preferences, so the variant gate passes and the
// game-limit gate is what fires here.
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
t.Fatalf("enqueue at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
}
invBody := fmt.Sprintf(`{"variant":"erudit_ru","invitee_ids":[%q]}`, opp.String())
if rec := userPost(t, srv, "/api/v1/user/invitations", human, invBody); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
t.Fatalf("invitation at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
}
// Accepting an incoming invitation is never blocked, even at the cap: another player invites
// the capped human, who accepts over HTTP and the game starts (friend games do not count).
inviter := provisionAccount(t)
inv := newInvitationService()
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{human}, englishInvite())
if err != nil {
t.Fatalf("create invitation: %v", err)
}
if rec := userPost(t, srv, "/api/v1/user/invitations/"+invitation.ID.String()+"/accept", human, ""); rec.Code != http.StatusOK {
t.Fatalf("accept at limit = %d (%s), want 200 — accept must bypass the cap", rec.Code, rec.Body.String())
}
}
// mustCount returns the account's active-quick-game count, failing on error.
func mustCount(t *testing.T, games *game.Service, id uuid.UUID) int {
t.Helper()
n, err := games.CountActiveQuickGames(context.Background(), id)
if err != nil {
t.Fatalf("count active quick games: %v", err)
}
return n
}
// mustCreateQuick creates an active quick game (no invitation) seating the given accounts and
// returns its id; vsAI flags an honest-AI game (the service then applies the 7-day clock).
func mustCreateQuick(t *testing.T, games *game.Service, seats []uuid.UUID, vsAI bool, seed int64) uuid.UUID {
t.Helper()
p := game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed}
if vsAI {
p.VsAI = true
p.TurnTimeout = 0 // the service applies the 7-day AI inactivity clock for vs_ai games
}
g, err := games.Create(context.Background(), p)
if err != nil {
t.Fatalf("create quick game (vsAI=%v): %v", vsAI, err)
}
return g.ID
}
// startFriendGame has a fresh inviter invite the given account to a friend game and the invitee
// accept it, so the (active) friend game exists with its game_invitations row.
func startFriendGame(t *testing.T, invitee uuid.UUID) {
t.Helper()
ctx := context.Background()
inv := newInvitationService()
inviter := provisionAccount(t)
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite())
if err != nil {
t.Fatalf("create invitation: %v", err)
}
if _, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true); err != nil {
t.Fatalf("accept invitation: %v", err)
}
}
// gamesListAtLimit fetches /api/v1/user/games and returns its at_game_limit flag.
func gamesListAtLimit(t *testing.T, srv *server.Server, id uuid.UUID) bool {
t.Helper()
rec := userGet(t, srv, "/api/v1/user/games", id)
if rec.Code != http.StatusOK {
t.Fatalf("games.list status = %d, want 200", rec.Code)
}
var body struct {
AtGameLimit bool `json:"at_game_limit"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode games.list: %v", err)
}
return body.AtGameLimit
}
// userPost issues an authenticated JSON POST (X-User-ID) against the assembled server.
func userPost(t *testing.T, srv *server.Server, path string, id uuid.UUID, body string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("X-User-ID", id.String())
req.Header.Set("Content-Type", "application/json")
srv.Handler().ServeHTTP(rec, req)
return rec
}
+76 -4
View File
@@ -11,6 +11,7 @@ import (
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
@@ -120,8 +121,8 @@ func TestGameLifecycleAndStats(t *testing.T) {
t.Fatalf("final game not finished: %+v", last.Game)
}
w0, l0, d0, mg0, _, ok0 := readStats(t, seats[0])
w1, l1, d1, mg1, _, ok1 := readStats(t, seats[1])
w0, l0, d0, mg0, mw0, ok0 := readStats(t, seats[0])
w1, l1, d1, mg1, mw1, ok1 := readStats(t, seats[1])
if !ok0 || !ok1 {
t.Fatal("both players must have a stats row")
}
@@ -133,6 +134,52 @@ func TestGameLifecycleAndStats(t *testing.T) {
if !decisive && !draw {
t.Errorf("inconsistent W/L/D: p0(%d/%d/%d) p1(%d/%d/%d)", w0, l0, d0, w1, l1, d1)
}
// Each player who made a scoring play gets exactly one per-variant best move (only
// scrabble_en was played here); its score equals the aggregate max_word_points and it
// carries the decoded word as tiles.
accounts := account.NewStore(testDB)
for _, p := range []struct {
id uuid.UUID
maxWord int
}{{seats[0], mw0}, {seats[1], mw1}} {
if p.maxWord == 0 {
continue // a player who only passed has no best move
}
st, err := accounts.GetStats(ctx, p.id)
if err != nil {
t.Fatalf("get stats: %v", err)
}
// The player made at least one scoring play (maxWord > 0), so the moves aggregate is
// positive; no hints were taken in this greedy game, so the hints aggregate stays 0.
if st.Moves <= 0 {
t.Errorf("moves = %d, want > 0", st.Moves)
}
if st.HintsUsed != 0 {
t.Errorf("hints used = %d, want 0 (no hints taken)", st.HintsUsed)
}
if len(st.BestMoves) != 1 {
t.Fatalf("want one best move (only scrabble_en played), got %d: %+v", len(st.BestMoves), st.BestMoves)
}
bm := st.BestMoves[0]
if bm.Variant != engine.VariantEnglish.String() {
t.Errorf("best move variant = %q, want %q", bm.Variant, engine.VariantEnglish.String())
}
if bm.Score != p.maxWord {
t.Errorf("best move score %d != max_word_points %d", bm.Score, p.maxWord)
}
if len(bm.Tiles) == 0 {
t.Error("best move word is empty")
}
for _, tl := range bm.Tiles {
if tl.Letter == "" {
t.Error("best move tile has empty letter")
}
if tl.Blank && tl.Value != 0 {
t.Errorf("blank tile must score 0, got %d", tl.Value)
}
}
}
}
// TestReplayEquivalence plays a few moves through one service, then proves a
@@ -369,6 +416,15 @@ func TestHintPolicy(t *testing.T) {
if _, err := svc.Hint(ctx, g.ID, seats[0]); err != nil { // spends the allowance
t.Fatalf("first hint: %v", err)
}
// The allowance is spent before the wallet: with an empty wallet, the state now reports no
// hints left and a zero wallet, so the per-game allowance (HintsRemaining-WalletBalance) is 0.
st, err := svc.GameState(ctx, g.ID, seats[0])
if err != nil {
t.Fatalf("state: %v", err)
}
if st.HintsRemaining != 0 || st.WalletBalance != 0 {
t.Errorf("after allowance hint: hints=%d wallet=%d, want 0/0", st.HintsRemaining, st.WalletBalance)
}
if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
}
@@ -377,8 +433,18 @@ func TestHintPolicy(t *testing.T) {
if err != nil {
t.Fatalf("wallet hint: %v", err)
}
if res.HintsRemaining != 1 {
t.Errorf("hints remaining = %d, want 1", res.HintsRemaining)
// The allowance stays exhausted; the wallet dropped 2->1, and WalletBalance carries it alone.
if res.HintsRemaining != 1 || res.WalletBalance != 1 {
t.Errorf("wallet hint: hints=%d wallet=%d, want 1/1", res.HintsRemaining, res.WalletBalance)
}
// game_players.hints_used counts BOTH hints (1 allowance + 1 wallet) — the per-game total
// that feeds the player's lifetime hint statistics, not just the allowance.
st2, err := svc.GameState(ctx, g.ID, seats[0])
if err != nil {
t.Fatalf("state after wallet hint: %v", err)
}
if got := st2.Game.Seats[0].HintsUsed; got != 2 {
t.Errorf("hints_used = %d after allowance + wallet hint, want 2", got)
}
off, err := svc.Create(ctx, game.CreateParams{
@@ -477,6 +543,12 @@ func TestEvaluatePlayPreview(t *testing.T) {
if bad.Valid {
t.Error("disconnected play must be invalid")
}
// A non-seated account cannot preview: with the game warm in the live cache, the
// membership check runs against the cached seat list (the hot path that skips GetGame).
if _, err := svc.EvaluatePlay(ctx, g.ID, provisionAccount(t), hint.Tiles); !errors.Is(err, game.ErrNotAPlayer) {
t.Errorf("evaluate by a non-player = %v, want ErrNotAPlayer", err)
}
}
// TestConcurrentSubmitSerialized confirms the per-game lock lets only one of two
+36 -2
View File
@@ -26,9 +26,10 @@ import (
// 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.
// newGameService builds a game service over the shared pool and registry, with a
// deterministic first-move draw so the suite keeps a stable turn order.
func newGameService() *game.Service {
return game.NewService(
svc := game.NewService(
game.NewStore(testDB),
account.NewStore(testDB),
testRegistry,
@@ -40,6 +41,39 @@ func newGameService() *game.Service {
},
zap.NewNop(),
)
svc.SetFirstMoveEntropy(seatZeroFirstMove)
return svc
}
// seatZeroFirstMove is a first-move-draw entropy factory that always elects the first
// listed account as the leader, keeping the integration suite's turn order stable
// despite the real draw's randomness: the first contender draws a blank — the best
// possible tile — and wins outright, the rest draw the first remaining tile. It is a
// factory so each game restarts the one-shot "blank" pick.
func seatZeroFirstMove() func(n int) (int, error) {
first := true
return func(n int) (int, error) {
if first {
first = false
return n - 1, nil // a blank sits last in the bag → best rank
}
return 0, nil
}
}
// seatOneFirstMove is a first-move-draw entropy factory that elects the second contender (in
// auto-match, the synthetic opponent) as the leader, so the caller is seated at seat 1: the
// first contender draws the first remaining tile and the second draws a blank, winning.
func seatOneFirstMove() func(n int) (int, error) {
pick := 0
return func(n int) (int, error) {
i := 0
if pick == 1 {
i = n - 1 // the second contender draws a blank → best rank
}
pick++
return i, nil
}
}
// newSocialService builds a social service over the shared pool, reading game
@@ -0,0 +1,134 @@
//go:build integration
package inttest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// TestListForLobbyDropsLeftAIGames covers the lobby filter (game.Service.ListForLobby): a
// finished honest-AI game the player left — by resigning or by abandoning to the inactivity
// timeout — drops out of the lobby list, while it stays in ListForAccount so the admin console
// and the account-merge count keep the full set. A normally finished AI game, an active AI game
// and a left human game all stay in the lobby. The drop is a game property, not a per-resigner
// one, so it leaves every seat's lobby.
func TestListForLobbyDropsLeftAIGames(t *testing.T) {
ctx := context.Background()
svc := newGameService()
createAI := func(t *testing.T) (uuid.UUID, []uuid.UUID) {
t.Helper()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: 1, VsAI: true,
})
if err != nil {
t.Fatalf("create AI game: %v", err)
}
return g.ID, seats
}
t.Run("resign dropped from lobby, kept in account list", func(t *testing.T) {
gid, seats := createAI(t)
me := seats[0]
res, err := svc.Resign(ctx, gid, me)
if err != nil {
t.Fatalf("resign: %v", err)
}
if res.Game.EndReason != "resign" {
t.Fatalf("end reason = %q, want resign", res.Game.EndReason)
}
if lobbyHas(t, svc, me, gid) {
t.Error("resigned AI game still in the lobby list")
}
if !containsGame(t, svc, me, gid) {
t.Error("resigned AI game missing from the full account list (the row must stay)")
}
if lobbyHas(t, svc, seats[1], gid) {
t.Error("resigned AI game still in the opponent's lobby list (drop is per-game)")
}
})
t.Run("timeout dropped from lobby", func(t *testing.T) {
gid, seats := createAI(t)
me := seats[0] // seat 0 is to move and will be timed out
// Disable the away window so the sweep is deterministic regardless of wall clock.
setAway(t, me, "UTC", "00:00", "00:00")
backdate(t, gid, time.Now().UTC().Add(-(game.AIInactivityTimeout + 2*time.Hour)))
if n, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
t.Fatalf("sweep swept %d (err %v), want >= 1", n, err)
}
if status, reason := gameStatus(t, svc, gid); status != game.StatusFinished || reason != "timeout" {
t.Fatalf("game not timed out: status %q reason %q", status, reason)
}
if lobbyHas(t, svc, me, gid) {
t.Error("abandoned (timed-out) AI game still in the lobby list")
}
})
t.Run("active AI game stays in lobby", func(t *testing.T) {
gid, seats := createAI(t)
if !lobbyHas(t, svc, seats[0], gid) {
t.Error("active AI game missing from the lobby list")
}
})
t.Run("normally finished AI game stays in lobby", func(t *testing.T) {
gid, seats := createAI(t)
var fin game.MoveResult
for i := 0; i < 8; i++ { // six consecutive passes end the game scoreless
r, err := svc.Pass(ctx, gid, seats[i%2])
if err != nil {
t.Fatalf("pass %d: %v", i, err)
}
fin = r
if r.Game.Status == game.StatusFinished {
break
}
}
if fin.Game.Status != game.StatusFinished || fin.Game.EndReason != "scoreless" {
t.Fatalf("AI game not scoreless-finished: status %q reason %q", fin.Game.Status, fin.Game.EndReason)
}
if !lobbyHas(t, svc, seats[0], gid) {
t.Error("normally finished AI game must stay in the lobby list")
}
})
t.Run("left human game stays in lobby", func(t *testing.T) {
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 human game: %v", err)
}
if _, err := svc.Resign(ctx, g.ID, seats[0]); err != nil {
t.Fatalf("resign: %v", err)
}
if !lobbyHas(t, svc, seats[0], g.ID) {
t.Error("resigned human game must stay in the lobby list (only AI games are dropped)")
}
})
}
// lobbyHas reports whether the account's own lobby list (ListForLobby) includes gameID.
func lobbyHas(t *testing.T, svc *game.Service, accountID, gameID uuid.UUID) bool {
t.Helper()
games, err := svc.ListForLobby(context.Background(), accountID)
if err != nil {
t.Fatalf("list for lobby: %v", err)
}
for _, g := range games {
if g.ID == gameID {
return true
}
}
return false
}
+39 -10
View File
@@ -74,9 +74,11 @@ func TestMatchmakingOpensThenJoins(t *testing.T) {
}
}
// TestMatchmakingReEnqueueReturnsOwnOpenGame checks a re-enqueue is idempotent: the
// caller gets their existing open game rather than a second one.
func TestMatchmakingReEnqueueReturnsOwnOpenGame(t *testing.T) {
// TestMatchmakingReEnqueueOpensNewGame checks a re-enqueue opens a fresh open game
// rather than returning the caller's existing one: tapping "random opponent" again while
// still waiting starts a new search instead of bouncing the player back into the pending
// game. Both games stay open until an opponent (a human or the reaper's robot) joins each.
func TestMatchmakingReEnqueueOpensNewGame(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second)
@@ -90,8 +92,13 @@ func TestMatchmakingReEnqueueReturnsOwnOpenGame(t *testing.T) {
if err != nil {
t.Fatalf("re-enqueue: %v", err)
}
if r2.Game.ID != r1.Game.ID || r2.Matched {
t.Fatalf("re-enqueue = (game %s, matched %v), want the same open game %s unmatched", r2.Game.ID, r2.Matched, r1.Game.ID)
if r2.Matched || r2.Game.ID == r1.Game.ID {
t.Fatalf("re-enqueue = (game %s, matched %v), want a new open game distinct from %s, unmatched", r2.Game.ID, r2.Matched, r1.Game.ID)
}
for _, g := range []uuid.UUID{r1.Game.ID, r2.Game.ID} {
if _, _, status, err := newGameService().Participants(ctx, g); err != nil || status != "open" {
t.Fatalf("game %s status = %q err %v, want open", g, status, err)
}
}
}
@@ -191,14 +198,36 @@ func TestInvitationLazyExpiry(t *testing.T) {
func TestInvitationBlockedInvitee(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
social := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
soc := newSocialService()
// The inviter has blocked an invitee: the invitation is refused outright (the inviter is aware).
inviter := provisionAccount(t)
invitee := provisionAccount(t)
if err := social.Block(ctx, invitee, inviter); err != nil {
blockedInvitee := provisionAccount(t)
if err := soc.Block(ctx, inviter, blockedInvitee); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
t.Fatalf("create blocked = %v, want ErrInvitationBlocked", err)
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{blockedInvitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
t.Fatalf("create with a blocked invitee = %v, want ErrInvitationBlocked", err)
}
// An invitee who has blocked the inviter is instead suppressed: the invitation is created
// (no error — the inviter notices nothing), but that invitee is never notified and never
// sees it in their list.
inviter2 := provisionAccount(t)
suppressed := provisionAccount(t)
if err := soc.Block(ctx, suppressed, inviter2); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.CreateInvitation(ctx, inviter2, []uuid.UUID{suppressed}, englishInvite()); err != nil {
t.Fatalf("create with a suppressing invitee = %v, want nil", err)
}
if pub.notified(suppressed, notify.NotifyInvitation) {
t.Error("an invitee who blocked the inviter must not be notified of the invitation")
}
if list, _ := svc.ListInvitations(ctx, suppressed); len(list) != 0 {
t.Errorf("suppressed invitee's invitation list = %v, want empty", list)
}
}
+55
View File
@@ -30,6 +30,34 @@ func setStats(t *testing.T, id uuid.UUID, w, l, d, mg, mw int) {
}
}
func setStatsCounts(t *testing.T, id uuid.UUID, moves, hints int) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE backend.account_stats SET moves=$2, hints_used=$3 WHERE account_id=$1`, id, moves, hints); err != nil {
t.Fatalf("set stats counts: %v", err)
}
}
func setBestMove(t *testing.T, id uuid.UUID, variant string, score int) {
t.Helper()
tiles := `[{"letter":"c","value":3,"blank":false}]`
if _, err := testDB.ExecContext(context.Background(),
`INSERT INTO backend.account_best_move (account_id, variant, score, tiles) VALUES ($1,$2,$3,$4)
ON CONFLICT (account_id, variant) DO UPDATE SET score=$3, tiles=$4`, id, variant, score, tiles); err != nil {
t.Fatalf("set best move: %v", err)
}
}
func bestMoveCount(t *testing.T, id uuid.UUID) int {
t.Helper()
var n int
if err := testDB.QueryRowContext(context.Background(),
`SELECT count(*) FROM backend.account_best_move WHERE account_id=$1`, id).Scan(&n); err != nil {
t.Fatalf("best move count: %v", err)
}
return n
}
func setWallet(t *testing.T, id uuid.UUID, hints int, paid bool) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
@@ -110,8 +138,15 @@ func TestAccountMergeCore(t *testing.T) {
setStats(t, primary, 1, 0, 0, 100, 90)
setStats(t, secondary, 3, 1, 2, 400, 80)
setStatsCounts(t, primary, 100, 5)
setStatsCounts(t, secondary, 50, 8)
setWallet(t, primary, 2, false)
setWallet(t, secondary, 5, true)
// Best moves: secondary's scrabble_en (80) beats primary's (50) and is kept; secondary's
// scrabble_ru (30) is new to primary and carried over.
setBestMove(t, primary, "scrabble_en", 50)
setBestMove(t, secondary, "scrabble_en", 80)
setBestMove(t, secondary, "scrabble_ru", 30)
email := "merge-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, secondary, email)
@@ -153,6 +188,26 @@ func TestAccountMergeCore(t *testing.T) {
if mergedInto(t, secondary) != primary {
t.Errorf("secondary.merged_into = %s, want primary %s", mergedInto(t, secondary), primary)
}
// The moves/hints counters sum, and the per-variant best moves merge (the higher score
// per variant kept), with the secondary's best-move rows cleaned up.
st, err := store.GetStats(ctx, primary)
if err != nil {
t.Fatalf("get merged stats: %v", err)
}
if st.Moves != 150 || st.HintsUsed != 13 {
t.Errorf("primary moves/hints = %d/%d, want 150/13", st.Moves, st.HintsUsed)
}
best := map[string]int{}
for _, b := range st.BestMoves {
best[b.Variant] = b.Score
}
if len(st.BestMoves) != 2 || best["scrabble_en"] != 80 || best["scrabble_ru"] != 30 {
t.Errorf("primary best moves = %+v, want scrabble_en:80 + scrabble_ru:30", st.BestMoves)
}
if bestMoveCount(t, secondary) != 0 {
t.Error("secondary best-move rows should be deleted after merge")
}
}
// TestAccountMergeActiveGameConflict refuses a merge when the two share an active
+17 -14
View File
@@ -18,8 +18,9 @@ import (
// The open-game suite covers an auto-match game that a player enters immediately and
// waits inside (status 'open', the opponent seat empty) until a human or a robot joins.
// evenOpeningSeed returns an even seed (so OpenOrJoin seats the starter at seat 0, which
// moves first) whose fresh two-player English opening rack has a legal move.
// evenOpeningSeed returns a seed whose fresh two-player English opening rack (seat 0) has a
// legal move, for tests that drive or attempt an opening play. It scans even seeds; seat
// order no longer depends on the seed — the first-move draw decides it (docs/ARCHITECTURE.md §6).
func evenOpeningSeed(t *testing.T) int64 {
t.Helper()
for seed := int64(2); seed <= 400; seed += 2 {
@@ -49,7 +50,7 @@ func openParams(seed int64) game.CreateParams {
func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) game.Game {
t.Helper()
g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute))
g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute), nil)
if err != nil {
t.Fatalf("open game: %v", err)
}
@@ -59,9 +60,9 @@ func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) ga
return g
}
// TestOpenGameStarterMovesThenWaits checks the starter (seat 0) may move on their turn
// while the game is open, after which it is the empty opponent seat's turn and the
// starter just waits.
// TestOpenGameStarterMovesThenWaits checks the starter may move on their turn while the game
// is open (the first-move draw, run when the game opened, put them at seat 0), after which it
// is the empty opponent seat's turn and the starter just waits.
func TestOpenGameStarterMovesThenWaits(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
@@ -89,28 +90,30 @@ func TestOpenGameStarterMovesThenWaits(t *testing.T) {
}
}
// TestOpenGameStarterWaitsWhenOpponentMovesFirst checks that when the starter is seated
// at seat 1 (odd seed), the still-empty seat 0 is to move, so the starter cannot act.
// TestOpenGameStarterWaitsWhenOpponentMovesFirst checks that when the first-move draw seats
// the starter at seat 1 (the synthetic opponent won the open draw), the still-empty seat 0 is
// to move, so the starter cannot act until an opponent fills it.
func TestOpenGameStarterWaitsWhenOpponentMovesFirst(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
svc.SetFirstMoveEntropy(seatOneFirstMove) // the synthetic opponent wins → the caller sits at seat 1
starter := provisionAccount(t)
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute))
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute), nil)
if err != nil {
t.Fatalf("open: %v", err)
}
if g.ToMove != 0 || g.Seats[0].AccountID != uuid.Nil {
t.Fatalf("odd-seed open game: to_move %d seat0 %s, want the empty seat 0 to move", g.ToMove, g.Seats[0].AccountID)
t.Fatalf("opponent-won open draw: to_move %d seat0 %s, want the empty seat 0 to move", g.ToMove, g.Seats[0].AccountID)
}
if _, err := svc.Pass(ctx, g.ID, starter); !errors.Is(err, game.ErrNotYourTurn) {
t.Fatalf("starter acting on the empty seat's turn = %v, want ErrNotYourTurn", err)
}
}
// TestOpenGameJoinAfterStarterMoved checks a second human joins an open game even after
// the starter has already made their first move, landing on the board mid-opening and
// able to act on their turn.
// TestOpenGameJoinAfterStarterMoved checks a second human joins an open game even after the
// starter has already made their first move: the seats were fixed by the draw at open, so the
// joiner takes the empty seat without disturbing the journal, and can act on their turn.
func TestOpenGameJoinAfterStarterMoved(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
@@ -128,7 +131,7 @@ func TestOpenGameJoinAfterStarterMoved(t *testing.T) {
}
joiner := provisionAccount(t)
g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute))
g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute), nil)
if err != nil {
t.Fatalf("join: %v", err)
}
@@ -0,0 +1,82 @@
//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"
)
// TestRobotFriendRequestIsPerGameAndReaped checks that sending a friend request to a
// disguised-robot opponent is recorded per-game in robot_friend_requests — never in the
// friendships table or against the shared robot account — that a re-send is idempotent, and
// that the reaper deletes the row once its game has been finished past the retention window
// while keeping the row for an active game.
func TestRobotFriendRequestIsPerGameAndReaped(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)
}
g, err := newGameService().Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID},
TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create game: %v", err)
}
if err := svc.RequestInGame(ctx, human, robot.ID, g.ID); err != nil {
t.Fatalf("request robot: %v", err)
}
rfr, err := svc.ListRobotFriendRequests(ctx, human)
if err != nil {
t.Fatalf("list robot friend requests: %v", err)
}
if len(rfr) != 1 || rfr[0].GameID != g.ID || rfr[0].Seat != 1 {
t.Fatalf("robot friend requests = %v, want one for game %s seat 1", rfr, g.ID)
}
// The friendships table must stay empty: no outgoing request against the shared robot account.
if out, _ := svc.ListOutgoingRequests(ctx, human); len(out) != 0 {
t.Errorf("friendships outgoing must be empty for a robot request, got %v", out)
}
// A re-send to the same game+seat is idempotent (the UNIQUE constraint collapses it).
if err := svc.RequestInGame(ctx, human, robot.ID, g.ID); err != nil {
t.Fatalf("re-request robot: %v", err)
}
if rfr, _ := svc.ListRobotFriendRequests(ctx, human); len(rfr) != 1 {
t.Fatalf("re-request must not duplicate, got %v", rfr)
}
// While the game is active the reaper keeps the row.
if n, err := svc.ReapExpiredRobotFriendRequests(ctx); err != nil || n != 0 {
t.Fatalf("reap on active game = (%d, %v), want (0, nil)", n, err)
}
if rfr, _ := svc.ListRobotFriendRequests(ctx, human); len(rfr) != 1 {
t.Fatalf("active-game request must survive the reaper, got %v", rfr)
}
// Finish the game well past the retention window: the reaper now deletes the row.
finished := time.Now().UTC().Add(-8 * 24 * time.Hour)
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.games SET status='finished', finished_at=$1 WHERE game_id=$2`, finished, g.ID); err != nil {
t.Fatalf("finish game: %v", err)
}
if n, err := svc.ReapExpiredRobotFriendRequests(ctx); err != nil || n != 1 {
t.Fatalf("reap on long-finished game = (%d, %v), want (1, nil)", n, err)
}
if rfr, _ := svc.ListRobotFriendRequests(ctx, human); len(rfr) != 0 {
t.Errorf("long-finished request not reaped: %v", rfr)
}
}
+132 -9
View File
@@ -181,8 +181,8 @@ func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) {
}
}
// 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.
// TestRobotProactiveNudge checks the robot's flat proactive-nudge schedule on the human's
// turn: nothing before the 9 h floor of the 9-12 h gap, exactly one once past its 12 h ceiling.
func TestRobotProactiveNudge(t *testing.T) {
ctx := context.Background()
svc := newGameService()
@@ -206,18 +206,20 @@ func TestRobotProactiveNudge(t *testing.T) {
t.Fatalf("create: %v", err)
}
// 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)
// A noon turn start. The drive instants below sit at UTC hours 20:00 and (next day) 12:00,
// both in [10:00, 20:00] so the robot is awake for every ±3h sleep drift — the gap, not the
// sleep window, decides the outcome. No nudge at 8 h idle (before the 9 h floor); exactly one
// at 24 h idle (well past the 12 h ceiling, however the seed drew within 9-12 h).
start := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
setTurnStarted(t, g.ID, start)
robots.Drive(ctx, start.Add(30*time.Minute))
robots.Drive(ctx, start.Add(8*time.Hour))
if n := countNudges(t, g.ID, robotID); n != 0 {
t.Errorf("robot nudges = %d at 30m idle, want 0 (before the first gap)", n)
t.Errorf("robot nudges = %d at 8h idle, want 0 (before the 9h floor)", n)
}
robots.Drive(ctx, start.Add(2*time.Hour))
robots.Drive(ctx, start.Add(24*time.Hour))
if n := countNudges(t, g.ID, robotID); n != 1 {
t.Errorf("robot nudges = %d at 2h idle, want 1 (after the first gap)", n)
t.Errorf("robot nudges = %d at 24h idle, want 1 (past the 12h ceiling)", n)
}
}
@@ -249,3 +251,124 @@ func playHuman(t *testing.T, ctx context.Context, svc *game.Service, gameID, hum
t.Fatalf("human pass: %v", err)
}
}
// countMoves returns the number of committed moves in a game's journal.
func countMoves(t *testing.T, gameID uuid.UUID) int {
t.Helper()
var n int
if err := testDB.QueryRowContext(context.Background(),
`SELECT count(*) FROM backend.game_moves WHERE game_id = $1`, gameID).Scan(&n); err != nil {
t.Fatalf("count moves: %v", err)
}
return n
}
// setLastTwoMoveTimes rewrites the created_at of a game's two most recent journal entries
// so the endgame think-time anchor (their gap, the human's last-move think time) is a known
// value, independent of the millisecond spacing of the manufactured passes.
func setLastTwoMoveTimes(t *testing.T, gameID uuid.UUID, prevAt, lastAt time.Time) {
t.Helper()
ctx := context.Background()
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.game_moves SET created_at = $2
WHERE game_id = $1 AND seq = (SELECT max(seq) FROM backend.game_moves WHERE game_id = $1)`,
gameID, lastAt); err != nil {
t.Fatalf("set last move time: %v", err)
}
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.game_moves SET created_at = $2
WHERE game_id = $1 AND seq = (SELECT max(seq) - 1 FROM backend.game_moves WHERE game_id = $1)`,
gameID, prevAt); err != nil {
t.Fatalf("set prev move time: %v", err)
}
}
// TestRobotEndgamePassShrinksThinkTime checks the dead-drawn-endgame shrink end to end: when
// the two most recent moves are both passes, RobotTurns reports EndgamePass with the human's
// last-move think time (OppLastMove), and the driver answers on the shortened schedule — well
// before the normal late-game delay — rather than dragging out the decided game. The journal
// state is manufactured with direct passes so the test isolates the timing mechanism (the
// SQL anchor and the driver gate) from a full game to an empty bag.
func TestRobotEndgamePassShrinksThinkTime(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)
}
human := provisionAccount(t)
seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robotID},
TurnTimeout: 24 * time.Hour, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
const robotSeat = 1
store := game.NewStore(testDB)
turnOf := func(id uuid.UUID) game.RobotTurn {
t.Helper()
turns, err := store.RobotTurns(ctx, []uuid.UUID{robotID})
if err != nil {
t.Fatalf("robot turns: %v", err)
}
for _, rt := range turns {
if rt.GameID == id {
return rt
}
}
t.Fatalf("no robot turn for game %s", id)
return game.RobotTurn{}
}
// One pass so far (the human): not yet a double-pass endgame.
if _, err := svc.Pass(ctx, g.ID, human); err != nil {
t.Fatalf("human pass 1: %v", err)
}
if rt := turnOf(g.ID); rt.EndgamePass {
t.Fatalf("EndgamePass after a single pass = true, want false")
}
// Robot then human pass: the two most recent moves are now both passes and it is the
// robot's turn — the guaranteed-pass endgame state.
if _, err := svc.Pass(ctx, g.ID, robotID); err != nil {
t.Fatalf("robot pass: %v", err)
}
if _, err := svc.Pass(ctx, g.ID, human); err != nil {
t.Fatalf("human pass 2: %v", err)
}
if _, toMove, _, err := svc.Participants(ctx, g.ID); err != nil || toMove != robotSeat {
t.Fatalf("after two passes: toMove %d err %v, want robot seat %d", toMove, err, robotSeat)
}
// Anchor the human's last-move think time to 60s and start the robot's turn at daytime.
setLastTwoMoveTimes(t, g.ID, daytime.Add(-60*time.Second), daytime)
setTurnStarted(t, g.ID, daytime)
rt := turnOf(g.ID)
if !rt.EndgamePass {
t.Fatalf("EndgamePass after a double pass = false, want true")
}
if d := rt.OppLastMove; d < 59*time.Second || d > 61*time.Second {
t.Fatalf("OppLastMove = %s, want ~60s", d)
}
// The normal schedule for this move is at least the early band floor (~3.75 min); the
// 60s-anchored endgame delay is at most 90s for every seed. Driving at +150s is past the
// shrunk delay but well before the normal one, so the robot acts only because of the shrink.
before := countMoves(t, g.ID)
robots.Drive(ctx, daytime.Add(150*time.Second))
if after := countMoves(t, g.ID); after != before+1 {
t.Fatalf("robot did not act on the shrunk endgame schedule: moves %d → %d (want +1)", before, after)
}
if _, toMove, _, err := svc.Participants(ctx, g.ID); err != nil || toMove == robotSeat {
t.Fatalf("after the shrunk move: still the robot's turn (toMove %d, err %v)", toMove, err)
}
}
@@ -37,7 +37,7 @@ func TestSeatNameFrozenAfterRename(t *testing.T) {
starter := provisionAccount(t)
if _, err := accounts.UpdateProfile(ctx, starter, account.ProfileUpdate{
DisplayName: "Original Name", PreferredLanguage: "en", TimeZone: "UTC",
DisplayName: "Original Name", PreferredLanguage: "en", TimeZone: "UTC", VariantPreferences: []string{"erudit_ru"},
}); err != nil {
t.Fatalf("set name: %v", err)
}
@@ -50,7 +50,7 @@ func TestSeatNameFrozenAfterRename(t *testing.T) {
// Rename the account; the snapshot on the already-taken seat must not follow.
if _, err := accounts.UpdateProfile(ctx, starter, account.ProfileUpdate{
DisplayName: "Renamed Player99", PreferredLanguage: "en", TimeZone: "UTC",
DisplayName: "Renamed Player99", PreferredLanguage: "en", TimeZone: "UTC", VariantPreferences: []string{"erudit_ru"},
}); err != nil {
t.Fatalf("rename: %v", err)
}
+67 -34
View File
@@ -45,6 +45,20 @@ func (c *capturePublisher) notified(user uuid.UUID, sub string) bool {
return false
}
// delivered reports whether an intent of the given top-level kind (e.g. chat_message,
// nudge) was published to user — used to assert that a blocked sender's message or nudge
// never reaches the blocker.
func (c *capturePublisher) delivered(user uuid.UUID, kind string) bool {
c.mu.Lock()
defer c.mu.Unlock()
for _, in := range c.intents {
if in.UserID == user && in.Kind == kind {
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.
@@ -121,24 +135,60 @@ func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) {
// Toggle: the addressee does not accept friend requests.
a, b := provisionAccount(t), provisionAccount(t)
if _, err := store.UpdateProfile(ctx, b, account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockFriendRequests: true}); err != nil {
if _, err := store.UpdateProfile(ctx, b, account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockFriendRequests: true, VariantPreferences: []string{"erudit_ru"}}); err != nil {
t.Fatalf("set toggle: %v", err)
}
if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestBlocked) {
t.Fatalf("toggle send = %v, want ErrRequestBlocked", err)
}
// Block: the addressee has blocked the requester.
c, d := provisionAccount(t), provisionAccount(t)
if err := svc.Block(ctx, d, c); err != nil {
// Block from the requester's own side: a requester who has blocked the addressee is
// refused outright (they are the blocker and aware of it). The reverse direction — the
// addressee having blocked the requester — is instead silently suppressed, covered by
// TestFriendRequestFromBlockedIsSuppressed.
_, seats := newGameWithSeats(t, 2)
c, d := seats[0], seats[1]
if err := svc.Block(ctx, c, d); err != nil {
t.Fatalf("block: %v", err)
}
if err := svc.SendFriendRequest(ctx, c, d); !errors.Is(err, social.ErrRequestBlocked) {
t.Fatalf("blocked send = %v, want ErrRequestBlocked", err)
t.Fatalf("blocker's own request = %v, want ErrRequestBlocked", err)
}
}
func TestBlockSeversFriendship(t *testing.T) {
// TestFriendRequestFromBlockedIsSuppressed checks the store-but-hide path: when the
// addressee has blocked the requester, the requester's friend request succeeds silently
// (no error — they must not notice the block), is stored, but is never delivered to nor
// surfaced for the blocker.
func TestFriendRequestFromBlockedIsSuppressed(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
_, seats := newGameWithSeats(t, 2)
blocker, blocked := seats[0], seats[1]
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if err := svc.SendFriendRequest(ctx, blocked, blocker); err != nil {
t.Fatalf("suppressed request = %v, want nil", err)
}
if pub.notified(blocker, notify.NotifyFriendRequest) {
t.Error("blocker must not be notified of a suppressed request")
}
if got, _ := svc.ListIncomingRequests(ctx, blocker); len(got) != 0 {
t.Errorf("blocker incoming = %v, want none (suppressed)", got)
}
// The blocked user sees an ordinary outgoing request — no leak of the block.
if got, _ := svc.ListOutgoingRequests(ctx, blocked); len(got) != 1 || got[0] != blocker {
t.Errorf("blocked outgoing = %v, want [blocker]", got)
}
}
// TestBlockKeepsFriendshipHiddenFromBlocker checks a block overrides but does not delete a
// friendship: the blocker stops seeing the blocked friend, the blocked user's own list is
// unchanged (so they never notice), and an unblock cleanly restores the friendship.
func TestBlockKeepsFriendshipHiddenFromBlocker(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
_, seats := newGameWithSeats(t, 2)
@@ -153,7 +203,16 @@ func TestBlockSeversFriendship(t *testing.T) {
t.Fatalf("block: %v", err)
}
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 0 {
t.Errorf("friendship must be severed by a block, got %v", friends)
t.Errorf("blocker's friend list = %v, want the blocked friend hidden", friends)
}
if friends, _ := svc.ListFriends(ctx, b); len(friends) != 1 || friends[0] != a {
t.Errorf("blocked user's friend list = %v, want [blocker] unchanged", friends)
}
if err := svc.Unblock(ctx, a, b); err != nil {
t.Fatalf("unblock: %v", err)
}
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 1 || friends[0] != b {
t.Errorf("after unblock, blocker's friend list = %v, want [b] restored", friends)
}
}
@@ -294,7 +353,7 @@ func TestChatPostListAndBlocks(t *testing.T) {
if _, err := svc.PostMessage(ctx, other, seats2[0], "hi", ""); err != nil {
t.Fatalf("post 2: %v", err)
}
if _, err := store.UpdateProfile(ctx, seats2[1], account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockChat: true}); err != nil {
if _, err := store.UpdateProfile(ctx, seats2[1], account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockChat: true, VariantPreferences: []string{"erudit_ru"}}); err != nil {
t.Fatalf("set block_chat: %v", err)
}
if msgs, _ := svc.Messages(ctx, other, seats2[1]); len(msgs) != 0 {
@@ -529,32 +588,6 @@ func TestRespondPublishesToRequester(t *testing.T) {
}
}
// 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) {
@@ -38,7 +38,7 @@ func TestSuspensionGate(t *testing.T) {
Accounts: accounts,
})
acc, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked")
acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked")
if err != nil {
t.Fatalf("provision: %v", err)
}
+1 -1
View File
@@ -18,7 +18,7 @@ func TestUserListFilter(t *testing.T) {
st := account.NewStore(testDB)
uniq := uuid.NewString()
human, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman")
human, _, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman")
if err != nil {
t.Fatalf("provision human: %v", err)
}
@@ -0,0 +1,51 @@
//go:build integration
package inttest
import (
"fmt"
"net/http"
"testing"
"time"
"go.uber.org/zap/zaptest"
"scrabble/backend/internal/account"
"scrabble/backend/internal/server"
)
// TestVariantPreferenceGate covers the New Game gate: a player may start a quick game
// or create a friend invitation only in a variant they have enabled in their profile
// (a fresh account defaults to Erudit only), and any other variant is refused with 400.
// The complementary case — an invited friend accepting an invitation in a variant they
// have NOT enabled — is exercised by TestGameLimitGate, where a default-Erudit human
// accepts an English invitation over HTTP.
func TestVariantPreferenceGate(t *testing.T) {
clearOpenGames(t)
srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
DB: testDB,
Accounts: account.NewStore(testDB),
Games: newGameService(),
Matchmaker: newMatchmaker(t, newRobotService(t, newGameService()), time.Minute, 0),
Invitations: newInvitationService(),
})
human := provisionAccount(t) // default preferences: erudit_ru only
opp := provisionAccount(t)
// A variant outside the player's preferences is refused on both create paths.
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"scrabble_en"}`); rec.Code != http.StatusBadRequest {
t.Fatalf("enqueue non-preferred variant = %d (%s), want 400", rec.Code, rec.Body.String())
}
invBody := fmt.Sprintf(`{"variant":"scrabble_en","invitee_ids":[%q]}`, opp.String())
if rec := userPost(t, srv, "/api/v1/user/invitations", human, invBody); rec.Code != http.StatusBadRequest {
t.Fatalf("invitation non-preferred variant = %d (%s), want 400", rec.Code, rec.Body.String())
}
// The default-enabled variant (Erudit) is allowed: the quick enqueue opens a game.
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"erudit_ru"}`); rec.Code != http.StatusOK {
t.Fatalf("enqueue preferred variant = %d (%s), want 200", rec.Code, rec.Body.String())
}
clearOpenGames(t)
}
+47 -3
View File
@@ -150,6 +150,14 @@ func (svc *InvitationService) emitInvitationUpdate(ctx context.Context, inv Invi
}
intents := make([]notify.Intent, 0, len(recipients))
for _, id := range recipients {
// An invitee who has blocked the inviter never sees this invitation, so they must not
// receive its updates either (an update would otherwise re-add it to their lobby). The
// inviter always gets updates; on a lookup error suppress rather than risk a leak.
if id != inv.InviterID {
if blk, err := svc.blocker.Blocks(ctx, id, inv.InviterID); err != nil || blk {
continue
}
}
intents = append(intents, notify.NotificationInvitationUpdate(id, summary))
}
svc.pub.Publish(intents...)
@@ -211,6 +219,11 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
}
seen := map[uuid.UUID]bool{inviterID: true}
// suppressed collects invitees who have blocked the inviter: the invitation is still
// created and persisted for them, but they are never notified and never see it (their
// friendship with the inviter survived the block, so they can still appear in the
// inviter's picker — they must not learn of the block).
suppressed := make(map[uuid.UUID]bool)
for _, id := range inviteeIDs {
if seen[id] {
return Invitation{}, fmt.Errorf("%w: %s invited twice or is the inviter", ErrInvalidInvitation, id)
@@ -222,13 +235,22 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
}
return Invitation{}, err
}
blocked, err := svc.blocker.IsBlocked(ctx, inviterID, id)
// A block the inviter placed refuses the invite (the inviter is aware; the picker hides
// the invitee anyway). A block the invitee placed on the inviter is instead suppressed.
iBlock, err := svc.blocker.Blocks(ctx, inviterID, id)
if err != nil {
return Invitation{}, err
}
if blocked {
if iBlock {
return Invitation{}, ErrInvitationBlocked
}
theyBlock, err := svc.blocker.Blocks(ctx, id, inviterID)
if err != nil {
return Invitation{}, err
}
if theyBlock {
suppressed[id] = true
}
}
id, err := uuid.NewV7()
@@ -253,7 +275,18 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
if err != nil {
return Invitation{}, err
}
svc.emitInvitation(ctx, inv, inviteeIDs)
// Notify every invitee except those who have blocked the inviter (their copy stays
// stored but unseen — the store-but-hide that keeps the block invisible).
notifyIDs := inviteeIDs
if len(suppressed) > 0 {
notifyIDs = make([]uuid.UUID, 0, len(inviteeIDs))
for _, id := range inviteeIDs {
if !suppressed[id] {
notifyIDs = append(notifyIDs, id)
}
}
}
svc.emitInvitation(ctx, inv, notifyIDs)
return inv, nil
}
@@ -347,6 +380,17 @@ func (svc *InvitationService) ListInvitations(ctx context.Context, accountID uui
if err != nil {
return nil, err
}
// Hide an invitation whose inviter the viewer has blocked: it stays stored but must
// never surface to the blocker. The viewer's own invitations (as inviter) are unaffected.
if inv.InviterID != accountID {
blocked, err := svc.blocker.Blocks(ctx, accountID, inv.InviterID)
if err != nil {
return nil, err
}
if blocked {
continue
}
}
out = append(out, inv)
}
return out, nil
+8 -4
View File
@@ -38,11 +38,15 @@ type RobotProvider interface {
PickNamed(variant engine.Variant) (uuid.UUID, string, error)
}
// Blocker reports whether two accounts have a block between them (either
// direction). social.Service satisfies it; the lobby uses it to refuse
// invitations between blocked accounts.
// Blocker exposes the per-user block graph the lobby needs. social.Service satisfies it.
// Invitations consult the exact direction (Blocks) so the inviter's block refuses while a
// block the invitee placed on the inviter is silently suppressed; auto-match consults
// BlockedWith so a block either way keeps the pair out of the same anonymous game.
type Blocker interface {
IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error)
// Blocks reports whether blocker has blocked blocked (exact direction).
Blocks(ctx context.Context, blocker, blocked uuid.UUID) (bool, error)
// BlockedWith returns every account in a block with accountID in either direction.
BlockedWith(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error)
}
// Auto-match defaults: a casual two-player game on the longest move clock with one
+33 -8
View File
@@ -18,7 +18,10 @@ import (
// reading a player's view to enrich the opponent_joined event. game.Service satisfies
// it.
type GameMatcher interface {
OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time) (game.Game, bool, error)
// OpenOrJoin joins the caller into another waiting player's open game or opens a fresh
// one; exclude lists account IDs whose open game must never be joined (the caller's
// per-user block set), so a block keeps the pair out of the same anonymous game.
OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time, exclude []uuid.UUID) (game.Game, bool, error)
AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (game.Game, bool, error)
ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error)
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
@@ -35,8 +38,9 @@ type GameMatcher interface {
// Matchmaker holds only the wait policy and the live-event publisher, and is safe for
// concurrent use.
//
// Auto-match is anonymous, so it does not consult per-user blocks (those govern
// friends, chat and invitations between known players).
// Auto-match is anonymous, but it still consults per-user blocks: a player is never
// paired into a game whose waiting opponent they have blocked, or who has blocked them
// (either direction), so a block keeps two players apart everywhere.
type Matchmaker struct {
games GameMatcher
robots RobotProvider
@@ -44,6 +48,7 @@ type Matchmaker struct {
jitter time.Duration
clock func() time.Time
pub notify.Publisher
blocker Blocker
log *zap.Logger
}
@@ -75,6 +80,16 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) {
}
}
// SetBlocker installs the per-user block source used to keep auto-match from pairing a
// player with someone they have a block with (either direction). It must be called
// during startup wiring; the default is no blocker, in which case auto-match pairs
// anonymously (no block exclusion).
func (m *Matchmaker) SetBlocker(b Blocker) {
if b != nil {
m.blocker = b
}
}
// EnqueueResult is the outcome of an auto-match enqueue: the game the caller now plays
// in, and whether it already had an opponent (they joined a waiting game) rather than
// being freshly opened and still awaiting one.
@@ -85,11 +100,22 @@ type EnqueueResult struct {
// Enqueue resolves an auto-match request for accountID under variant and the per-turn
// word rule (multipleWords) into the game they enter immediately — a freshly opened
// game awaiting an opponent, the caller's own still-open game (a re-enqueue is
// idempotent), or another player's open game they just joined. When the caller joins
// an existing game, opponent_joined is pushed to that game's waiting starter.
// game awaiting an opponent, or another player's open game they just joined. A
// re-enqueue while already waiting opens another game rather than returning the
// caller's own. When the caller joins an existing game, opponent_joined is pushed to
// that game's waiting starter.
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) {
g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline())
// Exclude every account the caller has a block with (either direction) from the
// candidate open games, so auto-match never pairs a blocked pair into one game.
var exclude []uuid.UUID
if m.blocker != nil {
var err error
exclude, err = m.blocker.BlockedWith(ctx, accountID)
if err != nil {
return EnqueueResult{}, err
}
}
g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline(), exclude)
if err != nil {
return EnqueueResult{}, err
}
@@ -180,7 +206,6 @@ func (m *Matchmaker) announceOpponent(ctx context.Context, g game.Game, joinerID
return
}
intent := notify.OpponentJoined(starter, g.ID, state)
intent.Language = g.Variant.Language()
m.pub.Publish(intent)
}
+30 -1
View File
@@ -25,6 +25,7 @@ type stubMatcher struct {
openErr error
openCalls int
lastDeadline time.Time
lastExclude []uuid.UUID
expired []game.OpenGame
@@ -39,9 +40,10 @@ type stubMatcher struct {
createParams game.CreateParams
}
func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time) (game.Game, bool, error) {
func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time, exclude []uuid.UUID) (game.Game, bool, error) {
s.openCalls++
s.lastDeadline = deadline
s.lastExclude = exclude
return s.openGame, s.openJoined, s.openErr
}
@@ -95,6 +97,33 @@ type capturePub struct{ intents []notify.Intent }
func (c *capturePub) Publish(intents ...notify.Intent) { c.intents = append(c.intents, intents...) }
// fakeBlocker is a Blocker returning a canned both-direction block set.
type fakeBlocker struct {
with []uuid.UUID
err error
}
func (f *fakeBlocker) Blocks(context.Context, uuid.UUID, uuid.UUID) (bool, error) { return false, nil }
func (f *fakeBlocker) BlockedWith(context.Context, uuid.UUID) ([]uuid.UUID, error) {
return f.with, f.err
}
// TestEnqueueExcludesBlockedStarters checks the matchmaker passes the caller's block set
// (both directions) to OpenOrJoin as the exclusion list, so auto-match never joins a game
// whose waiting player has a block with the caller.
func TestEnqueueExcludesBlockedStarters(t *testing.T) {
caller, blocked := uuid.New(), uuid.New()
m := &stubMatcher{openGame: twoSeatGame(caller, uuid.Nil)}
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop())
mm.SetBlocker(&fakeBlocker{with: []uuid.UUID{blocked}})
if _, err := mm.Enqueue(context.Background(), caller, engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue: %v", err)
}
if !slices.Contains(m.lastExclude, blocked) {
t.Fatalf("OpenOrJoin exclude = %v, want it to contain the blocked id %s", m.lastExclude, blocked)
}
}
// twoSeatGame is a two-player game seating starter at seat 0 and opponent at seat 1
// (uuid.Nil for a still-empty opponent seat).
func twoSeatGame(starter, opponent uuid.UUID) game.Game {
+1
View File
@@ -83,6 +83,7 @@ func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT
Rack: s.Rack,
BagLen: s.BagLen,
HintsRemaining: s.HintsRemaining,
WalletBalance: s.WalletBalance,
Alphabet: alphabet,
})
}
+10
View File
@@ -216,6 +216,16 @@ func BannerChanged(userID uuid.UUID) Intent {
return Notification(userID, NotifyBanner)
}
// ChatAccessChanged signals that userID's eligibility to write in the moderated
// Telegram discussion chat may have changed (an admin block/unblock, a chat_muted
// grant/revoke, or a temporary block lapsing). It carries no payload: the gateway
// resolves the user's Telegram identity and current eligibility and pushes the
// resulting chat-gate command to the bot. Unlike the lobby notifications it is an
// infra signal — a distinct top-level kind, never an out-of-app rendered message.
func ChatAccessChanged(userID uuid.UUID) Intent {
return Intent{UserID: userID, Kind: KindChatAccessChanged, EventID: eventID()}
}
// eventID returns a best-effort correlation id for one emitted event.
func eventID() string {
if id, err := uuid.NewV7(); err == nil {
+17 -5
View File
@@ -35,6 +35,13 @@ const (
// KindGameOver announces a finished game to each seated player, driving the
// out-of-app "game over" push.
KindGameOver = "game_over"
// KindChatAccessChanged signals that a player's eligibility to write in the
// moderated Telegram discussion chat may have changed (an admin block or unblock,
// a chat_muted grant or revoke, or a temporary block lapsing). It carries no
// payload and is never fanned out to in-app clients: the gateway consumes it to
// resolve the player's Telegram identity and current eligibility and push the
// resulting chat-gate command to the bot.
KindChatAccessChanged = "chat_access_changed"
)
// Notification sub-kinds carried in a KindNotification event payload; the client
@@ -62,6 +69,16 @@ const (
// it re-fetches profile.get to show or hide the banner. It carries no payload (the
// banner set rides the profile response). In-app only.
NotifyBanner = "banner"
// NotifyUserBlocked confirms to the blocker that a per-user block took effect,
// carrying the blocked account, so every one of the blocker's sessions updates the
// in-game block/add-friend controls and the struck name in place. It is delivered
// only to the blocker — never to the blocked user, who must not learn of the block —
// and is in-app only (no out-of-app push: blocking is the viewer's own action).
NotifyUserBlocked = "user_blocked"
// NotifyUserUnblocked confirms an unblock to the (former) blocker, carrying the
// unblocked account, so their open game screens restore the controls and un-strike
// the name in place. Like NotifyUserBlocked it reaches only the actor and is in-app only.
NotifyUserUnblocked = "user_unblocked"
)
// Intent is one live event destined for a single user. Payload is the
@@ -72,11 +89,6 @@ 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
+1
View File
@@ -56,6 +56,7 @@ type PlayerState struct {
Rack []int
BagLen int
HintsRemaining int
WalletBalance int
Alphabet []AlphabetLetter
}
@@ -0,0 +1,21 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type AccountBestMove struct {
AccountID uuid.UUID `sql:"primary_key"`
Variant string `sql:"primary_key"`
Score int32
Tiles string
UpdatedAt time.Time
}
@@ -20,4 +20,6 @@ type AccountStats struct {
MaxGamePoints int32
MaxWordPoints int32
UpdatedAt time.Time
Moves int32
HintsUsed int32
}
@@ -9,6 +9,7 @@ package model
import (
"github.com/google/uuid"
"github.com/lib/pq"
"time"
)
@@ -29,6 +30,6 @@ type Accounts struct {
PaidAccount bool
MergedInto *uuid.UUID
MergedAt *time.Time
ServiceLanguage *string
FlaggedHighRateAt *time.Time
VariantPreferences pq.StringArray
}
@@ -13,11 +13,12 @@ import (
)
type ChatMessages struct {
MessageID uuid.UUID `sql:"primary_key"`
GameID uuid.UUID
SenderID uuid.UUID
Kind string
Body string
SenderIP *string
CreatedAt time.Time
MessageID uuid.UUID `sql:"primary_key"`
GameID uuid.UUID
SenderID uuid.UUID
Kind string
Body string
SenderIP *string
CreatedAt time.Time
UnreadSeats int16
}
@@ -20,12 +20,11 @@ type FeedbackMessages struct {
AttachmentName *string
SenderIP *string
Channel string
Lang *string
ChannelLang *string
ReadAt *time.Time
ArchivedAt *time.Time
ReplyBody *string
RepliedAt *time.Time
ReplyReadAt *time.Time
CreatedAt time.Time
Lang *string
}
@@ -0,0 +1,24 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type GameSetupDraws struct {
GameID uuid.UUID `sql:"primary_key"`
Round int16 `sql:"primary_key"`
PickNo int16 `sql:"primary_key"`
AccountID *uuid.UUID
Letter string
IsBlank bool
DrawRank int16
CreatedAt time.Time
}
@@ -0,0 +1,90 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var AccountBestMove = newAccountBestMoveTable("backend", "account_best_move", "")
type accountBestMoveTable struct {
postgres.Table
// Columns
AccountID postgres.ColumnString
Variant postgres.ColumnString
Score postgres.ColumnInteger
Tiles postgres.ColumnString
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type AccountBestMoveTable struct {
accountBestMoveTable
EXCLUDED accountBestMoveTable
}
// AS creates new AccountBestMoveTable with assigned alias
func (a AccountBestMoveTable) AS(alias string) *AccountBestMoveTable {
return newAccountBestMoveTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new AccountBestMoveTable with assigned schema name
func (a AccountBestMoveTable) FromSchema(schemaName string) *AccountBestMoveTable {
return newAccountBestMoveTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new AccountBestMoveTable with assigned table prefix
func (a AccountBestMoveTable) WithPrefix(prefix string) *AccountBestMoveTable {
return newAccountBestMoveTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new AccountBestMoveTable with assigned table suffix
func (a AccountBestMoveTable) WithSuffix(suffix string) *AccountBestMoveTable {
return newAccountBestMoveTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newAccountBestMoveTable(schemaName, tableName, alias string) *AccountBestMoveTable {
return &AccountBestMoveTable{
accountBestMoveTable: newAccountBestMoveTableImpl(schemaName, tableName, alias),
EXCLUDED: newAccountBestMoveTableImpl("", "excluded", ""),
}
}
func newAccountBestMoveTableImpl(schemaName, tableName, alias string) accountBestMoveTable {
var (
AccountIDColumn = postgres.StringColumn("account_id")
VariantColumn = postgres.StringColumn("variant")
ScoreColumn = postgres.IntegerColumn("score")
TilesColumn = postgres.StringColumn("tiles")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{AccountIDColumn, VariantColumn, ScoreColumn, TilesColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{ScoreColumn, TilesColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{UpdatedAtColumn}
)
return accountBestMoveTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
AccountID: AccountIDColumn,
Variant: VariantColumn,
Score: ScoreColumn,
Tiles: TilesColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -24,6 +24,8 @@ type accountStatsTable struct {
MaxGamePoints postgres.ColumnInteger
MaxWordPoints postgres.ColumnInteger
UpdatedAt postgres.ColumnTimestampz
Moves postgres.ColumnInteger
HintsUsed postgres.ColumnInteger
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -72,9 +74,11 @@ func newAccountStatsTableImpl(schemaName, tableName, alias string) accountStatsT
MaxGamePointsColumn = postgres.IntegerColumn("max_game_points")
MaxWordPointsColumn = postgres.IntegerColumn("max_word_points")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{AccountIDColumn, WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
MovesColumn = postgres.IntegerColumn("moves")
HintsUsedColumn = postgres.IntegerColumn("hints_used")
allColumns = postgres.ColumnList{AccountIDColumn, WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn, MovesColumn, HintsUsedColumn}
mutableColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn, MovesColumn, HintsUsedColumn}
defaultColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn, MovesColumn, HintsUsedColumn}
)
return accountStatsTable{
@@ -88,6 +92,8 @@ func newAccountStatsTableImpl(schemaName, tableName, alias string) accountStatsT
MaxGamePoints: MaxGamePointsColumn,
MaxWordPoints: MaxWordPointsColumn,
UpdatedAt: UpdatedAtColumn,
Moves: MovesColumn,
HintsUsed: HintsUsedColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -33,8 +33,8 @@ type accountsTable struct {
PaidAccount postgres.ColumnBool
MergedInto postgres.ColumnString
MergedAt postgres.ColumnTimestampz
ServiceLanguage postgres.ColumnString
FlaggedHighRateAt postgres.ColumnTimestampz
VariantPreferences postgres.ColumnStringArray
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -92,11 +92,11 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
PaidAccountColumn = postgres.BoolColumn("paid_account")
MergedIntoColumn = postgres.StringColumn("merged_into")
MergedAtColumn = postgres.TimestampzColumn("merged_at")
ServiceLanguageColumn = postgres.StringColumn("service_language")
FlaggedHighRateAtColumn = postgres.TimestampzColumn("flagged_high_rate_at")
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, ServiceLanguageColumn, FlaggedHighRateAtColumn}
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, ServiceLanguageColumn, FlaggedHighRateAtColumn}
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn}
VariantPreferencesColumn = postgres.StringArrayColumn("variant_preferences")
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn}
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn}
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, VariantPreferencesColumn}
)
return accountsTable{
@@ -119,8 +119,8 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
PaidAccount: PaidAccountColumn,
MergedInto: MergedIntoColumn,
MergedAt: MergedAtColumn,
ServiceLanguage: ServiceLanguageColumn,
FlaggedHighRateAt: FlaggedHighRateAtColumn,
VariantPreferences: VariantPreferencesColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -17,13 +17,14 @@ type chatMessagesTable struct {
postgres.Table
// Columns
MessageID postgres.ColumnString
GameID postgres.ColumnString
SenderID postgres.ColumnString
Kind postgres.ColumnString
Body postgres.ColumnString
SenderIP postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
MessageID postgres.ColumnString
GameID postgres.ColumnString
SenderID postgres.ColumnString
Kind postgres.ColumnString
Body postgres.ColumnString
SenderIP postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
UnreadSeats postgres.ColumnInteger
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -65,29 +66,31 @@ func newChatMessagesTable(schemaName, tableName, alias string) *ChatMessagesTabl
func newChatMessagesTableImpl(schemaName, tableName, alias string) chatMessagesTable {
var (
MessageIDColumn = postgres.StringColumn("message_id")
GameIDColumn = postgres.StringColumn("game_id")
SenderIDColumn = postgres.StringColumn("sender_id")
KindColumn = postgres.StringColumn("kind")
BodyColumn = postgres.StringColumn("body")
SenderIPColumn = postgres.StringColumn("sender_ip")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{KindColumn, BodyColumn, CreatedAtColumn}
MessageIDColumn = postgres.StringColumn("message_id")
GameIDColumn = postgres.StringColumn("game_id")
SenderIDColumn = postgres.StringColumn("sender_id")
KindColumn = postgres.StringColumn("kind")
BodyColumn = postgres.StringColumn("body")
SenderIPColumn = postgres.StringColumn("sender_ip")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UnreadSeatsColumn = postgres.IntegerColumn("unread_seats")
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn, UnreadSeatsColumn}
mutableColumns = postgres.ColumnList{GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn, UnreadSeatsColumn}
defaultColumns = postgres.ColumnList{KindColumn, BodyColumn, CreatedAtColumn, UnreadSeatsColumn}
)
return chatMessagesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
MessageID: MessageIDColumn,
GameID: GameIDColumn,
SenderID: SenderIDColumn,
Kind: KindColumn,
Body: BodyColumn,
SenderIP: SenderIPColumn,
CreatedAt: CreatedAtColumn,
MessageID: MessageIDColumn,
GameID: GameIDColumn,
SenderID: SenderIDColumn,
Kind: KindColumn,
Body: BodyColumn,
SenderIP: SenderIPColumn,
CreatedAt: CreatedAtColumn,
UnreadSeats: UnreadSeatsColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -24,14 +24,13 @@ type feedbackMessagesTable struct {
AttachmentName postgres.ColumnString
SenderIP postgres.ColumnString
Channel postgres.ColumnString
Lang postgres.ColumnString
ChannelLang postgres.ColumnString
ReadAt postgres.ColumnTimestampz
ArchivedAt postgres.ColumnTimestampz
ReplyBody postgres.ColumnString
RepliedAt postgres.ColumnTimestampz
ReplyReadAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
Lang postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -80,16 +79,15 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
AttachmentNameColumn = postgres.StringColumn("attachment_name")
SenderIPColumn = postgres.StringColumn("sender_ip")
ChannelColumn = postgres.StringColumn("channel")
LangColumn = postgres.StringColumn("lang")
ChannelLangColumn = postgres.StringColumn("channel_lang")
ReadAtColumn = postgres.TimestampzColumn("read_at")
ArchivedAtColumn = postgres.TimestampzColumn("archived_at")
ReplyBodyColumn = postgres.StringColumn("reply_body")
RepliedAtColumn = postgres.TimestampzColumn("replied_at")
ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, LangColumn, ChannelLangColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, LangColumn, ChannelLangColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
LangColumn = postgres.StringColumn("lang")
allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn}
defaultColumns = postgres.ColumnList{CreatedAtColumn}
)
@@ -104,14 +102,13 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
AttachmentName: AttachmentNameColumn,
SenderIP: SenderIPColumn,
Channel: ChannelColumn,
Lang: LangColumn,
ChannelLang: ChannelLangColumn,
ReadAt: ReadAtColumn,
ArchivedAt: ArchivedAtColumn,
ReplyBody: ReplyBodyColumn,
RepliedAt: RepliedAtColumn,
ReplyReadAt: ReplyReadAtColumn,
CreatedAt: CreatedAtColumn,
Lang: LangColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -0,0 +1,99 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var GameSetupDraws = newGameSetupDrawsTable("backend", "game_setup_draws", "")
type gameSetupDrawsTable struct {
postgres.Table
// Columns
GameID postgres.ColumnString
Round postgres.ColumnInteger
PickNo postgres.ColumnInteger
AccountID postgres.ColumnString
Letter postgres.ColumnString
IsBlank postgres.ColumnBool
DrawRank postgres.ColumnInteger
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type GameSetupDrawsTable struct {
gameSetupDrawsTable
EXCLUDED gameSetupDrawsTable
}
// AS creates new GameSetupDrawsTable with assigned alias
func (a GameSetupDrawsTable) AS(alias string) *GameSetupDrawsTable {
return newGameSetupDrawsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new GameSetupDrawsTable with assigned schema name
func (a GameSetupDrawsTable) FromSchema(schemaName string) *GameSetupDrawsTable {
return newGameSetupDrawsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new GameSetupDrawsTable with assigned table prefix
func (a GameSetupDrawsTable) WithPrefix(prefix string) *GameSetupDrawsTable {
return newGameSetupDrawsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new GameSetupDrawsTable with assigned table suffix
func (a GameSetupDrawsTable) WithSuffix(suffix string) *GameSetupDrawsTable {
return newGameSetupDrawsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newGameSetupDrawsTable(schemaName, tableName, alias string) *GameSetupDrawsTable {
return &GameSetupDrawsTable{
gameSetupDrawsTable: newGameSetupDrawsTableImpl(schemaName, tableName, alias),
EXCLUDED: newGameSetupDrawsTableImpl("", "excluded", ""),
}
}
func newGameSetupDrawsTableImpl(schemaName, tableName, alias string) gameSetupDrawsTable {
var (
GameIDColumn = postgres.StringColumn("game_id")
RoundColumn = postgres.IntegerColumn("round")
PickNoColumn = postgres.IntegerColumn("pick_no")
AccountIDColumn = postgres.StringColumn("account_id")
LetterColumn = postgres.StringColumn("letter")
IsBlankColumn = postgres.BoolColumn("is_blank")
DrawRankColumn = postgres.IntegerColumn("draw_rank")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{GameIDColumn, RoundColumn, PickNoColumn, AccountIDColumn, LetterColumn, IsBlankColumn, DrawRankColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, LetterColumn, IsBlankColumn, DrawRankColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{IsBlankColumn, CreatedAtColumn}
)
return gameSetupDrawsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
GameID: GameIDColumn,
Round: RoundColumn,
PickNo: PickNoColumn,
AccountID: AccountIDColumn,
Letter: LetterColumn,
IsBlank: IsBlankColumn,
DrawRank: DrawRankColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -31,6 +31,7 @@ func UseSchema(schema string) {
GameInvitations = GameInvitations.FromSchema(schema)
GameMoves = GameMoves.FromSchema(schema)
GamePlayers = GamePlayers.FromSchema(schema)
GameSetupDraws = GameSetupDraws.FromSchema(schema)
Games = Games.FromSchema(schema)
Identities = Identities.FromSchema(schema)
Sessions = Sessions.FromSchema(schema)
File diff suppressed because it is too large Load Diff
@@ -1,16 +0,0 @@
-- +goose Up
-- Allow end_reason = 'aborted': a game whose journal can no longer be reconstructed (a
-- recorded move became illegal under tightened rules) is closed as a draw rather than left
-- unopenable. See engine.EndAborted and Service.voidGame.
SET search_path = backend, pg_catalog;
ALTER TABLE games DROP CONSTRAINT games_end_reason_chk;
ALTER TABLE games ADD CONSTRAINT games_end_reason_chk CHECK (
end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout', 'aborted')
);
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE games DROP CONSTRAINT games_end_reason_chk;
ALTER TABLE games ADD CONSTRAINT games_end_reason_chk CHECK (
end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout')
);
@@ -1,45 +0,0 @@
-- +goose Up
-- Manual account blocking ("suspension"), the operator's hard counterpart to the soft,
-- reversible accounts.flagged_high_rate_at marker. Named "suspension" throughout the schema
-- and Go to stay distinct from the peer-to-peer `blocks` table (one player muting another);
-- the wire/UI vocabulary the player sees is "blocked". A suspension forces the player to
-- forfeit every active game and replaces their whole UI with a terminal "blocked" screen until
-- it is lifted or (for a temporary one) expires. See docs/ARCHITECTURE.md §12.
SET search_path = backend, pg_catalog;
-- The operator-editable reason picklist, one row per reason with its English and Russian text.
-- A suspension snapshots the chosen text (account_suspensions.reason_en/ru), so editing or
-- deleting a reason here never changes or breaks a reason already shown to a blocked player.
CREATE TABLE suspension_reasons (
reason_id uuid PRIMARY KEY,
text_en text NOT NULL,
text_ru text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- The block history: one row per block. blocked_until is NULL for a permanent block and the
-- expiry instant for a temporary one; lifted_at is stamped when an operator unblocks early.
-- reason_en/reason_ru are the text snapshot taken at block time (NULL when no reason was
-- cited); reason_id keeps a loose link to the picklist entry for later analytics and is nulled
-- if that entry is deleted (the snapshot remains the source of truth shown to the player). An
-- account is currently blocked when its newest row has lifted_at IS NULL AND (blocked_until IS
-- NULL OR blocked_until > now()).
CREATE TABLE account_suspensions (
suspension_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
blocked_at timestamptz NOT NULL DEFAULT now(),
blocked_until timestamptz,
reason_en text,
reason_ru text,
reason_id uuid REFERENCES suspension_reasons (reason_id) ON DELETE SET NULL,
lifted_at timestamptz
);
-- The enforcement gate looks up the newest suspension for an account on every authenticated
-- request; this index serves that "latest by account" probe.
CREATE INDEX account_suspensions_account_idx ON account_suspensions (account_id, blocked_at DESC);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE IF EXISTS account_suspensions;
DROP TABLE IF EXISTS suspension_reasons;
@@ -1,55 +0,0 @@
-- +goose Up
-- User feedback ("Обратная связь"): a flat list of messages a registered player sends to the
-- operators from Settings -> Info, each with an optional single attachment, plus the operator's
-- inline reply shown back to the player. Distinct from the in-game chat_messages (peer-to-peer)
-- and from the out-of-app Telegram messages. Also introduces account_roles, the project's first
-- per-account role table, replacing per-feature boolean flags. See docs/ARCHITECTURE.md.
SET search_path = backend, pg_catalog;
-- One feedback message. read_at marks "the operator has dealt with this" (set by every console
-- action: read / reply / archive; never by merely opening the detail). The anti-spam gate forbids
-- a new submission while the player has any read_at IS NULL message. archived_at files a handled
-- message away. The reply lives on the same row: reply_body/replied_at are stamped when the
-- operator answers; reply_read_at is stamped when the player's app first fetches the reply for
-- display ("delivered = read"), and the reply is hidden from the player one week after that.
-- attachment is the raw bytes (capped at 1,000,000 in Go); attachment_name carries the original
-- file name (its extension drives the safe content-type at serve time). sender_ip is the
-- gateway-forwarded client IP (validated, like chat); channel is the submitting platform
-- (telegram/ios/android/web, validated in Go). The sender's interface/bot language snapshots
-- (lang, channel_lang) are added additively in 00005.
CREATE TABLE feedback_messages (
message_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
body text NOT NULL,
attachment bytea,
attachment_name text,
sender_ip text,
channel text NOT NULL,
read_at timestamptz,
archived_at timestamptz,
reply_body text,
replied_at timestamptz,
reply_read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
-- The per-player probes (anti-spam "has unread", "latest message", "latest reply") and the
-- console's user-scoped search all look an account up newest-first.
CREATE INDEX feedback_messages_account_idx ON feedback_messages (account_id, created_at DESC);
-- The console queue lists messages newest-first across all accounts.
CREATE INDEX feedback_messages_created_idx ON feedback_messages (created_at DESC);
-- Named per-account roles. A reusable replacement for per-feature boolean flags: the first role,
-- 'feedback_banned', forbids only feedback submission (not the whole account, unlike a
-- suspension). Roles are validated in Go (no CHECK here) so new ones need no migration. Granted
-- from the feedback console (the "block" checkbox on delete) and granted/revoked from /users.
CREATE TABLE account_roles (
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
role text NOT NULL,
granted_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, role)
);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE IF EXISTS account_roles;
DROP TABLE IF EXISTS feedback_messages;

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