Compare commits

...

235 Commits

Author SHA1 Message Date
developer a19512adaa Merge pull request 'dev-deploy: production mirror + full observability behind the /_gm gate' (#88) from feature/dev-prod-mirror into development
Deploy · Dev / deploy (push) Successful in 42s
Tests · Go / test (push) Successful in 1m58s
Tests · Integration / integration (push) Successful in 1m44s
Tests · UI / test (push) Successful in 3m20s
2026-06-01 04:56:45 +00:00
Ilia Denisov 814eae0802 docs: observability stack + the single /_gm gate for Grafana/Mailpit
Tests · Go / test (pull_request) Successful in 1m56s
Tests · Integration / integration (pull_request) Successful in 1m41s
Tests · UI / test (pull_request) Successful in 3m23s
- ARCHITECTURE §17: the dev (production-mirror) collection stack
  (Prometheus / Loki / Tempo / promtail / node-exporter / cAdvisor) and
  the single /_gm Basic Auth gate fronting Grafana and the Mailpit UI.
- tools/dev-deploy/monitoring/README.md (new): services, what is
  collected, Grafana-behind-the-gate access, config delivery, tuning.
- tools/dev-deploy/README.md: an Observability section; the Mailpit UI
  under /_gm/mailpit/; Networking diagram and Files list updated.
- FUNCTIONAL §10.2.1 (+ ru mirror): the operator console nav links to
  Grafana and Mailpit under the same /_gm gate, one sign-in for all.
2026-06-01 06:37:24 +02:00
Ilia Denisov cb8491c200 feat(dev-deploy): one /_gm gate for console + Grafana + Mailpit
Tests · Go / test (push) Successful in 1m59s
Consolidate the operator console and the observability / captured-mail
UIs behind a single Basic Auth gate, so one password (the admin-console
account, dev: gm/gm-dev-password) unlocks all three, with links in the
console nav:

- Caddyfile.dev: a single basic_auth on /_gm/* fronts nested routes —
  /_gm/grafana/ -> Grafana, /_gm/mailpit/ -> Mailpit, catch-all -> the
  gateway/backend console. Caddy forwards the same Authorization header,
  which the backend console also accepts, so there is one prompt. The
  former top-level /grafana/ and /mailpit/ routes are removed.
- Grafana: served under /_gm/grafana/ (sub-path) as anonymous Admin with
  the login form and basic auth disabled, so it relies solely on the
  /_gm gate and ignores the forwarded credentials.
- Mailpit: MP_WEBROOT=/_gm/mailpit (and the healthcheck path) so its UI
  lives under the gate.
- Operator console: add Grafana and Mailpit links to the nav.
2026-06-01 06:30:15 +02:00
Ilia Denisov 45815c27d9 fix(dev-deploy): probe Mailpit /mailpit/livez under MP_WEBROOT
MP_WEBROOT=/mailpit prefixes every Mailpit HTTP route, including the
/livez health endpoint. The container healthcheck still probed
http://localhost:8025/livez, which now 404s, so Mailpit reported
unhealthy; the backend depends_on it with condition: service_healthy
and never started, cascading to the gateway and Caddy and failing
`docker compose up --wait`. Point the healthcheck at /mailpit/livez.
2026-06-01 06:11:25 +02:00
Ilia Denisov e11092234c feat(dev-deploy): expose Grafana + Mailpit UIs via Caddy; seed monitoring config
Deploy wiring for the observability stack (the services and collector
config landed in the previous commit):

- Caddyfile.dev: route /grafana/* to galaxy-grafana:3000 (Caddy
  sub-path mode, Grafana keeps its own login) and /mailpit/* to
  galaxy-mailpit:8025 behind dev basic-auth, so the captured-mail UI
  (every message, relayed or not) and Grafana are reachable through the
  single dev origin.
- dev-deploy.yaml: seed the monitoring config tree to a stable,
  reboot-surviving host path (GALAXY_DEV_MONITORING_DIR) before bringing
  the stack up, and inject the Grafana admin password from a Gitea
  secret (GALAXY_DEV_GRAFANA_ADMIN_PASSWORD; empty falls back to the
  compose default).
2026-06-01 05:46:19 +02:00
Ilia Denisov 84a0ccb23f feat(dev-deploy): full observability stack (Prometheus/Grafana/Loki/Tempo)
Stand up a production-mirror monitoring stack in the long-lived dev
contour, all on galaxy-dev-internal with no host ports (reached only via
the in-repo galaxy-dev-caddy):

- Prometheus scrapes backend:9100, gateway:9191, node-exporter and
  cadvisor (30s interval, 15d retention); Loki (7d) + promtail (Docker
  service discovery by the galaxy.stack=dev-deploy label) for logs;
  Tempo (3d) for traces.
- Backend and gateway now export OTLP traces to Tempo over plaintext
  gRPC on the internal network (OTEL_EXPORTER_OTLP_INSECURE).
- Grafana provisioned as code (Prometheus/Loki/Tempo datasources plus a
  starter dashboard), served under /grafana/ via Caddy sub-path mode;
  admin password from the GALAXY_DEV_GRAFANA_ADMIN_PASSWORD secret.
- Expose the Mailpit capture UI under /mailpit/ (Caddy basic-auth +
  MP_WEBROOT) so every captured message is readable regardless of relay.
- dev-deploy.yaml seeds the monitoring config to a stable, reboot-
  surviving host path and injects the Grafana admin secret.

Per-service memory limits keep the footprint within budget. All
collector config lives under tools/dev-deploy/monitoring/ for dev/prod
parity.
2026-05-31 23:39:06 +02:00
Ilia Denisov 7fb6a63c2b feat(dev-deploy): relay Mailpit to Gmail (Stage 3)
Keep Mailpit as the backend's SMTP submission point and turn on its
relay so OTP/notification mail addressed to the owner reaches a real
Gmail inbox, while everything else stays captured-only.

- mailpit gains --smtp-relay-config + --smtp-relay-matching (default
  non-routable, so an unconfigured stack only captures); relay.conf is
  mounted from a new galaxy-dev-mailpit-config volume
- tools/dev-deploy/mailpit/relay.conf.tmpl + a dev-deploy.yaml step that
  renders it from Gitea secrets (Gmail App Password, never committed)
  and seeds the volume; the GALAXY_DEV_MAIL_RELAY_MATCH var drives the
  relay-matching recipient
- backend SMTP config unchanged (still -> galaxy-mailpit:1025)
- dev-deploy README documents the relay + required secrets/vars

Verified locally: compose config valid; the rendered relay.conf is
accepted by mailpit v1.21.8 (relay + recipient-matching enabled).
Real Gmail delivery is verified at the dev-deploy preview once the
owner sets the secrets.
2026-05-31 22:44:32 +02:00
Ilia Denisov 225f89fad6 docs(ui): correct the synthetic-report loader gate comment
Tests · UI / test (push) Successful in 3m16s
Stage 2 of the dev-as-prod-mirror rework. The legacy-report (synthetic)
report loader is already available in the dev-deploy UI: it is gated by
the build-time flag VITE_GALAXY_DEV_AFFORDANCES (set "true" in
dev-deploy.yaml line 89, unset in prod-build.yaml so prod strips it),
not by import.meta.env.DEV. Correct the stale header comment that
claimed import.meta.env.DEV. No functional change — the desired
"loader in dev, absent in prod" posture already holds.
2026-05-31 22:33:32 +02:00
Ilia Denisov 0cae89cba2 refactor(dev): remove the dev-sandbox bootstrap everywhere
Tests · Go / test (push) Successful in 1m59s
Stage 1 of the dev-as-prod-mirror rework. The auto-provisioned "Dev
Sandbox" game and dummy users are removed so the dev contour starts
empty like prod; the separate legacy-report loader stays as the
test-data path.

- delete backend/internal/devsandbox (package + tests)
- drop the bootstrap call + DevSandboxConfig (struct, Config field,
  BACKEND_DEV_SANDBOX_* env, defaults, loader, validation)
- strip BACKEND_DEV_SANDBOX_* from dev-deploy + local-dev compose and
  .env.example; the generic engine-recycle / prune-broken-engines logic
  stays (it serves real games)
- update tooling docs (dev-deploy README + KNOWN-ISSUES, local-dev
  README + Makefile) and stale comments; DeleteGame and
  InsertMembershipDirect remain (exercised by lobby integration tests)

No app behaviour change beyond not auto-creating the sandbox game.
2026-05-31 22:28:03 +02:00
developer 26f1e62924 Merge pull request 'feat(admin-console): server-rendered operator console at /_gm' (#87) from feature/admin-console into development
Deploy · Dev / deploy (push) Failing after 37s
Tests · Go / test (push) Successful in 1m58s
Tests · Integration / integration (push) Successful in 1m43s
2026-05-31 19:07:47 +00:00
Ilia Denisov 7cac910de4 feat(admin-console): Stage 6 — mail & notifications domain
Tests · Go / test (push) Successful in 2m2s
Tests · Go / test (pull_request) Successful in 1m59s
Tests · Integration / integration (pull_request) Successful in 1m43s
Add the mail, notifications, and broadcast pages over the mail, notification,
and diplomail services (no new business logic), completing the operator console.

- GET  /_gm/mail                         deliveries (paginated) + dead-letters
- GET  /_gm/mail/deliveries/{id}         delivery detail + attempts
- POST /_gm/mail/deliveries/{id}/resend  re-enqueue a non-sent delivery
- GET  /_gm/notifications                notifications + dead-letters + malformed
- GET/POST /_gm/broadcast                multi-game admin diplomatic broadcast

Console depends on MailAdmin / NotificationAdmin / DiplomailAdmin interfaces
(satisfied by the concrete services); pages render in tests without a database.
Delivery detail and dead-letters live under /_gm/mail/deliveries/* and
/_gm/mail/... static segments to avoid a param/static route conflict. Resend
and broadcast flow through the CSRF guard.

Tests: mail page, delivery detail (+ not-found), resend (+ bad-CSRF),
notifications overview, broadcast form + send (input assertions) + bad game
ids, and unavailable. Plus an integration test that drives /_gm end to end
through the real gateway → backend (401 challenge + authenticated dashboard).

Docs: backend/docs/admin-console.md page inventory completed.
2026-05-31 20:43:12 +02:00
Ilia Denisov 87a272166b feat(admin-console): Stage 5 — operators (admin accounts)
Tests · Go / test (push) Successful in 1m59s
Add the operator-management page over *admin.Service (no new business logic).

- GET/POST /_gm/operators                       list + create operator
- POST     /_gm/operators/{user}/disable|enable  toggle access
- POST     /_gm/operators/{user}/reset-password  set a new password

Console depends on an OperatorAdmin interface (satisfied by *admin.Service) so
the page renders in tests without a database. Create POST is mounted on the
collection path; per-row disable/enable/reset are guarded by the CSRF middleware
and redirect back. Passwords are never logged.

Tests: list render, create (+ username/password assertions), username-taken
conflict, disable/enable, reset (+ password assertion), missing-password 400,
bad-CSRF 403, and unavailable 503.

Docs: backend/docs/admin-console.md page inventory extended.
2026-05-31 20:31:16 +02:00
Ilia Denisov ecfb2d3351 feat(admin-console): Stage 4 — games & runtimes domain
Tests · Go / test (push) Successful in 1m58s
Add the games, runtime, and engine-version pages over the existing lobby,
runtime, and engine-version services (no new business logic).

- GET/POST /_gm/games                         list + create public game
- GET      /_gm/games/{id}                    detail incl. runtime snapshot
- POST     /_gm/games/{id}/force-start|stop    game state actions
- POST     /_gm/games/{id}/ban-member          ban a member (uuid + reason)
- POST     /_gm/games/{id}/runtime/restart|patch|force-next-turn
- GET/POST /_gm/engine-versions               registry + register
- POST     /_gm/engine-versions/{ver}/disable disable a version

Console depends on GameAdmin / RuntimeAdmin / EngineVersionAdmin interfaces
(satisfied by the concrete services) so the pages render in tests without a
database. Collection-mutating POSTs are mounted on the collection path to avoid
a static-vs-param route conflict in gin. Writes flow through the CSRF guard and
redirect back; the create form parses datetime-local as UTC.

Tests: list/detail (with and without a runtime), create (visibility/owner/time
assertions), force-start (+ bad-CSRF), ban-member (+ bad uuid), runtime patch
(+ missing version), engine-version list/register/disable, and unavailable.

Docs: backend/docs/admin-console.md page inventory extended.
2026-05-31 20:25:28 +02:00
Ilia Denisov cf34710b4f feat(admin-console): Stage 3 — users domain
Tests · Go / test (push) Successful in 1m56s
Add the operator console's user-administration pages over the existing
*user.Service (no new business logic).

- GET  /_gm/users            paginated account list
- GET  /_gm/users/{id}       account detail: profile, entitlement, sanctions
- POST /_gm/users/{id}/block        apply permanent_block (reason required)
- POST /_gm/users/{id}/entitlement  set the entitlement tier
- POST /_gm/users/{id}/soft-delete  soft-delete the account (cascades)

The console depends on a UserAdmin interface (satisfied by *user.Service) so the
pages render in tests without a database. All writes flow through the CSRF
guard, carry the operator as the audit actor, and answer with a 303 redirect;
a generic message page handles not-found, validation, and failure notices.
Unblock is intentionally absent — the admin API exposes no remove-sanction
endpoint.

Tests: list/detail render, not-found, block (with actor/scope/reason
assertions), missing-reason 400, bad-CSRF 403, entitlement, soft-delete
redirect, and the service-unavailable path.

Docs: backend/docs/admin-console.md gains the page inventory.
2026-05-31 20:15:19 +02:00
Ilia Denisov 985e51d25e feat(admin-console): Stage 2 — dashboard monitoring
Tests · Go / test (push) Successful in 1m58s
Turn the console landing page into an operational dashboard.

- new internal/opsstatus: read-only Postgres projection via go-jet — ping +
  per-status COUNT/GROUP BY on runtime_records, mail_deliveries,
  notification_routes, and a malformed-intent count; degrades per-probe into
  Snapshot.Errors rather than failing the page
- dashboard renders backend readiness, database health, the three status
  tables, the malformed count, and any collection errors; falls back to a
  "monitoring not wired" note when no reader is injected
- AdminConsoleHandlers now takes an AdminConsoleDeps struct (Monitor + Ready
  added) so later stages add service refs without churning the signature

Tests: opsstatus store test against a Postgres testcontainer (empty schema +
one enqueued delivery); dashboard render tests with a fake reader (with and
without monitoring).

Docs: ARCHITECTURE 14.1 + FUNCTIONAL 10.2.1 (+ru) describe the dashboard.
(Prometheus /metrics exporters were already enabled in dev-deploy in Stage 1.)
2026-05-31 20:04:48 +02:00
Ilia Denisov 27916bbe61 feat(admin-console): Stage 1 — pipe + skeleton behind the gateway
Tests · Go / test (push) Successful in 2m0s
Add the server-rendered operator console at /_gm, exposed publicly through
the gateway behind the existing admin_accounts Basic Auth.

Backend:
- new internal/adminconsole package (html/template Renderer, stateless HMAC
  CSRF signer, embedded stylesheet)
- /_gm route group reusing basicauth.Middleware(admin.Service) + a CSRF guard
  (per-operator token + same-origin check); dashboard landing page
- BACKEND_ADMIN_CONSOLE_CSRF_KEY config (per-process random fallback)

Gateway:
- new "admin" public route class (per-IP rate limit, body + GET/HEAD/POST
  method limits) classifying /_gm traffic
- reverse proxy to the backend /_gm surface, preserving Host and relaying the
  backend 401 Basic Auth challenge; 502 when the backend is unreachable
- GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_* config

dev-deploy:
- Caddy routes /_gm/* to the gateway
- bootstrap admin + stable CSRF key; enable Prometheus /metrics exporters on
  backend and gateway (forward-compat for a future Prometheus/Grafana stack)

Docs: ARCHITECTURE 14.1/16, FUNCTIONAL 10.2.1 (+ru mirror), backend and
gateway READMEs, new backend/docs/admin-console.md.

Tests: renderer + CSRF unit tests; backend router auth/render/asset/CSRF;
gateway classifier, proxy forwarding/Host/401/405/413/429/502.
2026-05-31 19:50:15 +02:00
developer 5d2f2bfc26 Merge pull request 'docs(site): publish game rules (RU) and migrate off game/rules.txt' (#85) from feature/site-rules-ru into development
Deploy · Dev / deploy (push) Successful in 46s
Tests · Integration / integration (push) Successful in 1m43s
Build · Site / build (push) Successful in 8s
Tests · Go / test (push) Successful in 3m12s
Tests · UI / test (push) Successful in 3m22s
2026-05-31 15:45:05 +00:00
Ilia Denisov e998c8a03a docs(site): add English rules page + home intro (Stage 2 of the rules)
Build · Site / build (push) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 1m42s
Build · Site / build (pull_request) Successful in 8s
Tests · UI / test (pull_request) Successful in 3m20s
Tests · Go / test (pull_request) Successful in 1m59s
site/rules.md is a faithful English mirror of the authoritative Russian
site/ru/rules.md — the same section anchors (so the in-page cross-links
and the RU/EN structure line up), the same LaTeX formulas with English
labels, and the same tables and engine nuances. Rewrite the English home
intro to match the Russian one and link to the rules, and register Rules
in the English sidebar. Completes the bilingual rules.
2026-05-31 17:19:50 +02:00
Ilia Denisov a01e3891e7 chore: remove screenshot 2026-05-31 17:16:06 +02:00
Ilia Denisov f9f725f657 fix(site): formulas — downgrade markdown-it-mathjax3 to v4 (fix hydration)
Build · Site / build (push) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 1m42s
Build · Site / build (pull_request) Successful in 8s
Tests · Go / test (pull_request) Successful in 3m18s
Tests · UI / test (pull_request) Successful in 3m24s
The duplicate-then-disappearing formulas were a Vue hydration mismatch,
not a CSS problem. markdown-it-mathjax3 v5 pulls the `mathxyjax3` fork,
which emits each formula's CSS as an in-content `<style>` block scoped to
a per-container `#mjx-<id>` that the static build never sets. The orphaned
scoped CSS left the screen-reader MathML twin visible (the duplicate), and
the in-`<main>` `<style>` elements break VitePress/Vue hydration
("Hydration completed but contains mismatches"), which strips the SVG
glyph `<path>`s and blanks every formula after the page finishes loading.

Downgrade to markdown-it-mathjax3 ^4.3.2 — the mathjax-full-based version
VitePress officially supports. It uses `juice` to inline all CSS into the
element `style` attributes (no in-content `<style>`), so hydration is
clean (glyphs survive) and the MathML twin is hidden by its own inlined
style (no duplicate). This also drops the earlier custom.css workaround,
which only treated the symptom and itself blanked the formulas.

Verified with a headless Chromium render of the built /ru/rules: all 10
formulas keep their glyph paths after hydration, no console mismatch, no
duplicate copies.
2026-05-31 17:03:16 +02:00
Ilia Denisov 9b689b2885 fix(site): hide the MathJax assistive-MathML twin (no duplicate formulas)
Build · Site / build (push) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 1m48s
Build · Site / build (pull_request) Successful in 7s
Tests · UI / test (pull_request) Successful in 3m20s
Tests · Go / test (pull_request) Successful in 2m11s
markdown-it-mathjax3 renders each formula as a visible SVG plus a MathML
twin (<mjx-assistive-mml>) for screen readers, hidden via CSS scoped to a
per-container #mjx-<id> selector. In the static build the containers carry
no id, so that scoped rule matches nothing and the twin renders as a
second, oversized (theme-monospaced) copy of every formula, in every
browser. Add a global visually-hidden rule for mjx-assistive-mml in the
theme CSS: the twin stays in the DOM for assistive tech but is removed
from view and from layout.
2026-05-31 16:32:43 +02:00
Ilia Denisov 2a3f31a32b chore: bug screenshot
Tests · Integration / integration (pull_request) Successful in 1m48s
Build · Site / build (pull_request) Successful in 9s
Tests · Go / test (pull_request) Successful in 2m0s
Tests · UI / test (pull_request) Successful in 3m14s
2026-05-31 16:23:01 +02:00
Ilia Denisov 140ee8e0ee docs(site): edit rules for clarity + cross-links; migrate off rules.txt
Build · Site / build (push) Successful in 8s
Tests · Go / test (push) Successful in 2m27s
Tests · UI / test (push) Waiting to run
Tests · Integration / integration (pull_request) Successful in 1m45s
Build · Site / build (pull_request) Successful in 9s
Tests · Go / test (pull_request) Successful in 3m14s
Tests · UI / test (pull_request) Successful in 3m14s
Editorial pass over site/ru/rules.md (on top of the verbatim port):
- moved the lore intro to the RU home page, rewritten in a modern voice;
- fixed typos, replaced the TODO/WTF cargo-tech note and the abandoned
  (---ссылка---) marker with the verified mechanic and a real cross-link,
  dropped the report TODO row;
- wove organic intra-page cross-links (#combat, #movement, #victory, ...);
- documented engine nuances verified against the code: ore auto-farming
  and the capital / "запасы промышленности" store (industry capped at
  population); cargo lost with ships destroyed in battle; and that a
  losing race's colonists at a neutral planet are NOT lost — they stay
  aboard (this corrects the audit note, verified in route.go).

Migration: delete game/rules.txt (its content now lives, authoritative,
in site/ru/rules.md) and repoint every reference to it (ui/frontend code
comments + tests, ui/docs, tools, ui/PLAN.md links). Record the
RU-authoritative rule in site/README.md and CLAUDE.md. The English
site/rules.md mirror follows in a separate stage.
2026-05-31 15:56:00 +02:00
Ilia Denisov d3770e7f77 docs(site): port game rules to site/ru/rules.md (verbatim markdown)
Faithful Markdown rendering of game/rules.txt for the site: headings with
stable anchors, GFM tables and LaTeX formulas — the text itself is
unchanged (typos, the TODO/WTF notes, the broken (---ссылка---) marker and
the lore intro are all preserved as-is). The editorial pass (clarity,
nuances, organic cross-links, intro moved to the home page) follows in a
separate commit so its diff isolates exactly what changed relative to the
original. Registers the page in the RU sidebar.
2026-05-31 14:07:50 +02:00
developer 3d7e4d30bb Merge pull request 'ci(ui-test): clean root-owned build artifacts so runner teardown succeeds' (#84) from feature/ci-ui-test-build-cleanup into development
Tests · UI / test (push) Successful in 3m15s
2026-05-31 10:35:01 +00:00
Ilia Denisov eb549e6049 ci(ui-test): clean root-owned build artifacts so runner teardown succeeds
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 3m24s
In host-mode the ui-test job runs as root, so vite (test:pwa),
svelte-kit and Playwright write build/, .svelte-kit/, test-results/ and
playwright-report/ root-owned into the shared host workspace. The
act_runner (non-root) then cannot remove them at teardown
("unlinkat ui/frontend/build: permission denied"), which spuriously
marks this or a sibling job that inherits the dirty workspace as failed
— it hit go-unit on the #83 merge even though every test passed.

Add an `if: always()` step that removes those generated dirs while the
job still has root, after the artifact uploads. Keeps the shared
workspace clean for the runner's own teardown and for later jobs.
2026-05-31 12:15:32 +02:00
developer bc838d72af Merge pull request 'chore(fbs): pin flatc toolchain to 25.9.23 and guard codegen drift' (#83) from feature/flatc-pin-25-9-23 into development
Tests · FBS codegen / codegen (push) Successful in 5s
Deploy · Dev / deploy (push) Successful in 49s
Tests · Go / test (push) Failing after 1m58s
Tests · Integration / integration (push) Successful in 1m51s
Tests · UI / test (push) Successful in 3m15s
2026-05-31 10:02:23 +00:00
Ilia Denisov 658ab7f6e7 chore(fbs): pin flatc toolchain to 25.9.23 and guard codegen drift
Tests · FBS codegen / codegen (push) Successful in 5s
Tests · Go / test (push) Successful in 2m29s
Tests · FBS codegen / codegen (pull_request) Successful in 6s
Tests · UI / test (push) Waiting to run
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 3m20s
Tests · UI / test (pull_request) Successful in 3m19s
The committed FlatBuffers bindings were generated by flatc 25.x (the TS
runtime is flatbuffers@25.9.23), but nothing pinned the compiler, so a
regen on a box with an older flatc (Debian apt ships 23.5.26) silently
churns output and flips nullable-scalar builder defaults. PR #82 hit this
and shipped 5 report files from the wrong compiler.

Unify the whole toolchain on 25.9.23 (the only version available as an
npm package, a prebuilt flatc binary, and a Go tag) and make the bindings
reproducible:

- Downgrade the flatbuffers Go module 25.12.19 -> 25.9.23 (schema,
  transcoder, gateway, integration) so compiler and both runtimes match.
- Regenerate every schema with flatc 25.9.23. The only resulting change
  is order/command-item.ts: the lone straggler still on the old
  optional-scalar builder default (cmd_applied/cmd_error_code: 0 -> null).
  Inert in practice — the TS side never builds those response-only fields
  (the engine sets them in Go); the reader is unchanged.
- Pin the version in tooling: a flatc-check guard in ui/Makefile (fbs-ts)
  and a new pkg/schema/fbs/Makefile (fbs-go); both refuse a mismatched
  flatc and point at the release binary. Fix the stale apt install hint.
- Add a path-filtered CI guard (.gitea/workflows/fbs-codegen.yaml) that
  regenerates with the pinned flatc and fails on any diff.
- Document the pinned version and the regen commands in the schema README.

No wire-format change: Go build/vet, transcoder roundtrip + engine tests,
pnpm check and the full vitest suite (888) stay green.
2026-05-31 11:51:20 +02:00
developer afb8c1225c Merge pull request 'feat(game): race exit warnings in the turn report (#12)' (#82) from feature/game-race-exit-warnings into development
Deploy · Dev / deploy (push) Successful in 56s
Tests · Go / test (push) Successful in 2m8s
Tests · Integration / integration (push) Successful in 1m51s
Tests · UI / test (push) Successful in 3m53s
2026-05-31 09:12:33 +00:00
Ilia Denisov 9e9977d5f1 feat(game): race exit warnings in the turn report (#12)
Tests · Go / test (push) Successful in 2m29s
Tests · UI / test (push) Waiting to run
Tests · Go / test (pull_request) Successful in 2m17s
Tests · Integration / integration (pull_request) Successful in 1m53s
Tests · UI / test (pull_request) Successful in 3m37s
Surface the inactivity-removal countdown the rules promise but the
engine never reported. A race within five turns of being auto-removed
for inactivity gets a personal warning in its own report; every race
within three turns is listed publicly to all participants.

- model: Report.PersonalExitWarning + RacesLeavingSoon ([]RaceExitNotice)
- fbs: RaceExitNotice table + Report.personal_exit_warning /
  races_leaving_soon (regenerated Go + TS bindings)
- transcoder: encode/decode both fields
- engine: ReportExitWarnings fills the recipient's TTL (1..5) and lists
  other non-extinct races with TTL 1..3, excluding the recipient itself
- ui: danger-styled personal banner + "races leaving soon" section
  (hidden when empty), wired into the report view, EN/RU i18n
- docs: rules.txt report-section list, FUNCTIONAL.md 6.4 + RU mirror

Voluntary quit and idle timeout share the TTL countdown and are not
distinguished, per the agreed scope.
2026-05-31 10:34:50 +02:00
developer 9dce15c7bb Merge pull request 'fix(game): small reconciliation fixes (science, generation, dismantle, report)' (#81) from feature/game-small-fixes into development
Deploy · Dev / deploy (push) Successful in 49s
Tests · Go / test (push) Successful in 2m4s
Tests · Integration / integration (push) Successful in 1m45s
2026-05-31 07:35:59 +00:00
Ilia Denisov dc621cc715 fix(game): small reconciliation fixes (science, generation, dismantle, report)
Tests · Go / test (push) Successful in 1m58s
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m1s
A bundle of small rules-vs-engine corrections:

- Science proportions: accept a sum that equals 1 only up to float
  rounding (was an exact != 1 comparison); the rules example is reworded
  so it is unambiguous that proportions are fractions summing to 1.
- Generation: super-big planets get a resource strictly above 0 (minimum
  0.001, was a hard 0.1); the rules table is fixed for big planets (1-10,
  not 0.1-10) and the false "0.1-20 / average 1.5" resource claim removed.
- Dismantle over a neutral planet now unloads the colonists and settles
  it (the planet becomes the race's); over a foreign planet they are
  still lost. The rules clause is clarified for own / neutral / foreign.
- Report: ship-production entries are written at the compacted report
  index (was the planet's map index, which could write past the grown
  slice and panic); the incoming-group "remaining distance" is measured
  from the group's current hyperspace position, not its origin planet
  (matching OtherGroup).
- validator: the cargo-value error now carries the cargo value, not the
  shields value.

Tests added for each behavioural fix; rules.txt updated in the same patch.
2026-05-31 09:29:07 +02:00
developer bef6c46a1c Merge pull request 'fix(game): release a banished race's assets during turn generation' (#80) from feature/game-banish-wipe into development
Deploy · Dev / deploy (push) Successful in 45s
Tests · Integration / integration (push) Successful in 1m39s
Tests · Go / test (push) Successful in 3m6s
2026-05-31 07:17:16 +00:00
Ilia Denisov 3b1c52cd02 fix(game): release a banished race's assets during turn generation
Tests · Go / test (push) Successful in 1m57s
Tests · Integration / integration (pull_request) Successful in 1m49s
Tests · Go / test (pull_request) Successful in 2m2s
TurnWipeExtinctRaces iterated only non-extinct races, so an
administratively banished race (flagged extinct, TTL untouched) was never
wiped: its planets stayed owned and its ships lingered, while the race
itself could no longer act. The loop now covers every race and wipes when
either an active race's TTL has run out (idle / quit) or an extinct race
still holds assets (banish). The asset check makes repeated passes
idempotent.

wipeRace already matched the rules for exclusion (ships removed, planets
uninhabited, industry and capital cleared, material retained), so the
behaviour is just documented in game/README.md.

Tests: banish releases planets and ships on the next turn (and is
idempotent); idle-timeout wipe still fires under the new iterator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 09:03:01 +02:00
developer 200236369f Merge pull request 'fix(game): bomb in descending power order, collapse industry on wipe' (#79) from feature/game-bombing-order into development
Deploy · Dev / deploy (push) Successful in 48s
Tests · Go / test (push) Successful in 2m11s
Tests · Integration / integration (push) Successful in 1m43s
2026-05-31 06:59:44 +00:00
Ilia Denisov a01f39e4a7 fix(game): bomb in descending power order, collapse industry on wipe
Tests · Go / test (push) Successful in 1m57s
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 2m2s
Per the rules ("Бомбардировка планет"), a planet is bombed from the
strongest attacking power downwards, and a planet bombed to extinction
keeps its material and capital stockpiles but loses its working industry.

ProduceBombings now sorts attacking races by total bombing power
(descending) instead of iterating the attacker map in random order, and
on a wipe zeroes the planet's industry (Free already keeps capital and
material). bombingPower is extracted as a shared helper.

The rules already describe both, so no documentation change. Tests:
bombing order by power, and industry collapse with capital/material kept
on a wipe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 08:47:45 +02:00
developer 6c00a24577 Merge pull request 'fix(game): gate group visibility by visibility range, report battle classes' (#78) from feature/game-visibility-report into development
Deploy · Dev / deploy (push) Successful in 48s
Tests · Go / test (push) Successful in 2m8s
Tests · Integration / integration (push) Successful in 1m41s
2026-05-31 06:42:44 +00:00
Ilia Denisov 6ec1098f15 fix(game): gate group visibility by visibility range, report battle classes
Tests · Go / test (push) Successful in 1m55s
Tests · Integration / integration (pull_request) Successful in 1m49s
Tests · Go / test (pull_request) Successful in 2m6s
Bring the report's foreign-group and foreign-class visibility in line
with the rules (game/rules.txt "Движение" and the report sections):

- incoming groups (heading to one of the recipient's planets) are shown
  only within the recipient's visibility range (driveTech*30); beyond it
  a group is hidden even though it is inbound;
- the unidentified-group list now uses the visibility range (it used the
  flight range, driveTech*40), excludes groups heading to the recipient's
  planets (those belong to the incoming list), and reports each group
  once (it previously emitted an entry per in-range owned planet);
- ship classes met in a battle the recipient took part in or witnessed
  now appear in OtherShipClass, with the design looked up from the owner
  race's ship types (the battle report carries only the class name).

The rules already describe this behaviour and the report wire shape is
unchanged, so no documentation change. Tests added for all three.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 08:36:33 +02:00
developer f877a199c2 Merge pull request 'fix(game): fight before departure and reorder the turn sequence' (#77) from feature/game-turn-order-departures into development
Deploy · Dev / deploy (push) Successful in 48s
Tests · Integration / integration (push) Successful in 1m42s
Tests · Go / test (push) Successful in 3m9s
2026-05-31 06:29:07 +00:00
Ilia Denisov 53b3cafbc4 fix(game): charge a ship upgrade against production only once
Tests · Go / test (push) Successful in 2m7s
Tests · Go / test (pull_request) Successful in 2m10s
Tests · Integration / integration (pull_request) Successful in 1m41s
TurnPlanetProductions started its production budget from
PlanetProductionCapacity, which already subtracts the reserved upgrade
cost, and then subtracted each applied upgrade's cost again in the apply
loop — charging every applied upgrade twice. That both starved the
planet's build/research budget and could skip upgrades that were in fact
affordable.

The budget now starts from the planet's full production potential and the
apply loop deducts each upgrade once; PlanetProductionCapacity stays the
report's net-of-upgrades "free L".

Test: TestUpgradeDoesNotDoubleChargeProduction; the TestProduceShips MAT
expectation is updated to the once-charged value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 08:24:46 +02:00
Ilia Denisov b4abf90ec5 fix(game): fight before departure and reorder the turn sequence
Tests · Go / test (push) Successful in 1m58s
Tests · Integration / integration (pull_request) Successful in 1m50s
Tests · Go / test (pull_request) Successful in 2m5s
Per the documented turn order (game/rules.txt "Последовательность
действий"), no ship should dodge the pre-departure battle by slipping
into hyperspace. MakeTurn now runs merge -> battle -> load+launch routed
groups -> fly -> merge -> battle, so:

- ships ordered to depart (Launched) and ships being upgraded now take
  part in the pre-departure battle at their planet (CollectPlanetGroups /
  FilterBattleGroups); only survivors then enter hyperspace;
- routed transports are loaded and launched AFTER that battle, so they
  fight empty and cannot escape it.

A just-launched group has no stored hyperspace position, so moveShipGroup
starts its first leg from the origin planet; the previous code read the
nil launch coordinate and would panic.

Because upgrading groups can now lose ships in the battle, the pending
upgrade cost is recomputed from the group's current ship count instead of
the value stored when the order was validated.

Rules: reordered "Последовательность действий" and rewrote the combat
note that ordered/routed ships skip the battle.

Tests: launched-group move from origin, launched/upgrade groups taking
part in battle, upgrade cost tracking ship losses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:25:46 +02:00
developer 5e86ca9999 Merge pull request 'fix(game): resolve battles ship by ship, matching the combat rules' (#76) from feature/game-combat-correctness into development
Deploy · Dev / deploy (push) Successful in 47s
Tests · Go / test (push) Successful in 2m3s
Tests · Integration / integration (push) Successful in 1m46s
2026-05-30 22:06:44 +00:00
Ilia Denisov cc67364113 fix(game): resolve battles ship by ship, matching the combat rules
Tests · Go / test (push) Successful in 2m2s
Tests · Integration / integration (pull_request) Successful in 1m48s
Tests · Go / test (pull_request) Successful in 2m0s
The battle engine diverged from the documented combat model
(game/rules.txt "Сражения") in three ways:

- the destruction roll was inverted (rand >= p), so a near-certain hit
  destroyed its target only ~(1-p) of the time;
- a whole group fired as a single ship (Armament shots per round)
  regardless of its ship count, so fleet size never affected offence;
- the defending mass used the whole group's full mass instead of one
  target ship's, weakening grouped ships' shields by ~Number^(1/3).

SingleBattle now resolves ship by ship: every living ship fires once per
round in random order across all groups, each gun targets a random enemy
ship (weighted by group size), and the destruction roll matches the
documented probability. FilterBattleOpponents evaluates per-ship mass.

Also fixes opponent-map initialisation in ProduceBattles that kept only
an attacker's last opponent.

The rules already describe this model, so no documentation change is
needed. Tests: per-ship one-sided wipe, destruction-roll direction, and
the updated per-ship-mass probability expectation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:57:27 +02:00
developer 97b5535c02 Merge pull request 'chore(cleanup): purge /command residuals — fakeEngine, canon golden, openapi' (#75) from feature/post-command-cleanup into development
Deploy · Dev / deploy (push) Successful in 47s
Tests · Go / test (push) Successful in 2m6s
Tests · Integration / integration (push) Successful in 1m51s
Tests · UI / test (push) Successful in 3m20s
2026-05-30 17:54:47 +00:00
Ilia Denisov bde9d535dc chore(cleanup): purge /command residuals — fakeEngine, canon golden, openapi
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 2m4s
Tests · Go / test (push) Successful in 2m28s
Tests · UI / test (push) Successful in 3m22s
Follow-up tidy after the cross-service /command removal (#73):

- Rename the router test double dummyExecutor -> fakeEngine (and the
  newExecutor / setupRouterExecutor helpers -> newFakeEngine /
  setupRouterEngine): it implements handler.Engine now, "executor" was a
  leftover of the removed adapter. Test-only.

- Regenerate the ui/core canon signing golden onto user.games.order
  (request_user_games_command.json -> request_user_games_order.json, fresh
  canonical bytes + Ed25519 signature) and drop the last
  user.games.command references from the Go/TS tests and docs.

- Align game openapi: CommandRequest.cmd no longer carries minItems: 1. It
  is now used only by PUT /api/v1/order, which accepts an empty batch
  (clearing the player's stored order, equivalent to removing every
  command); the contract test freezes the empty-allowed shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:16:17 +02:00
developer 40d6ba6ba4 Merge pull request 'fix(backend): retry migrations on transient connection errors' (#74) from feature/pg-migration-transient-retry into development
Deploy · Dev / deploy (push) Successful in 54s
Tests · Integration / integration (push) Successful in 1m44s
Tests · Go / test (push) Successful in 3m7s
2026-05-30 12:51:40 +00:00
Ilia Denisov 06a2e631c9 fix(backend): retry migrations on transient connection errors
Tests · Go / test (push) Successful in 2m1s
Tests · Go / test (pull_request) Successful in 3m0s
Tests · Integration / integration (pull_request) Successful in 1m42s
Backend e2e tests (and, more rarely, service startup) intermittently
failed applying migrations with `driver: bad connection`: a freshly
started Postgres — notably a test container — can reset a pooled
connection moments after it reports ready, killing the migration
transaction. The harness already waits for the double "ready" log and
pings before migrating, yet goose can still draw a connection postgres
then resets.

ApplyMigrations now wraps the schema-create + goose run in a bounded
retry that fires only on transient connection errors (driver.ErrBadConn
and the connection-failure messages Postgres drivers surface); both
steps are idempotent, so a retry resumes cleanly. Deterministic SQL
errors still fail fast.

Fixes the intermittent TestDiplomailAsyncFallbackOnUnsupportedPair (and
the eight other testcontainer e2e harnesses that share ApplyMigrations).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:45:53 +02:00
developer 2f55fc4988 Merge pull request 'refactor(game): lock-free storage, remove /command, flatten engine wrapper' (#73) from feature/game-engine-refactor into development
Deploy · Dev / deploy (push) Successful in 45s
Tests · Go / test (push) Failing after 1m53s
Tests · Integration / integration (push) Successful in 1m53s
Tests · UI / test (push) Successful in 3m29s
2026-05-30 12:23:28 +00:00
Ilia Denisov 601970b028 refactor(game): lock-free storage, remove /command, flatten engine wrapper
Tests · Go / test (push) Successful in 2m27s
Tests · UI / test (push) Waiting to run
Tests · Integration / integration (pull_request) Successful in 1m45s
Tests · Go / test (pull_request) Successful in 3m13s
Tests · UI / test (pull_request) Successful in 3m8s
Three-stage refactor of the game-engine plumbing (game logic untouched):

Stage 1 — lock-free persistence + admin serialisation. Remove the file
lock from repo/fs (the .lock file, the Read/Write-vs-*Safe duality and the
dead ReadSafe polling) and replace the two-step rename with a single atomic
rename so concurrent reads are torn-free without a lock. Serialise the
state-mutating admin writers (init/turn/banish) with one shared router
LimitMiddleware, rewritten to block on the request context instead of a
racy shared 100ms timer.

Stage 2 — remove the obsolete immediate-command path end to end. Players
submit through PUT /api/v1/order; the legacy PUT /api/v1/command path is
deleted across game (route, handler, 24 command factories, Ctrl), backend
(Commands handler/route, engineclient.ExecuteCommands), gateway (dispatch +
executeUserGamesCommand + routing entry), the FlatBuffers/model contract
(UserGamesCommand[Response]) and transcoder, plus every affected
OpenAPI/README/FUNCTIONAL/ARCHITECTURE doc. The integration proxy test is
converted to the order path.

Stage 3 — flatten the REST->engine wrapper. Replace the executor adapter,
the controller package functions and RepoController with one concrete
controller.Service; drop the single-implementation Repo and Storage
interfaces (repo.Repo / fs.FS are now concrete). Handlers depend on a thin
handler.Engine seam and own the domain->REST projection; storage is
resolved once at startup instead of per request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:37:07 +02:00
developer e36d33482f Merge pull request 'feat(game): canonical gameId in POST /api/v1/admin/init' (#72) from feature/canonical-game-id-init into development
Deploy · Dev / deploy (push) Successful in 54s
Tests · Go / test (push) Successful in 2m13s
Tests · Integration / integration (push) Successful in 1m46s
2026-05-29 11:29:20 +00:00
Ilia Denisov 15d35f6f1f feat(game): canonical gameId in POST /api/v1/admin/init
Tests · Go / test (push) Successful in 1m57s
Tests · Integration / integration (pull_request) Successful in 1m48s
Tests · Go / test (pull_request) Successful in 2m0s
Engine no longer mints its own game UUID. The orchestrator (backend)
generates the game UUID at game-create time and passes it in the
admin/init request body as the required `gameId` field, so the value
that names the engine container and host bind-mount directory also
ends up inside the engine's state.json.

The engine rejects the zero UUID with 400 and any init that conflicts
with an existing state.json with 409 (a second init on the same gameId
is also a conflict; full idempotency is not part of the contract).

Updates rest.InitRequest, openapi.yaml (schema + 409 response),
controller.GenerateGame/NewGame/buildGameOnMap signatures, the engine
HTTP handler/executor, the backend runtime worker, and the relevant
unit and contract tests. Documentation in game/README.md,
docs/ARCHITECTURE.md, backend/README.md, and backend/docs/{runtime,flows}.md
is updated in the same patch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 13:13:31 +02:00
developer 4a7bf0be61 feat(game): #59 — per-command rejection on PUT /api/v1/order (#71)
Deploy · Dev / deploy (push) Successful in 46s
Tests · Integration / integration (push) Successful in 1m47s
Tests · UI / test (push) Successful in 3m32s
Tests · Go / test (push) Successful in 2m34s
Closes #59. Engine returns 202 + per-command cmdApplied/cmdErrorCode/cmdErrorMessage instead of blanket 500; pkg/error consts reshelved onto 1xxx/2xxx/3xxx; UI keeps sync banner green on per-command rejection, surfaces the engine reason inline, and hydrates per-command verdicts from the server on game re-entry. Dev-deploy now recycles game containers when galaxy-engine:dev SHA drifts.
2026-05-29 10:18:15 +00:00
Ilia Denisov 2ffd7527a6 test(ui): align rejected-submit e2e specs with per-command sync semantics
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 1m59s
Tests · UI / test (pull_request) Successful in 3m8s
The `rename-planet` and `ship-classes` rejected-submit specs broke on
the previous commit because:

1. `tests/e2e/fixtures/order-fbs.ts` builds the FBS response without
   `forceDefaults(true)`, and flatbuffers@25's TS codegen now elides
   `cmd_applied=false` against its int8 default of 0. The encoded
   payload no longer carried the rejection, so the UI decoded the row
   as `applied` and the assertions on the `rejected` status text
   failed first. The production Go transcoder already force-slots
   the field; mirror that behaviour in the e2e fixture.
2. The specs themselves still asserted the old blanket
   `data-sync-status="error"` on per-command rejection. After the
   previous commit's behaviour change the bar stays `synced` for
   per-command rejection (only genuine transport failures keep the
   red banner + Retry), so the assertions now read the row's inline
   reason text instead.

`tests/e2e/fixtures/order-fbs.ts` also gains the `cmdErrorMessage`
field so future fixtures can mirror the engine's rejection reason
through the round trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 12:11:50 +02:00
Ilia Denisov 723885e74e fix(order): surface rejection reason, keep sync green, hydrate verdicts
Tests · UI / test (push) Has been cancelled
Tests · Go / test (push) Successful in 2m3s
Tests · Go / test (pull_request) Successful in 2m5s
Tests · Integration / integration (pull_request) Successful in 1m44s
Tests · UI / test (pull_request) Failing after 4m28s
Three issues surfaced once the per-command rejection from the previous
commit actually reached the UI:

1. Sync banner falsely red. `OrderDraftStore.runSync` flipped
   `syncStatus = "error"` whenever any command was rejected and
   advertised a Retry button. A per-command rejection is a
   player-correctable state — the round trip succeeded, the engine
   just refused that command — so the retry can't help. Keep
   `syncStatus = "synced"` on `success`; the red row highlight is
   the visible cue.

2. Rejection reason missing. Add `cmd_error_message: string` to
   `CommandItem` in `pkg/schema/fbs/order.fbs` (appended last to
   preserve existing slot offsets) and regenerate the Go + TS stubs
   for that one type. Plumb the message through `CommandMeta`,
   `Controller.applyCommand`'s `m.Result(code, message)` call, the
   Go transcoder, the UI decoders in `submit.ts` /  `order-load.ts`,
   and the `OrderDraftStore.errorMessages` map. `order-tab.svelte`
   renders it as an italic danger-coloured line under rejected
   commands, with new CSS for `.error-reason`.

3. Verdict lost on navigation. `order-load.ts.decodeCommand` never
   read `cmdApplied`/`cmdErrorCode`, so `hydrateFromServer` fell
   back to a blanket "applied" status — a previously-rejected
   command came back green after a lobby → game round trip. Extend
   the fetch decoder to populate `statuses`/`errorCodes`/
   `errorMessages` maps and have `hydrateFromServer` use them.
   Engine-side persistence already records the verdict on disk —
   verified against the live `0000/order/<id>.json`.

`flatbuffers@25` elides default-int8/int64 fields on write; the Go
transcoder force-slots `cmd_applied=false` / `cmd_error_code=0`
already, the new test fixtures flip `builder.forceDefaults(true)` to
mirror that behaviour so the round trip survives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 11:42:27 +02:00
Ilia Denisov e038ea6154 fix(dev-deploy): recycle engine containers on galaxy-engine:dev SHA drift
Tests · Integration / integration (pull_request) Successful in 1m48s
Tests · Go / test (pull_request) Successful in 2m1s
`backend`'s reconciler adopts pre-existing `galaxy-game-*` containers
without comparing their image SHA against the freshly-built
`galaxy-engine:dev`, so a long-lived sandbox would otherwise keep
serving the previous engine code after a redeploy. Issue #59 surfaced
this: after the per-command-rejection fix was deployed via
`workflow_dispatch`, the running sandbox container was still on the
old image SHA and the browser kept seeing the 503/unavailable response.

Adds a `Recycle engine containers on image drift` step right before
`Reap stray dev-deploy containers`. The step compares the new
`galaxy-engine:dev` SHA against every running `galaxy-game-*`
container and, on drift, stops the backend, removes the container,
wipes the bind-mounted per-game state directory (Engine.Init() writes
turn-0 over any pre-existing `turn-N` files — silent state corruption
otherwise), and cascade-deletes the lobby `games` row. The
`dev-sandbox` bootstrap on the next backend boot finds no live
sandbox and provisions a fresh one on the new engine image.

When the engine sources are unchanged, the BuildKit cache hits and
the SHA stays the same — the recycle step is a no-op and the running
games keep their state across the deploy. Verified end-to-end against
the live dev environment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 10:47:25 +02:00
Ilia Denisov af30846091 fix(game): #59 — per-command rejection on PUT /api/v1/order
Tests · Go / test (push) Successful in 2m2s
Tests · Go / test (pull_request) Successful in 3m3s
Tests · Integration / integration (pull_request) Successful in 1m40s
Validation of a player's order now applies every command against a
transient game-state snapshot and records the per-command outcome
(cmdApplied, cmdErrorCode) in each command's meta. The order is
persisted even when some commands are rejected, and the response is
202 + UserGamesOrder so clients can surface the partial failure
without the chain collapsing into "downstream service is unavailable".

Pkg/error consts are reshelved onto three explicit ranges with a
package doc and helpers (IsInternalCode/IsInputCode/IsGameStateCode):
1xxx internal/server (500/501), 2xxx structural input (400), 3xxx
game-state per-command rejection (400 when escaping HTTP, otherwise
recorded as cmdErrorCode). Two pre-existing typos fixed mechanically
(ErrBeakGroupNumberNotEnough -> ErrBreakGroupNumberNotEnough,
ErrRaceExinct -> ErrRaceExtinct) along with all callsites.

Engine errorResponse maps *GenericError by shelf rather than mapping
everything to 500. The Quit-not-last structural check in
Controller.ValidateOrder is preserved and its type assertion fixed
(was a value assertion against a pointer-typed command, so the check
silently never fired).

Backend, gateway and UI are unchanged — they were already correct on
the 202 path; only the engine collapsing per-command rejection into
500 was needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 09:36:29 +02:00
developer ce1dc19a29 Merge pull request 'feat(ui): F8-12 — map polish (#55)' (#70) from feature/issue-55-map-polish into development
Deploy · Dev / deploy (push) Successful in 51s
Tests · UI / test (push) Successful in 3m12s
Closes #55.
2026-05-28 12:21:17 +00:00
Ilia Denisov a37b784452 perf(ui): F8-12 — toggle responsiveness on 700-planet legacy reports (#55)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m11s
Profiling KNNTS041 (700 planets, 1283 primitives, 29 LOCAL fog
circles) flushed three independent costs out of the toggle path:

* `setVisibilityFog` rebuilt the inverse mask + 29 × 9 paint ops on
  every effect run, even when the input was identical. Caches a
  fingerprint of the circles + wrap mode and bails on a no-op
  call — knocks ~1 ms off every flip, more on heavier maps.
* `paintLabelEntry` was split into `paintLabelLayout` (hit-area /
  line positions / frame geometry — runs on every content change)
  and `paintLabelSelection` (text fills + frame visibility — runs
  only when the selection identity actually flips). The incremental
  path now skips the 6300 redundant `Text.style.fill = ...` writes
  it used to perform on every `planetNames` flip, which is what
  forced Pixi to invalidate the underlying text textures.
* `applyLabelContent` no longer blanks `nameText.text` when the
  toggle hides the name — it just flips `visible`. The cached text
  texture survives, so the next paint frame skips ~700 texture
  rebuilds.

Also enables Pixi-side culling on every per-copy primitive / outline
/ label container. With 9 torus copies × ~700 planets the scene
graph holds thousands of nodes, most of which sit outside the
visible viewport at any moment — the cullable flag lets Pixi skip
them in the per-frame traversal.

The legacy `KNNTS041` probe (chromium-desktop, headless) shows
`applyVisibilityState` collapsing from ~24 ms to ~5 ms after a
cache-warm flip; `app.render` drops from ~46 ms to ~22 ms. Reading
the toggle delay end-to-end inside the browser still measures
~460 ms in headless, which is consistent with the runner's RAF
cadence — owner can confirm on the real machine where the previous
~1 s delay was reported.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:10:29 +02:00
Ilia Denisov f4670c1831 perf+fix(ui): F8-12 — max-zoom clamp + planet-names toggle responsiveness (#55)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m16s
* Max-zoom clamp: `MIN_VISIBLE_WORLD_AT_MAX_ZOOM = 5` world units on
  the longest viewport axis. Tuned against the owner's
  debug-overlay readings — mobile longest ≈ 412 px clamps at
  scale ≈ 82, desktop longest ≈ 1200 px clamps at scale ≈ 240.
  Same formula adapts to both shapes automatically; no separate
  mobile / desktop branch.
* Planet-names toggle no longer rebuilds every Pixi.Text on a flip.
  When `setPlanetLabels` sees the same planet set (which is the
  common case — only the `name` lines toggling on / off), it walks
  the live label containers and just retunes text content +
  visibility instead of destroying and recreating 9 × N Text
  instances. A 500-planet map flips the toggle inside a frame now.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:58:43 +02:00
Ilia Denisov 4d729c1f50 feat(ui): F8-12 — smooth planet discs + ?debug=1 overlay (#55)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m18s
* Planet discs (and every other circle the renderer draws —
  outlines, picker hover ring, reach / bombing rings, etc.) trace
  a fixed 32-segment polygon instead of leaning on Pixi's adaptive
  bezier subdivision. PixiJS v8 picks the segment count from the
  world-space radius, which collapsed to 6-8 segments once the
  parent container's scale climbed — so the planet read as a
  visible polygon at high zoom. The custom path stays cheap (~64
  floats per disc) and gives a perceptually round silhouette at
  every zoom level.
* Opt-in dev overlay activated by `?debug=1` in the URL. A small
  bottom-left panel shows the current `scale`, the
  "whole world fits" reference scale, the current zoom ratio
  (scale / scale_ref), and the world-units rectangle visible in
  the viewport — so the owner can decide what `maxScale` to clamp
  to on the next iteration without guessing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:33:23 +02:00
Ilia Denisov 24d75564bb fix(ui): F8-12 — owner feedback round 3 (#55)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m28s
* Hit-test: a click inside a planet's visible disc always picks the
  planet, regardless of overlapping route shafts or battle X-crosses
  with higher base `priority`. Closes the #1, #2, #4 reports
  (picker hover would only catch the circumference, planet+routes
  swallowed disc clicks, label click on a battled planet routed to
  the battle viewer). Slop-only hits (cursor near a line but not on
  any disc) still use the existing priority order.
* Labels and planet outlines render in all nine torus copies again
  so they follow the player into wrap tiles — closes #3 (labels
  vanished on the wrong half of the viewport whenever the camera
  was panned past the wrap seam). The fingerprint guard keeps the
  per-toggle / per-selection rebuild cheap.
* Pixi.Text gets a few px of `padding` so the rasteriser no longer
  clips the last letter on a half-pixel measurement — closes #5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 10:17:08 +02:00
Ilia Denisov eb5018342e feat(ui): F8-12 — owner feedback round 2 (#55)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m18s
* Bug fix: theme flip no longer leaves planets oversized. The
  camera-preserving remount now calls a new
  `RendererHandle.refreshCameraDerivedDraws` explicitly after the
  manual moveCenter/setZoom pair so the post-mount geometry tracks
  `viewport.scaled` even if pixi-viewport's `'zoomed'` listener
  races the next Ticker tick.
* Doc #3: clicks on a planet label route through the same hit-test
  path as a click on the disc. The label `Container` now has a
  pointer hit area sized to the text + frame padding; pointertap
  simulates a click at the planet centre, so selection and
  pick-mode resolution behave identically.
* Doc #4: battle X-crosses + cargo arrowhead wings grow
  sub-linearly with zoom (PLANET_SIZE_ZOOM_ALPHA). New
  `Style.softLengthAnchor` ('center' / 'start') makes the renderer
  treat the recorded endpoints as the geometry "at the reference
  scale" and rescale around the midpoint (X-cross) or the start
  endpoint (arrow wings). Arrowhead base length is halved from 6
  to 3 world units to match the owner's "in half" request.
* Doc #5: picker overlay loses the anchor ring at the source, the
  cursor line drops to a cargo-route-thin 0.6 px stroke, and the
  hover ring around the destination is replaced by a planet-style
  outline (visible disc + 1 px padding) in the `pickHighlight`
  accent — so candidate destinations read like selection in warm
  yellow.
* Doc #6: regression test pins the in-disc hit zone.
* Perf #1: camera-driven redraws are throttled onto the next
  Ticker tick. A rapid wheel / pinch burst now coalesces into at
  most one `clear() + redraw` pass per painted frame, which keeps
  the 500-planet map responsive on zoom and toggle flips.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:40:20 +02:00
Ilia Denisov 6c3cd25476 add converted legacy report
Tests · UI / test (pull_request) Successful in 3m25s
2026-05-28 08:53:27 +02:00
Ilia Denisov 6996a79286 perf(ui): F8-12 — pixel-space planet sizing + single-copy label/outline layers (#55)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m20s
* Planet size formula moves to pixel-space:
  `pointRadiusBasePx = 2 + 2 * cbrt(size / SIZE_NORMALIZER)`. The
  on-screen disc now reads ~4-7 px at the reference zoom regardless
  of how large the world rectangle is — the previous `world-units`
  formulation blew up on small maps and made Source-class planets
  swallow their neighbours.
* Labels + outlines live in the origin copy only. The 9× replication
  across torus copies was the dominant cost on a 100+ planet map
  (Pixi.Text creation + Graphics rebuilds on every zoom step); the
  origin-copy layout is what the camera-wrap listener guarantees
  the user actually sees.
* `setPlanetLabels` and `setPlanetOutlines` skip Pixi-object
  rebuilds when the input fingerprint is unchanged — toggle flips
  and selection changes now keep the existing Text / Graphics
  instances alive and only repaint the affected pieces.
* `renderer.md` updated to the new contract.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:39:19 +02:00
Ilia Denisov 75a4211373 fix(ui): F8-12 — settle e2e regressions from the polish PR (#55)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m25s
* state-binding.ts: normalise planet size by the engine's typical
  mid-range (`SIZE_NORMALIZER = 100`) so legacy fixtures recording
  Size in the hundreds do not blow up the world-unit disc and start
  overlapping neighbouring planets. The cube-root growth stays;
  Size-800 reads twice as big as Size-100.
* cargo-routes.spec.ts: retire the selection-ring CirclePrim from
  the expected primitive count (4 planets + 3 cargo arrow lines = 7).
* map-toggles.spec.ts: bombing-rings → planet outlines (the high-bit
  0xc… range is permanently empty); planet-names persist test waits
  for the renderer's debug providers and for the IndexedDB write to
  flush before reload.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:14:14 +02:00
Ilia Denisov 680ebac919 feat(ui): F8-12 — map polish (zoom invariance, labels, selection, soft radius) (#55)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Failing after 5m16s
* Honest pixel-space sizing for `pointRadiusPx` / `strokeWidthPx`: the
  renderer divides by the current camera scale on every
  `viewport.zoomed` so thin lines / small markers stay the same on-screen
  size at any zoom.
* Known-size planets switch to `pointRadiusWorld`, softened against the
  reference scale by `PLANET_SIZE_ZOOM_ALPHA = 0.33`; unidentified
  planets pin to a 3-px disc.
* New planet label layer renders a two-line `name / #N` legend under
  each planet (`#N` only for unidentified or when the new `planetNames`
  toggle is off). Selection now paints an inverse-fill frame around the
  selected planet's label plus an outline on the disc; the old
  selection-ring primitive is retired.
* Bombing markers swap the separate CirclePrim for a planet-outline
  overlay (damaged / wiped colour); the report deep-link moves to a
  "view bombing report" link in the planet inspector.
* Docs + tests follow: `renderer.md` reflects the new sizing contract +
  label / outline layers, vitest covers the sizing math, label
  formatting, and the new toggle, and the map-toggles e2e adds a
  persistence case for `planetNames`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 23:51:16 +02:00
developer ba93a9092e Merge pull request 'feat(ui): F8-11 — battles table under table submenu (#54)' (#69) from feature/issue-54-battles-table into development
Deploy · Dev / deploy (push) Successful in 48s
Tests · UI / test (push) Successful in 2m50s
2026-05-27 20:29:52 +00:00
Ilia Denisov 209f8508cd feat(ui): F8-11 — battles table under table submenu (#54)
Tests · UI / test (push) Successful in 2m53s
Tests · UI / test (pull_request) Successful in 3m0s
Adds a sortable battles list as a new entity under the existing
`view → table` submenu (entity slug `battles`), replacing the
standalone top-level `battle log` shortcut which always opened a
"battle not found" placeholder. The single-battle viewer stays put
and is reached only by clicking a row (or a battle marker on the
map), identical to the existing `section-battles.svelte` flow.

Columns are planet (via the shared `planetLabel` helper) and shots
(the per-battle action count carried by `BattleSummary`), sortable
both ways with shots-desc default. No backend / FBS / map changes:
the wire payload is unchanged. Participants / observers / total
mass require the full BattleReport and were intentionally dropped
to avoid N round trips per menu open.

The top-level `battle log` item is removed from `header/view-menu`
and `sidebar/bottom-tabs` (and their stale comment blocks updated);
the now-orphan `game.view.battle` i18n key is dropped from both
locales.
2026-05-27 22:12:51 +02:00
developer e4fbb6644c Merge pull request 'feat(ui): F8-10 — tables planets / ship-groups / fleets, ship-classes delete guard (#53)' (#68) from feature/issue-53-table-planets-groups-fleets into development
Deploy · Dev / deploy (push) Successful in 55s
Tests · UI / test (push) Successful in 2m56s
2026-05-27 19:28:09 +00:00
Ilia Denisov 8e552f556d fix(ui): F8-10 owner-feedback — persistent filters, camera, disabled visual, dropdown narrowing (#53)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 2m53s
Polish pass after the first F8-10 walkthrough:

  - table-planets: moved the `foreign` chip to the end of the row and
    hid the race dropdown until `foreign` is on (it never made sense
    to pick a race while the bucket itself was off).
  - persistent per-table filter / sort state — extracted to
    `table-{planets,ship-groups,fleets}-state.svelte.ts` singletons so
    a row click → map → back to the table restores the prior chip /
    dropdown / sort state. Held in memory only; an F5 still resets.
  - table-ship-groups: the planet and class dropdowns now narrow to
    the slice surviving the owner checkboxes, so toggling `foreign`
    off removes planets / classes touched only by foreign rows.
  - map.svelte: camera (centre + zoom) is captured on every dispose
    path into a new `GameStateStore.lastCamera` and consumed on the
    next mount, so leaving the map for any other active view and
    coming back restores the prior pan / zoom. A pending focus from
    the tables still wins for the centre point.
  - table-ship-classes: `:disabled` now reads as disabled (muted
    colour, no hover ring, not-allowed cursor) — the click was already
    a no-op, only the visual was lying.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 21:18:11 +02:00
Ilia Denisov 80ed11e3b6 feat(ui): F8-10 — tables planets / ship-groups / fleets, ship-classes delete guard (#53)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m45s
Lights up three previously-stubbed table active views and tightens the
existing one:

  - table-planets: 4 kind checkboxes (own / foreign / uninhabited /
    unknown) + race dropdown that filters the foreign slice; row click
    selects + centres the planet on the map.
  - table-ship-groups: local + foreign groups in one grid, owner
    checkboxes, planet dropdown (destination OR origin), class
    dropdown; on-planet click focuses the destination planet, in-space
    click focuses the ship group itself (camera follows interpolated
    position).
  - table-fleets: own fleets only with the shared planet dropdown;
    on-planet click focuses the planet, in-space click centres the
    camera on the interpolated fleet position without altering the
    selection (no fleet variant in Selected).
  - table-ship-classes: per-row Delete is disabled with a count tooltip
    while at least one local ship group references the class. The
    engine refuses the removal anyway; the UI pre-empts the surface.

Wires the click → map flow through a transient `SelectionStore.focus`
/ `focusPoint` channel that `map.svelte` consumes once on mount —
in-memory only, so an F5 does not re-centre.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:35:38 +02:00
developer ef4cecb4b2 Merge pull request 'feat(ui): F8-09 — turn report sticky icon-popup section menu' (#67) from feature/issue-52-report-toc-popup into development
Deploy · Dev / deploy (push) Successful in 50s
Tests · UI / test (push) Successful in 2m47s
2026-05-27 17:52:59 +00:00
Ilia Denisov 1b2c13ecd6 fix(ui): F8-09 owner-feedback — fixed TOC trigger + heading offset (#52)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 2m44s
Owner-reported regressions in Firefox + Safari on desktop after the
initial F8-09 patch landed:

1. The TOC trigger rode up with the page during scroll instead of
   staying pinned to the viewport (mobile worked, desktop did not).
2. Clicking a popover item scrolled the matching section so its
   heading went up under the chrome — only the table body was visible.

Root cause for (1): the in-game shell declares `overflow-y: auto`
on `.active-view-host` so mobile (where `.game-shell` is fixed at
`inset: 0`) has an internal scroll region. On desktop the host
grows with content, no overflow ever engages, and the document
body becomes the actual scroll container. Per CSS spec the host
remains the "scrollport" for any `position: sticky` descendant, so
the trigger inside the report column never sees the scroll event
and rides up with the body content.

Fix:

- Swap the trigger from `position: sticky` to `position: fixed`.
  The component is mounted only while the report active view is on
  screen, so the fixed element is naturally tied to the view's
  lifetime. Anchor at `top: 4rem` (below the in-game header), and
  on `min-width: 1024px` shift `right` by 18 rem to clear the
  always-on sidebar; below 1024 px the sidebar is an overlay so
  the default `right: 1.25rem` matches the report's right padding.
- Add `padding-top: 4.5rem` to `.report-view` (4rem mobile) so the
  first section heading does not land under the trigger at scroll
  position 0.
- Add `scroll-margin-top: 7.5rem` to every `<section
  id="report-…">` so `scrollIntoView({ block: "start" })` lands
  the heading below the trigger after a popover-driven jump.
- Sync `ui/docs/report-view.md` §"Table of contents and active
  highlight" with the new positioning rationale.

Tests: `pnpm check`, `pnpm test` (821), `pnpm test:e2e
report-sections` (4 projects) all green.

Refs: #52 (#43 umbrella).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:45:44 +02:00
Ilia Denisov cfbe052242 feat(ui): F8-09 — turn report sticky icon-popup section menu (#52)
Tests · UI / test (push) Successful in 2m45s
Tests · UI / test (pull_request) Successful in 2m52s
- Replace the 14 rem sticky sidebar (and its mobile <select> twin)
  with a single sticky icon-popup trigger pinned to the top-right
  corner of the report column. Trigger shows `≡` followed by the
  currently active section title (CSS-clamped with text-overflow:
  ellipsis so long RU titles cannot bloat the button). Click opens
  an anchored popover on desktop and a fixed bottom-sheet on
  <768.98 px (mirrors lib/active-view/map-toggles.svelte).
- Each menuitem closes the popover and scrolls the matching
  `<section id="report-<slug>">` into view. The scroll is deferred
  one animation frame so the surface unmount + restoreFocus's
  focus restoration on the (sticky) trigger commit first; otherwise
  the focus call could cancel the just-started smooth/instant
  scroll under desktop Chromium and WebKit.
- Drop the in-report "Back to map" button — the same affordance
  lives in the app-shell view menu (tests/e2e/game-shell.spec.ts
  covers it).
- Tighten the report grid to a single flex column so the section
  body now occupies the full container width.
- i18n: remove game.report.back_to_map and
  game.report.toc.mobile_label; add game.report.toc.open and
  game.report.toc.close (mirrors game.map.toggles.open/close).
- Tests: Vitest report-toc.test.ts rewritten for the new icon-popup
  contract; Playwright report-sections.spec.ts switches the anchor
  loop to trigger → menuitem and adds a mobile bottom-sheet
  assertion; game-shell-stubs.test.ts no longer asserts the
  back-to-map button on the report orchestrator.
- Docs: ui/docs/report-view.md (TOC + i18n + test seams) and
  docs/FUNCTIONAL{,_ru}.md §6.4 updated. The stale SvelteKit
  Snapshot reference (the route file was removed by the single-URL
  app-shell) is dropped at the same time.

Refs: #52 (#43 umbrella).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 18:11:00 +02:00
developer 147c7d0a6a Merge pull request 'F8-05 — game-mode chrome cleanup + inspector compact rows' (#66) from feature/issue-48-game-chrome-and-inspector-compact-rows into development
Deploy · Dev / deploy (push) Successful in 43s
Tests · Integration / integration (push) Successful in 1m44s
Tests · Go / test (push) Successful in 3m14s
Tests · UI / test (push) Successful in 2m50s
2026-05-27 14:59:21 +00:00
Ilia Denisov 24c68e9846 feat(model+ui): F8-05 — race on OtherGroup, real attribution + N×M label
Tests · UI / test (push) Has been cancelled
Tests · Go / test (pull_request) Successful in 2m6s
Tests · Go / test (push) Successful in 2m6s
Tests · Integration / integration (pull_request) Successful in 1m51s
Tests · UI / test (pull_request) Successful in 3m53s
Issue #48 п.32 ("Stationed ship groups") shipped with a fragile race
fallback: when a foreign group sat on a non-`other`-kind planet the
inspector printed a generic "foreign" label, which collapsed the
race dropdown to a single uninformative bucket. The engine FBS
contract did not carry per-group race either, so live games hit the
same gap. This patch carries race authoritatively from the engine
through every layer down to the inspector.

Wire format & engine
- `pkg/schema/fbs/report.fbs`: add `race:string` to `OtherGroup` and
  `LocalGroup` (additive — old clients ignore).
- `pkg/schema/fbs/report/`: regenerated Go bindings.
- `ui/frontend/src/proto/galaxy/fbs/report/`: regenerated TS bindings.
- `pkg/model/report.OtherGroup.Race`: new field; carried through
  `LocalGroup` via the embedded `OtherGroup`.
- `pkg/transcoder/report.go`: encode + decode `race` on both
  `LocalGroup` and `OtherGroup`.
- `game/internal/controller/report.go.otherGroup`: set `v.Race`
  from `c.g.Race[c.RaceIndex(sg.OwnerID)].Name` so every emitted
  group — own or foreign — carries the resolved race name.

Legacy parser
- `tools/local-dev/legacy-report/parser.go`: capture the
  `<Race> Groups` header into `pendingOtherGroup.race`, fill local
  group `Race` from `p.rep.Race`, propagate both into the
  `report.OtherGroup` rows.
- Tests + smoke counts updated; regenerated `KNNTS{039,041}.json`
  fixtures so the synthetic loader carries the new field.

UI
- `ui/frontend/src/api/`: `ReportShipGroupBase.race` field;
  synthetic loader + FBS decoder populate it.
- `ui/frontend/src/lib/inspectors/planet/ship-groups.svelte`: the
  stationed-groups inspector picks race directly from
  `group.race` (own falls back to `localRace`, both finally to the
  `race.unknown` placeholder). The planet-owner / "foreign"
  heuristic is gone.
- Row label changes from "N ships mass M" to a compact
  `<class>` | `<N ×>` | `<mass>` three-column layout: the count
  cell is right-aligned tabular, the mass cell is right-aligned
  monospace + tabular, matching the inspector / calculator number
  conventions. Stale i18n keys removed
  (`ship_groups.row.count`, `.row.mass`, `.race.foreign`).
- All affected unit tests (8 files) carry the new `race` field.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 16:23:17 +02:00
Ilia Denisov cc4bc3c2b7 feat(ui+legacy): F8-05 owner-feedback round 1 — inspector tweaks + parser
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 2m45s
Owner-reported polish on top of #48, plus a legacy-parser gap that
prevented verifying stationed ship groups against a real .REP fixture.

UI:
- Production: drop the empty `(production)` placeholder option. Owned
  planets always produce something, so the primary select now opens on
  `industry` by default when `planet.production` is null/unknown,
  keeping the row inside the four real production kinds at all times.
- Production: lock the row to a single line (no flex-wrap) and strip
  border + padding from the ✓/✗ buttons so the apply/cancel icons read
  as glyphs and the row no longer breaks into two visual rows for
  Research / Ship contexts where both selects are present.
- Cargo routes: the placeholder option is now an `<option disabled>`
  styled like a section header (greyed, italic) and reads "manage
  routes" instead of "cargo routes". The wording shifts the intent
  from a section label to an action prompt.

Legacy parser:
- F8-05 (#48 п.32) "Stationed ship groups" couldn't be verified against
  the dg fixture because the legacy `<Race> Groups` blocks (outside
  battles) and the `Unidentified Groups` block were dropped by the
  parser — both are now wired up. Foreign group rows parse the
  `# T D W S C T Q D P M` columns and resolve the destination against
  the parsed planet tables (rows with an invisible destination drop,
  matching the existing local-group convention). The legacy row
  carries no origin / range columns, so foreign groups surface as
  stationed at the destination.
- Smoke tests on every fixture extended with `otherGroups` and
  `unidentifiedGroups` counts. New focused unit test
  `TestParseOtherAndUnidentifiedGroups` covers the column layout, the
  drop-on-unknown-destination rule, and the `X Y`-only unidentified
  rows.
- `tools/local-dev/reports/dg/KNNTS039.json` and
  `tools/local-dev/reports/dg/KNNTS041.json` regenerated so the
  synthetic-loader fixtures carry the new arrays.
- README updated: the two sections move out of "Skipped sections" into
  a "Foreign and unidentified groups" block; package doc-comment
  reflects the broader scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:21:55 +02:00
Ilia Denisov aee5f39a7e docs(ui): F8-05 — sync inspector topic docs with the new compact rows
Tests · UI / test (pull_request) Successful in 11m1s
- order-composer.md describes the production row's apply-gate (two
  selects + ✓/✗) and the click-to-edit entry point for planetRename.
- cargo-routes-ux.md replaces the four-slot grid description with the
  new single-row dropdown + contextual actions and notes the
  "stays on the picked type" UX rule.
- science-designer-ux.md updates the production-picker integration
  description to the dropdown pair and refreshes the e2e walkthrough
  step.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:41:44 +02:00
Ilia Denisov 4a23c357e5 feat(ui): F8-05 — game-mode chrome cleanup + inspector compact rows (#48)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Waiting to run
Drains six F8 polish items (parent #43) in one feature:

а) Chrome cleanup
- п.6 — remove the AccountMenu (settings/sessions/theme/language/logout
  ∼ rudimentary in-game) and replace it with a single icon-button
  light/dark theme toggle. The toggle flips an in-memory `theme.override`;
  game-shell unmount calls `theme.clearOverride()` so the lobby (and
  any re-entry) re-projects the persisted lobby choice.
- п.8 — remove the wrap-scrolling radio from the map gear popover. The
  per-game `wrapMode` store and the renderer's no-wrap path stay in
  place for a future engine-side topology feature; only the UI surface
  is dropped (wrap is a server-side concept, not a per-session UI
  affordance).

б) Inspector compact rows (single idiom: select + ✓ apply / ✗ cancel,
or contextual edit/remove/add)
- п.13 — planet name is now click-to-edit: clicking the name opens an
  inline `<input>` + ✓ confirm icon; Escape cancels; the explicit
  Rename action button and Cancel button are gone.
- п.14 — production becomes one row: primary `<select>` picks
  industry/materials/research/ship, conditional secondary `<select>`
  picks the target (tech / science / ship class) for research and
  ship contexts. Apply is gated until row state differs from the
  planet's current effective production; auto-submit-on-click is
  replaced by the apply-gate.
- п.16 — cargo routes collapse to one row: a single dropdown
  (COL/CAP/MAT/EMP plus a placeholder that absorbs the old section
  title) and contextual action buttons (add / edit + remove) to the
  right. After a successful pick or remove the dropdown stays on the
  type the user just acted on.
- п.32 — stationed ship groups hoist the race column into a dropdown
  above the table. The dropdown seeds with the player's own race when
  local groups are stationed here, otherwise the first race
  alphabetically; rendered only when more than one race is in orbit.
  The race column is dropped in both single- and multi-race modes —
  the dropdown's value already names the active race.

Tests: unit and Playwright e2e updated for every changed test-id and
flow; new coverage added for `theme.override`, the in-game toggle, the
apply-gate behaviour, and the stationed-race dropdown. i18n keys for
the removed menu items, the wrap radios, the cargo title, and the
explicit `rename.cancel` are dropped from both locales; new
`game.shell.theme_toggle.*`, `production.main/target.*`,
`production.apply/cancel`, `cargo.placeholder`, and
`ship_groups.race_filter.aria` keys land.

Docs synced: `docs/FUNCTIONAL.md` §6.7 + `docs/FUNCTIONAL_ru.md`
mirror drop the torus / no-wrap radio mention; `ui/docs/design-system.md`
documents the lobby-owned persisted picker + the in-game ephemeral
override channel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:38:42 +02:00
developer 2901ecb21b Merge pull request 'fix(ui): F8-08 unified number format — mono, fixed 3-decimal, no separators' (#65) from feature/issue-51-number-format into development
Deploy · Dev / deploy (push) Successful in 54s
Tests · UI / test (push) Successful in 2m50s
2026-05-27 10:14:05 +00:00
Ilia Denisov ed4e2f58a1 test(ui): F8-08 e2e — match new 1-dec percent + 3-dec float formatting
Tests · UI / test (pull_request) Waiting to run
Tests · UI / test (push) Successful in 2m50s
sciences.spec.ts: `sciences-cell-drive` now reads "25.0" (was "25") because
formatPercent always emits one fractional digit.

ship-classes.spec.ts: `ship-classes-cell-drive` now reads "1.000" (was "1")
because formatFloat always emits three fractional digits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:14:28 +02:00
Ilia Denisov b31d9f4c45 fix(ui): F8-08 unified number format — mono, fixed 3-decimal, no separators
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Waiting to run
Engine emits Floats at Fixed3 quantisation; UI now renders them as 3-decimal
fixed-point strings without thousand separators, monospaced via var(--font-mono)
on .numeric cells, and right-aligned in tables so columns line up on the
decimal point. Integer counts render with 0 decimals and no separators;
science fractions render as 1-decimal percent (matches the engine's third
decimal of precision).

Bug fixes from #51 (umbrella #43):
  - Player Status drive/weapons/shields/cargo: were tech LEVELS rendered
    through formatPercent (x100) — now use formatFloat (raw level).
  - Races table: same bug, same fix.

Style/UX cleanups:
  - Inspector field labels lose "stockpile" word ($ / M suffix carries it).
  - Coordinates drop the parentheses (just "x, y").
  - Inspector + report tables unify font sizes with calculator-tab
    (values 0.85rem mono, labels 0.8rem).

Files:
  - new util: ui/frontend/src/lib/util/number-format.ts
  - report/format.ts becomes a thin re-export to keep section imports compact
  - inspector planet / ship-group / actions: drop inline formatNumber,
    mark numeric <dd> with class="numeric"
  - table-races (+ bug fix), table-sciences, table-ship-classes,
    designer-science: drop inline formatters, switch to util, add
    class="numeric" on numeric <th>/<td>
  - 17 report section files: class="numeric" on numeric th/td +
    scoped CSS rule for mono+right-align
  - i18n en/ru: drop "stockpile" word, drop "%" from tech-level column
    headers in races + player_status (the "%" was the misleading bit
    from the bug)
  - tests/inspector-planet + tests/table-races: update assertions to
    match the new format

Verification: pnpm test (814 passed), pnpm check (0 errors/warnings),
pnpm build clean.

Refs: #51 (#43 umbrella).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:08:22 +02:00
developer 208d30073b Merge pull request 'fix(ui): F8-04b lobby — auto-expand first available games sub + hide empty invitations' (#64) from feature/lobby-default-expand-and-empty-invitations into development
Deploy · Dev / deploy (push) Successful in 55s
Tests · UI / test (push) Successful in 2m52s
2026-05-27 08:31:07 +00:00
Ilia Denisov 6fbab5417f fix(ui): F8-04b lobby — auto-expand first available games sub + hide empty invitations
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m45s
Two follow-up nits on the F8-04b sidebar:

1. The bare-`lobby` resolver (lobby-screen.svelte) redirected to
   `games-recruitment` unconditionally on mount. With games already
   in the player's roster the sidebar then highlighted the wrong
   sub-page. The resolver now awaits the lobby fan-out + account
   fetch, then hands off to the same `firstVisibleGamesScreen` helper
   the sidebar uses — so a fresh entry with games lands on
   `active-past`, the canonical-order fallback stays `recruitment`.

2. `games-invitations` was unconditionally visible in the sidebar.
   Now it follows the `active-past` rule: hidden until the
   pending-invites list reports >=1. The lobby shell's auto-kick
   effect treats it symmetrically — accepting / declining the last
   invite moves the player to the next visible sub-page once the
   fan-out has resolved.

Acceptance order in games-invitations-screen.acceptInvite was also
swapped to setMyGames-before-removeInvitation: both mutations land
in the same microtask, so the new auto-kick sees the freshly added
game in `myGames` when invitations drop to zero and routes the
player to `active-past` instead of bouncing through `recruitment`.

The visibility predicates and canonical order live in the new
`src/lib/lobby-nav.ts` pure helper, shared between the sidebar and
the resolver so they cannot disagree. Unit tests cover every
combination of (hasMyGames, hasInvitations, isPaidOrDev).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:17:57 +02:00
developer 8e8b34d112 Merge pull request 'fix(ui): F8-07 cargo-route picker — torus-wrap overlay + thinner arrows' (#63) from feature/issue-50-cargo-picker-torus into development
Deploy · Dev / deploy (push) Successful in 48s
Tests · UI / test (push) Successful in 2m53s
2026-05-27 08:08:10 +00:00
Ilia Denisov 175bf25794 docs(ui): F8-07 update renderer.md pick-mode lifecycle for per-copy overlay
Tests · UI / test (pull_request) Successful in 2m55s
Spec described the overlay as a single Graphics in the origin tile,
which was both the bug source and out of date after the F8-07 fix.
Updates the Open / Tick steps to describe the nine-copy replication
and the torus-shortest line endpoint contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:51:06 +02:00
Ilia Denisov 3d8aa91973 fix(ui): F8-07 cargo-route picker — torus-wrap overlay + thinner arrows
Tests · UI / test (push) Successful in 3m4s
Pick overlay (anchor ring, cursor line, hover outline) was drawn into a
single Pixi container — copies[ORIGIN_COPY_INDEX] — so any view of a wrap
copy lost it: picker from A1/A2 to the right (across the seam) showed no
hover highlight on A3's wrap copy, and the picker on A3 (x≈1.44, near the
left edge) put its anchor far left of the viewport. Fix replicates the
overlay across all nine torus copies (matching how primitives and fog
already render) and switches the cursor-line endpoint to torus-shortest
geometry via torusShortestDelta. Anchor and hover-outline coordinates
stay canonical; the per-copy replication renders them under the user's
view in whatever tile is on screen.

Also reduces cargo-route arrow strokes: COL/CAP/MAT 2->0.6 wu and EMP
1->0.4 wu (~3 / ~2 screen px at typical zoom) per the owner's request.

Tests cover the new torus path: source near the left edge with cursor on
the wrap copy across the seam (x axis), source near the top edge with
cursor across the y seam, and a guard that anchor / hover-outline coords
stay canonical regardless of the world argument.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:49:48 +02:00
developer 3153a95292 Merge pull request 'feat(lobby): F8-04b hierarchical sidebar + paid-tier gate for create-game' (#62) from feature/f8-04b-lobby-restructure into development
Deploy · Dev / deploy (push) Successful in 46s
Tests · Integration / integration (push) Successful in 1m42s
Tests · Go / test (push) Successful in 3m13s
Tests · UI / test (push) Successful in 3m2s
2026-05-27 07:18:32 +00:00
Ilia Denisov f42ab87233 test(ui): F8-04b mobile-safe assertion for free-tier private-games entry
Tests · UI / test (push) Successful in 2m52s
Tests · Integration / integration (pull_request) Successful in 1m50s
Tests · Go / test (pull_request) Successful in 2m3s
Tests · UI / test (pull_request) Successful in 2m52s
The desktop submenu (.desktop-only) is CSS-hidden on mobile
viewports — the mobile sidebar tucks the same sub-panel entries
behind a dropdown popover. Assert `toBeAttached()` instead of
`toBeVisible()` so the dev-bundle smoke check works on every
viewport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:31:03 +02:00
Ilia Denisov cff7cc3859 fix(ui): F8-04b e2e — viewport-agnostic nav + refresh after create
Tests · UI / test (push) Failing after 3m8s
- lobby-create-screen: call lobbyData.refresh() after a successful
  POST so the new game shows up in the private-games panel
  immediately. The shared lobby-data store is otherwise lazy
  (ensure-on-first-mount), which rendered a stale list across the
  post-create navigation in the e2e suite.
- e2e tests that move between lobby sub-panels now go through
  `window.__galaxyNav.go(...)` rather than clicking the sidebar
  items. The mobile sidebar tucks the submenu behind a dropdown, so
  testid-based clicks fail on the mobile-iphone-13 / pixel-5
  viewports — the dev nav surface bypasses that UX (which has its
  own coverage in `lobby-tier-gate` / future submenu specs).
- game-shell-map missing-membership test: assert
  `lobby-account-name` instead of `lobby-create-button` on
  drop-back-to-lobby (the button moved into the paid-only
  private-games sub-panel; the identity strip is the constant lobby
  chrome).
- inspector-ship-group + ship-group-send synthetic loader specs:
  jump straight to the dev-only `synthetic-reports` top-level
  screen via the dev nav surface before looking for the file
  input (the loader moved off Overview in F8-04b).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:25:49 +02:00
Ilia Denisov 058c4fcf69 test(ui): update profile-screen e2e for F8-04b sidebar rename
Tests · UI / test (push) Failing after 11m56s
`lobby-nav-overview` is replaced by `lobby-nav-games` (the new parent),
and the empty-games active-past sub-panel is hidden entirely so the
landing testid becomes `lobby-recruitment-empty` (the always-visible
sub-panel for a no-games session).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:55:46 +02:00
Ilia Denisov 009ea560f9 feat(lobby): F8-04b hierarchical sidebar + paid-tier gate for create-game
Tests · Go / test (push) Successful in 2m17s
Tests · UI / test (push) Waiting to run
Reshape the lobby UI from a single Overview into a two-level sidebar
(games · profile · DEV synthetic-reports) with four games sub-panels
(active-past · recruitment · invitations · private-games). Move the
`create new game` button into the private-games panel, merge the
applications section into recruitment cards as status chips, and add
DEV-only synthetic-report loader as a top-level screen.

Add a paid-tier gate at backend `lobby.game.create`: free callers get
`403 forbidden` before the lobby service is invoked. The UI hides the
private-games sub-panel + create button on free tier (DEV affordances
flag overrides). Update every integration test that creates a game to
use a new `testenv.PromoteToPaid` helper; add a new
`TestLobbyFlow_FreeUserCreateGameForbidden`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:53:53 +02:00
developer 98d1fe6cae feat(ui): lobby site-style sidebar + profile screen (#47)
Deploy · Dev / deploy (push) Successful in 49s
Tests · UI / test (push) Successful in 2m39s
Closes #47

Round 2 polish: time-zone dropdown, save-stay on profile, shared account store removing header flicker.
2026-05-26 20:53:14 +00:00
Ilia Denisov a679d9cdcb fix(ui): F8-04 profile polish — IANA timezone picker, save-stay, shared identity cache
Tests · UI / test (push) Has been cancelled
Tests · Go / test (push) Successful in 2m30s
Tests · UI / test (pull_request) Successful in 2m49s
PR-feedback round on #60:

- Time-zone field is now a continent-grouped <select> populated from
  `Intl.supportedValuesOf("timeZone")`, with the browser-detected
  zone pre-selected when no value is stored. A stored zone the
  runtime no longer advertises is preserved as an "Other" entry.
- Saving the profile no longer kicks the user back to the lobby:
  the form stays put and shows a transient `saved` notice, cleared
  on the next edit. Only `cancel` returns to the lobby.
- New `lib/account-store.svelte.ts` caches `user.account.get` for
  the session; lobby + profile share it through `account.ensure()`,
  so navigating Overview ⇄ Profile no longer flashes the
  "loading account…" placeholder or fires a second gateway call.
  Profile save writes through to the store so the shell identity
  strip picks up the new display name without refetching. Cleared
  on logout to prevent identity bleed between accounts.
- e2e: existing 4 cases adjusted for save-stay; added two new ones
  for the timezone dropdown and identity-strip stability across
  navigation.
- Docs: `ui/docs/lobby.md` updated to describe the shared cache,
  the new timezone picker shape, and the save-stay behaviour.
2026-05-26 22:38:14 +02:00
Ilia Denisov 2ecdecad1e feat(ui): lobby site-style sidebar + profile screen (#47)
- Wrap lobby and profile in a shared `lobby-shell.svelte` chrome:
  page-list sidebar (Overview/Profile) and a top "Player-xxxx"
  identity strip mirroring the project site's monospace look.
- Strip the legacy `lobby.title`, device-session-id `<code>`, and
  `lobby.greeting` paragraph; the identity strip both names the user
  and opens the profile editor.
- Add a top-level `profile` AppScreen with a three-field form
  (`display_name`, `preferred_language`, `time_zone`) backed by a new
  `src/api/account.ts` wrapper around `user.account.get`,
  `user.profile.update`, and `user.settings.update`. Saving switches
  the active i18n locale in-place when the new preferred language is
  one the UI ships translations for.
- Update e2e fixture + auth-flow / lobby-flow specs to use the new
  `lobby-account-name` testid and wait for the loaded identity before
  releasing pending `SubscribeEvents` (webkit revocation race). New
  `profile-screen.spec.ts` covers navigation, edit-save, and cancel.
- Sync `ui/docs/lobby.md` and `ui/docs/navigation.md` to the new
  layout.

Closes #47
2026-05-26 22:25:40 +02:00
developer b03993fcb1 Merge pull request 'fix(ui): F8-06 calculator polish — input steps, lock idiom, tech floor, speed-lock fix' (#61) from feature/issue-49-calculator-polish into development
Deploy · Dev / deploy (push) Successful in 45s
Tests · Integration / integration (push) Successful in 1m41s
Tests · Go / test (push) Successful in 3m10s
Tests · UI / test (push) Successful in 2m41s
2026-05-26 17:23:26 +00:00
Ilia Denisov b01a60e42b fix(ui): F8-06 calculator polish — drop delete-class button, reserve lock slot
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m48s
Tests · Go / test (pull_request) Successful in 2m1s
Tests · UI / test (pull_request) Successful in 2m34s
- Remove the `delete <ship_class_name>` button (and `deleteClass`,
  `canDelete`, `.delete` CSS, `game.calculator.action.delete` i18n key)
  from the calculator. Delete-class lives in the ship-classes table —
  the broader rework will land under #53.
- Bombing and cargo-capacity rows now reserve a hidden lock-slot
  placeholder so their value column lines up vertically with the
  mass/speed/attack/defence rows (which carry a lock button).
2026-05-26 19:10:59 +02:00
Ilia Denisov cc4727a32e fix(ui): F8-06 calculator polish — always 3-decimal display, mono font, input cap
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m48s
Tests · Go / test (pull_request) Successful in 2m3s
Tests · UI / test (pull_request) Successful in 2m28s
Owner feedback round 2 on PR #61:

- Pad every read-only calculator value to three decimals: tech labels,
  derived results (mass, speed, attack, defence, bombing, cargo
  capacity), planet MAT, planet build-rate, modernization cost, and
  the full-cargo capacity label all read as "1.000" instead of "1",
  matching the goal-seek back-solved input and the report. Drops
  thousands grouping so the same `fmt()` string also embeds cleanly
  in the read-only `<input type="number">` cell.
- Switch label and input styling onto the existing `--font-mono`
  token (right-aligned, tabular-nums) so columns line up vertically
  across rows like a financial table.
- Refuse a fourth decimal as the user types in every calculator
  number input (DWSC blocks, tech, MAT, custom load, lock value,
  modernization target tech): the `oninput` truncates the input text
  past three decimal digits and explicitly writes the truncated
  value back through `bind:value`, so Svelte's later reactive flush
  cannot undo the cap.
- Doc + tests follow the rule (five new vitest cases covering the
  3-decimal label format, the input cap on each input class, and
  the integer-padding rule for derived results).
2026-05-26 18:43:32 +02:00
Ilia Denisov cbf7f65916 fix(ui): F8-06 calculator polish — unified spinner UX, lock-infeasible on (0, 1), dropdown reset-changes
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m45s
Tests · Go / test (pull_request) Successful in 2m3s
Tests · UI / test (pull_request) Successful in 2m28s
Owner review on PR #61:

- п.9 (option B). Hide the native spinner on EVERY numeric input in
  the calculator (DWSC blocks, armament, tech, planet MAT, custom
  load, lock value, modernization target tech) and drive every step
  through ArrowUp / ArrowDown. The column widths stay stable and the
  inputs read consistently across the whole row. The ship blocks
  keep the smart (0 ↔ 1) jump on ArrowUp/ArrowDown; armament steps
  ±1 with a JS handler instead of relying on the native spinner.
  Other inputs step by their natural grain (±0.001 for tech / lock,
  ±0.01 for MAT / load).
- п.10. Tech-level labels (`tech-val`) and the planet MAT label
  (`mat-val`) now read through the same `Ceil3` formatter as the
  derived results, so plain-text numeric values share the report's
  3-decimal tabular formatting. The design-area component receives
  `formatNumber` as a prop; the resolved (goal-seek) cell uses the
  same formatter, so the read-only computed value matches the rest
  of the row.
- п.12. `computeCalculator` now validates the back-solved block
  against the same DWSC rule the live validator enforces (`0` or
  `≥ 1`). When the solver lands in the `(0, 1)` gap (e.g. attack
  0.5 / weaponsTech 1.5 → weapons 0.333…) the lock is flagged
  infeasible — the lock input flips red and the claimed block is
  NOT back-solved into the invalid range, so the design preview
  keeps reading the user's own typed values instead of silently
  showing a sub-1 block.
- new. Selecting an existing ship class from the name datalist now
  loads it immediately. `change` fires only on blur in Firefox,
  which is why the previous behaviour looked delayed; switching the
  load to `oninput` with an `InputEvent.inputType` check makes the
  load synchronous everywhere (datalist replacement carries
  `"insertReplacementText"` in Chromium / WebKit, `undefined` in
  Firefox; keyboard typing always carries a typing `inputType`).
  Before loading we compare the live blocks to the previously
  loaded class (or to the empty defaults) and, if they differ, ask
  through a `window.confirm`. On decline we revert the name field
  and leave the design untouched.

Tests: calculator-tab and calc-model gain six cases (armament
step, tech/MAT formatter labels, lock infeasible on (0, 1) for
both attack→weapons and emptyMass→cargo, lock-value Arrow step,
dropdown immediate load + confirm-blocks-load + confirm-allows-load),
all 779 vitest tests green. docs/calculator-ux.md follows the new
behaviour.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:02:56 +02:00
Ilia Denisov e9b904332e fix(ui): calculator polish — smart input steps, unified tech/MAT lock idiom, tech floor, speed-lock ceiling fix
Tests · Go / test (push) Successful in 2m31s
Tests · UI / test (push) Waiting to run
Tests · Integration / integration (pull_request) Successful in 1m41s
Tests · Go / test (pull_request) Successful in 3m14s
Tests · UI / test (pull_request) Successful in 2m32s
- pkg/calc: DriveForSpeed treats restMass==0 as a valid ceiling-only
  case (every positive drive solves it), so locking the displayed
  speed of a D=1, W=A=S=C=0 ship is no longer a phantom "infeasible".
- ship-design-area: drive/weapons/shields/cargo inputs use a JS-driven
  smart step on ArrowUp/ArrowDown (0↔1 jump, otherwise ±0.1) and hide
  the native spinner so it cannot produce invalid (0, 1) values;
  armament keeps its native step 1.
- Tech and planet MAT cells follow the same lock idiom as goal-seek
  locks: open padlock (🔓) over the inherited value → click to open
  an input with a closed padlock (🔒). The padlock slot is always
  reserved, so the column width is stable.
- Tech overrides (design area and modernization target) are floored
  at the player's current tech on this turn — a lower value is
  flagged as invalid.
2026-05-26 14:30:43 +02:00
developer 793b709d8f feat(ui): readable order card — status as background tint, corner ✕ (#58)
Deploy · Dev / deploy (push) Successful in 49s
Tests · UI / test (push) Successful in 2m40s
Closes #46

F8-03 — повышаем читаемость карточки приказа: перенос длинного текста, статус в фоне карточки через --color-{success,danger,warning}-subtle, маленький угловой ✕ всегда видимый.
2026-05-26 06:39:09 +00:00
Ilia Denisov 2294d8b3d9 fix(ui): tighter order card — calculator-scale font, corner-flush ✕;
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 2m47s
stabilise report-sections e2e

Owner review on PR #58:

- shrink the order-card body to 0.8rem (matching the calculator's body
  text scale) so the order list reads as part of the sidebar's
  density, not its own larger surface;
- shrink the delete ✕ to 0.95rem and glue it flush to the card's
  top-right corner (no offset, sized to fit the corner padding-space);
- tighten the card padding to match the smaller text.

Independently — the same review asked to fix `report-sections › every
TOC anchor lands its section in view`, which had been a long-standing
e2e flake (run #366 on `development` already failed it twice before
passing on retry; my PR's run #367 simply exhausted all five retries).
The root cause is the smooth `scrollIntoView` settling slower than
Playwright's 5 s viewport wait under heavy CI load. The production
TOC already honours `prefers-reduced-motion: reduce` and swaps to an
instant scroll there; switching the Playwright config to that media
mode makes every spec deterministic without touching production code.
2026-05-26 08:28:58 +02:00
Ilia Denisov 5ca30df334 feat(ui): readable order card — status as background tint, corner ✕
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m48s
The order-tab row now wraps long labels (`overflow-wrap: anywhere`),
encodes status into the card background via the design-token subtle
palette (applied → success-subtle, invalid/rejected/conflict →
danger-subtle, draft/valid/submitting → warning-subtle), and exposes
a small framed `✕` delete button absolutely positioned in the
card's top-right corner — always visible, labelled by
`game.sidebar.order.command_delete` for assistive tech. The textual
status name remains in the DOM as an `.sr-only` node so screen
readers and the existing testids still observe it.

Refs #46
2026-05-26 07:23:44 +02:00
developer 1f6791549a Merge pull request 'fix(ui): turn navigator no-op when re-selecting the on-screen turn' (#57) from feature/issue-45-turn-navigator-noop into development
Deploy · Dev / deploy (push) Successful in 47s
Tests · UI / test (push) Successful in 2m41s
2026-05-26 05:13:23 +00:00
Ilia Denisov e82c9f8bbd fix(ui): no-op when re-selecting the turn already on screen
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 3m35s
Clicking the current-turn row in the header turn navigator while
already viewing it routed through returnToCurrent() →
viewTurn(currentTurn), which re-fetches the live report and flips the
view through `loading`. At turn 0 the only row is the live turn, so
the dropdown always fired a pointless backend round-trip and redraw.

Guard goToTurn() against re-selecting the on-screen turn
(turn === viewedTurn): just close the popover and stop. Leaving
history is unaffected — there the viewed turn differs from the target.

Closes #45

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 00:18:30 +02:00
developer b957d17022 Merge pull request 'feat(ui): autofocus login fields; keep verification code out of form history' (#56) from feature/issue-44-login-autofocus-otp into development
Deploy · Dev / deploy (push) Successful in 47s
Tests · UI / test (push) Successful in 2m59s
2026-05-25 22:11:35 +00:00
Ilia Denisov 3d5b331bd9 feat(ui): autofocus login fields; keep verification code out of form history
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m51s
The two-step e-mail login now drops the cursor on each step's primary
field as it mounts — the e-mail field on load, the code field once the
e-mail step advances — via a small `use:` action. Focusing fires each
input's onfocus, which clears the readonly autofill guard, so the field
is editable straight away.

The code input now requests `autocomplete="one-time-code"` instead of
`new-password`. The latter is a password-manager hint and does not stop
Firefox saving the typed code to form history (it was offering the
previous code back in a dropdown). `one-time-code` is the semantic token
for a verification code; Firefox honours it specifically to keep the
value out of form history (Mozilla bug 1547294). The e-mail field keeps
`new-password` to fend off saved-login autofill.

Tests: new Vitest cases assert autofocus on both steps and the code
field's `one-time-code` token; a new Playwright case covers the same in
Chromium and WebKit (Safari engine). Firefox form history is owner
manual-QA — there is no Firefox project in the e2e matrix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 23:53:20 +02:00
developer 6f2967024a Merge pull request 'feat(ui): map canvas follows light/dark theme; fix invisible gear control' (#42) from feature/issue-40-map-light-theme into development
Deploy · Dev / deploy (push) Successful in 1m58s
Tests · UI / test (push) Successful in 2m39s
2026-05-24 07:15:08 +00:00
Ilia Denisov f6e4a4f6bd feat(ui): map canvas follows light/dark theme; fix invisible gear control
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m45s
The map view now selects a DARK_THEME or LIGHT_THEME palette from the
resolved app theme and threads it through every primitive builder, so
the canvas, planets, ship groups, cargo routes, battle/bombing markers,
fog, reach + selection rings, pending-Send tracks, and the pick overlay
all switch with the rest of the chrome. A theme flip remounts the
renderer preserving the camera — Pixi bakes the background at init and
every primitive bakes its colour at build, so a live re-tint is not
possible on the same instance.

This also fixes the reported bug: the gear-popover trigger and the
loading overlay hardcoded a dark navy background, so in light theme the
gear was invisible (dark icon on dark chip) until hover flipped it to a
white chip. Both now use the --color-surface-overlay token and read
correctly in both themes.

The light palette mirrors the dark one role-for-role, darkened /
saturated for contrast on a light background while keeping the incoming,
battle, and bombing accents vivid. The values are a first pass meant to
be refined during the F8 manual-QA loop.

Removes the now-dead "Phase 35" references from the code and lifts the
map-recoloring prohibition from the design-system / renderer docs; the
battle scene stays a fixed-palette data-viz surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 08:49:37 +02:00
owner d44ad9b6eb Merge pull request 'fix(gateway): verify client signature before payload_hash' (#41) from feature/issue-39-verification-order into development
Deploy · Dev / deploy (push) Successful in 48s
Tests · Go / test (push) Successful in 2m1s
Tests · Integration / integration (push) Successful in 1m40s
Reviewed-on: #41
2026-05-24 05:59:44 +00:00
Ilia Denisov 91e34a0929 fix(gateway): verify client signature before payload_hash
Tests · Go / test (push) Successful in 2m1s
Tests · Go / test (pull_request) Successful in 2m58s
Tests · Integration / integration (pull_request) Successful in 1m39s
ARCHITECTURE.md §15 "Verification order" specifies signature verification
(step 4) before payload_hash (step 5), but the authenticated-edge
decorator chain wrapped the payload-hash gate outside the signature gate,
so the hash was checked first. gateway/README.md and gateway/docs/flows.md
had drifted to match the code (hash-first), leaving ARCHITECTURE.md as the
lone source describing the intended order.

Swap the two decorators in server.go so the signature gate runs first, and
align README + flows.md to ARCHITECTURE.md. Signature-first is the
cryptographically sound order: the signature covers the payload_hash field,
so the request is authenticated before any of its content is processed.

Observable side effect: a request carrying a tampered payload_hash whose
signature was computed over the original hash is now rejected at the
signature gate (UNAUTHENTICATED "invalid request signature") instead of the
hash gate (INVALID_ARGUMENT). Security is unchanged — both refusals happen
before the payload is handled. The four payload-hash unit tests re-sign
over the tampered hash so they keep exercising the hash gate; the
cross-service integration test signs over the overridden hash and already
accepts both codes.

Refs #39

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 02:42:09 +02:00
developer f0857243e2 Merge pull request 'feat(ui): single-URL game app-shell (in-memory screens/views)' (#35) from feature/ui-app-shell into development
Deploy · Dev / deploy (push) Successful in 46s
Tests · UI / test (push) Successful in 2m59s
2026-05-23 20:18:09 +00:00
Ilia Denisov 1dadf08672 docs(ui): clarify lobby re-enter starts at the map, only refresh restores
Tests · UI / test (pull_request) Successful in 2m32s
The single-URL restore replays the saved screen/view on an in-place
refresh only. Re-entering a game from the lobby resets activeView to the
map (lobby calls activeView.reset() before appScreen.go("game")), and
browser Back / the return-to-lobby control exit to the lobby. Spell this
out so the refresh-restore is not mistaken for a per-re-enter restore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:14:15 +02:00
Ilia Denisov c1672224a6 fix(ui): pin the mobile game shell to the viewport
Tests · UI / test (push) Successful in 2m49s
The app-shell migration surfaced a mobile-only e2e failure: taps on the
bottom-tab bar, the map-toggles menu, and the planet sheet were
intercepted by sibling elements despite the targets being on top.

Root cause: `.game-shell` used `min-height: 100vh`, so sub-pixel content
overflowed the viewport and made the document scrollable. On mobile that
scroll toggles the browser's dynamic toolbar, which resizes the viewport
and every `position: fixed` overlay (their sizes derive from `100vh`)
mid-gesture — defeating Playwright's actionability hit-test, and making
the real controls jittery to tap.

Pin the shell with `position: fixed; inset: 0` on the mobile breakpoint
so it leaves document flow: the document can no longer scroll, the
toolbar stays put, the viewport and overlays stay stable, and the
active-view area remains the single internal scroll region. Desktop is
unchanged (the rule is scoped to max-width: 767.98px).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:40:06 +02:00
Ilia Denisov e31fb2c17a docs(ui): sync docs to the app-shell; fix stale nav comments
Tests · UI / test (push) Failing after 9m28s
Rewrite ui/docs (navigation, order-composer, auth-flow, pwa-strategy,
game-state + secondary topic docs) and ui/README for the single-URL
app-shell (in-memory screens/views, Back→lobby via shallow routing,
sessionStorage restore + validation, return-to-lobby). ui/PLAN.md gets a
Phase-10 supersede note (implemented; standalone-compatible). Fix stale
code comments (session-store auth gate, report-sections spec contract).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:04:11 +02:00
Ilia Denisov 4e0058d46c test(ui): migrate suite to the app-shell (state-driven navigation)
- Unit: repoint moved screen imports (lib/screens, lib/game), mock
  $lib/app-nav (appScreen/activeView) instead of $app/navigation, drop the
  removed gameId props, assert screen/view selection.
- e2e: add a dev-only window.__galaxyNav affordance; specs enter a game via
  enterGame(...) instead of a /games/:id URL; URL assertions become content
  assertions (the URL stays /game/); reload uses waitUntil:"commit" (shallow
  routing) and mocks /rpc on game entry.
- Remove the obsolete report scroll-restore test (it relied on a SvelteKit
  route Snapshot that no longer exists); update the missing-membership test
  to the new lobby-redirect+toast behaviour. Fix a stale report.svelte
  docstring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:49:35 +02:00
Ilia Denisov 80545e9f9d feat(ui): app-shell behaviour — restore validation, return-to-lobby, push
- A restored game that no longer exists (cancelled/removed/revoked) drops to
  the lobby with a toast instead of the in-game error state: game-state
  exposes a `notFound` flag and the shell redirects via appScreen.go("lobby").
- Add a visible "return to lobby" control to the in-game header.
- Push/toast deep-links use activeView.select(...) (no URL); fix a latent
  visibility-listener double-install on in-place game switches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:11:54 +02:00
Ilia Denisov be7f06e163 feat(ui): screen-level history for the app-shell (Back → lobby)
Mirror the screen into browser history via SvelteKit shallow routing
(pushState/replaceState with page.state) so Back/Forward move between
screens while the URL stays at /game/. Overlays (game, lobby-create) push;
lobby/login replace. A popstate→page.state effect syncs the store back
without re-pushing (no loop); the boot stamp puts a restored overlay above
the load entry so Back falls through to lobby. In-game view switches never
touch history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:07:03 +02:00
Ilia Denisov b6770d394c feat(ui): app-shell core — single-route dispatcher, route collapse, nav→state
Collapse the game UI to one route (`/`): a screen dispatcher renders
login/lobby/lobby-create/game from `appScreen`/`activeView` state instead of
URL routes. Move screen components to lib/screens & lib/game; the game shell
reads the game id from `appScreen.gameId` and re-inits per-game stores via an
$effect; in-game views render from `activeView`. Flip ~23 goto/href nav sites
to store mutations; drop the `?sidebar=` URL coupling. Auth gate is now
state-based. WIP: browser-history (Back→lobby), restore-validation, the
return-to-lobby button, push deep-links, and the test migration are follow-ups
on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:04:04 +02:00
Ilia Denisov 182beebcd6 feat(ui): app-nav state stores (app-shell foundation)
Add `appScreen` + `activeView` rune singletons with a shared sessionStorage
snapshot — the in-memory source of truth that replaces URL-based screen/view
routing for the single-URL app-shell. Not wired in yet (additive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:45:27 +02:00
developer ae91037bc3 Merge pull request 'feat(deploy): single-origin path-based deployment + project site' (#34) from feature/deploy-single-origin into development
Deploy · Dev / deploy (push) Successful in 43s
Tests · Go / test (push) Successful in 2m3s
Build · Site / build (push) Successful in 9s
Tests · Integration / integration (push) Successful in 1m49s
Tests · UI / test (push) Successful in 2m36s
2026-05-23 17:24:25 +00:00
Ilia Denisov ec98639d49 fix(site): link to the game with target=_self to avoid VitePress SPA 404
Build · Site / build (push) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 1m38s
Build · Site / build (pull_request) Successful in 10s
Tests · Go / test (pull_request) Failing after 3m0s
Tests · UI / test (pull_request) Successful in 2m35s
VitePress is a Vue SPA; a same-origin link to /game/ (a separate app, not
a VitePress page) was intercepted by its client router and rendered
VitePress's own 404 until a manual reload. Mark the game links (both
home pages and the nav item) target="_self" so the click is a real
browser navigation that the edge Caddy serves from the game bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:13:15 +02:00
Ilia Denisov 9cb5097f54 fix(ui): redirect app root to lobby/login; evict stale root service worker
Tests · UI / test (push) Has been cancelled
Build · Site / build (push) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 1m42s
Build · Site / build (pull_request) Successful in 6s
Tests · UI / test (pull_request) Successful in 2m23s
Tests · Go / test (pull_request) Successful in 1m56s
- The app root ("/", i.e. /game/) rendered a dev "workspace skeleton"
  stub, and the layout guard only redirected anonymous users off it, so
  an authenticated visitor stayed on the stub. Redirect "/" to /lobby
  (authenticated) and /login (anonymous), and replace the stub with a
  minimal loading placeholder. Drop the obsolete landing-stub unit test
  (root redirect is covered by the auth-flow e2e).
- Ship a tombstone /service-worker.js on the project site so any old
  root-scoped PWA worker (from when the game lived at the origin root)
  unregisters itself instead of serving a stale cached page at the
  site origin. The game now registers its worker only under /game/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 18:53:16 +02:00
Ilia Denisov a453b74b04 test(ui): assert relative manifest start_url in the PWA spec
Tests · UI / test (push) Successful in 2m28s
Tests · Integration / integration (pull_request) Successful in 1m46s
Build · Site / build (pull_request) Successful in 10s
Tests · Go / test (pull_request) Successful in 2m1s
Tests · UI / test (pull_request) Successful in 2m46s
The single-origin manifest now uses relative URLs (`start_url: "./"`) so
it stays base-agnostic under `/` and `/game/`. Update the PWA spec to
assert the relative value instead of the old absolute `/`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 18:24:55 +02:00
Ilia Denisov 8565942392 feat(deploy): single-origin path-based deployment + project site
Build · Site / build (push) Successful in 8s
Tests · Go / test (push) Successful in 2m22s
Tests · UI / test (push) Failing after 2m42s
Serve the whole stack behind one host: site at /, game UI at /game/,
gateway REST at /api + /healthz, Connect at /rpc (prefix stripped by the
edge Caddy). The built artifact is domain-agnostic — the UI talks to the
gateway same-origin via relative URLs, so the same bundle runs under any
host with no rebuild and with CORS disabled.

- Rename the Connect proto service galaxy.gateway.v1.EdgeGateway ->
  edge.v1.Gateway; regenerate Go + TS; public path /rpc/edge.v1.Gateway.
- Move the game UI under base path /game (env BASE_PATH); make the
  manifest, service-worker scope, WASM loader, and all navigation
  base-aware via a withBase helper.
- Relative API + /rpc Connect prefix; Vite dev proxy mirrors the strip.
- Rewrite the edge Caddy (dev + prod) for path-based routing; empty CORS
  allow-lists (same-origin); single host.
- New VitePress project site (site/): i18n en/ru with switcher, LaTeX
  math, minimal monospace theme; built and served at /.
- dev-deploy compose/Makefile + CI (dev-deploy, prod-build, new
  site-build) build and seed the site; probes hit /, /game/, /healthz.
- Sync docs (ARCHITECTURE, gateway README/openapi, dev-deploy &
  local-dev READMEs, CLAUDE.md, ui/PLAN).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 18:19:07 +02:00
developer fa0df5183a Merge pull request 'chore: remove deprecated client' (#33) from chore/cleanup-code-and-repo into development
Deploy · Dev / deploy (push) Successful in 45s
Tests · Integration / integration (push) Successful in 1m37s
Tests · Go / test (push) Successful in 3m11s
Tests · UI / test (push) Successful in 2m34s
Reviewed-on: https://gitea.lan/developer/galaxy-game/pulls/33
2026-05-23 14:57:28 +00:00
Ilia Denisov 8e0a1c39c0 chore: remove deprecated client
Tests · UI / test (pull_request) Has been cancelled
Tests · Go / test (push) Successful in 2m25s
Tests · UI / test (push) Waiting to run
Tests · Integration / integration (pull_request) Successful in 1m44s
Tests · Go / test (pull_request) Successful in 2m2s
2026-05-23 16:55:02 +02:00
developer 780769b3c4 Merge pull request 'docs(ui): documentation finalization (F7)' (#32) from feature/ui-finalize-f7-docs into development 2026-05-22 14:07:02 +00:00
Ilia Denisov 53fb4f5f76 docs(ui): finalize docs sync — README finalized-web summary + topic links (F7)
The de-archaeology and the ui/docs index landed in the earlier reorg
(0 "Phase N" refs in ui/docs/; 24 topic docs linked). This finishes the
sync: ui/README.md gains a finalized-web-target summary and links the new
topic docs (design-system, a11y, error-state-ux, pwa-strategy).
docs/ARCHITECTURE.md and docs/FUNCTIONAL.md need no change — they cover
the backend/gateway/cross-service contracts with no stale UI statements;
the finalized UI is client-local UX documented under ui/docs/. Marks F7
done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:06:27 +02:00
developer 1dd8df9f6e Merge pull request 'feat(ui): installable offline PWA (F5)' (#31) from feature/ui-finalize-f5-pwa into development
Deploy · Dev / deploy (push) Successful in 46s
Tests · UI / test (push) Successful in 2m55s
2026-05-22 14:02:40 +00:00
Ilia Denisov 11f51944df fix(ui): register the service worker in production only
Tests · UI / test (push) Successful in 2m19s
Tests · UI / test (pull_request) Successful in 2m25s
SvelteKit's automatic SW registration also runs under `vite dev`, where
the worker intercepted/cached the dev-server e2e suite (42 failures).
Disable auto-registration (kit.serviceWorker.register: false) and
register the worker manually from the root layout guarded by `!dev`, so
`vite dev` and the e2e suite run worker-free while the production build —
and the PWA preview test — still install it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:56:32 +02:00
Ilia Denisov 04c7f6e68a feat(ui): installable offline PWA — service worker, manifest, icons (F5)
Tests · UI / test (push) Failing after 7m31s
Native SvelteKit service worker (src/service-worker.ts): a version-keyed
cache precaches the app shell + build artefacts (incl. core.wasm) +
static files; activate purges old caches; the gateway is never
intercepted; navigations fall back to the cached shell offline. Adds
static/manifest.webmanifest, a generated placeholder icon set
(scripts/gen-pwa-icons.mjs — dependency-free pure-Node PNG encoder), and
manifest / theme-color / apple-touch tags in app.html.

Gated by Playwright against a production preview (playwright.pwa.config.ts
+ tests/pwa/pwa.spec.ts via `pnpm test:pwa`, wired into ui-test):
manifest + installable icons, SW registration + a single version-keyed
cache, and offline shell load. Lighthouse is not used — its PWA category
was removed in v12.

Docs: ui/docs/pwa-strategy.md (+ index); F5 marked done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:46:42 +02:00
developer c066a8958e Merge pull request 'build(ui): build core.wasm in CI, stop committing the binary (F6)' (#30) from feature/ui-finalize-f6-wasm-ci into development
Deploy · Dev / deploy (push) Successful in 44s
Tests · UI / test (push) Successful in 2m18s
2026-05-22 12:37:24 +00:00
Ilia Denisov b729036778 build(ui): build core.wasm in CI, stop committing the binary (F6)
Tests · UI / test (push) Successful in 3m48s
Tests · UI / test (pull_request) Successful in 2m35s
core.wasm and wasm_exec.js are no longer tracked (untracked + gitignored).
A reusable composite action .gitea/actions/build-wasm installs TinyGo
(actions/cache'd) and runs `make -C ui wasm`; it runs in all three
frontend-building workflows — ui-test (before Playwright; Vitest uses the
fake Core and needs no build), dev-deploy, and prod-build. ui-test gains a
Go setup (TinyGo shells out to Go); the deploy workflows already had one.

Docs: ui/docs/wasm-toolchain.md, ui/README.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:29:33 +02:00
developer 9d3a652b6b Merge pull request 'feat(ui): error & state UX (F4)' (#29) from feature/ui-finalize-f4-error-ux into development
Deploy · Dev / deploy (push) Successful in 35s
Tests · UI / test (push) Successful in 2m19s
2026-05-22 11:53:16 +00:00
Ilia Denisov b07b8fb1c8 test(ui): cargo-routes counts the selection ring in the primitive total
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 2m4s
The F4 selection ring is a real map primitive. The cargo-route flow has
the source planet selected, so the total primitive count is 8 (7 + the
ring circle), not 7; the line count (3) is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:50:42 +02:00
Ilia Denisov 35e27c5aec fix(ui): bottom-sheet tap-outside only fires while the sheet is shown
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Failing after 2m53s
The planet/ship-group sheets stay mounted on desktop but are hidden by a
media query (`display: none`); the document-level tap-outside listener
fired regardless, so the first click after selecting a planet cleared the
selection — breaking every desktop inspector/select flow in CI. Guard the
handler on the sheet's computed display (`offsetParent` is unreliable for
`position: fixed`). The swipe handle is naturally inert when hidden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:45:41 +02:00
Ilia Denisov 8dcaf1c6c6 feat(ui): error & state UX — error surface, view states, map selection, sheet gestures (F4)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Failing after 7m13s
- lib/error/: classify any caught error into a stable ErrorKind from the
  transport signal (HTTP status / Connect Code / fetch TypeError /
  navigator.onLine); map to translated error.* messages via reportError
  (sticky Retry toast for retryable kinds) or errorMessageKey (inline).
  Mail compose now surfaces the translated 403/error inline.
- lib/ui/view-state.svelte: shared loading/empty/error placeholder with
  the right live-region role + optional action; entity tables
  (races/sciences/ship-classes) migrated, rest adopt incrementally.
- map/selection-ring.ts: accent ring around the selected planet, fed into
  the map buildExtras alongside the reach circles.
- lib/ui/sheet-dismiss.ts: tap-outside + drag-handle swipe-down dismissal
  for the planet/ship-group bottom-sheets (hand-rolled pointer events).

Tests: error, view-state, selection-ring, sheet-dismiss (761 total).
Docs: ui/docs/error-state-ux.md (+ index); F4 marked done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:29:11 +02:00
developer 87d524fb89 Merge pull request 'feat(ui): localisation completeness — persistence + parity guards (F3)' (#28) from feature/ui-finalize-f3-i18n into development
Deploy · Dev / deploy (push) Successful in 38s
Tests · UI / test (push) Successful in 2m10s
2026-05-22 06:57:23 +00:00
Ilia Denisov 1e62837c68 feat(ui): locale persistence + i18n completeness guards (F3)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m11s
An audit found the client already i18n-first: one hard-coded UI string
(the battle-scene aria-label, now keyed) and en/ru already share an
identical 692-key set.

- Persist the locale: i18n.setLocale writes localStorage (galaxy-locale)
  and the store boots from stored > browser detection > default, so a
  language switch survives reloads.
- tests/i18n-completeness.test.ts: en/ru key-set parity, non-empty
  values, and locale persistence.
- Docs: ui/docs/i18n.md; mark F3 done in ui/PLAN-finalize.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:48:13 +02:00
developer c56050f5dd Merge pull request 'feat(ui): accessibility pass — WCAG 2.2 AA (F2)' (#27) from feature/ui-finalize-f2-a11y into development
Deploy · Dev / deploy (push) Successful in 35s
Tests · UI / test (push) Successful in 2m38s
2026-05-22 06:43:40 +00:00
Ilia Denisov 70f2973396 fix(ui): darken light-theme danger to meet AA contrast
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 2m33s
With the default theme now following the OS, Playwright renders the light
theme, where the previous light `--color-danger` (#c84d4d, ~3.9:1 on a
near-white surface) failed WCAG 1.4.3 on error text — caught by the axe
scan of the science designer's empty-name error. Darken light
`--color-danger` to #c0392b (~5.5:1 on white; white-on-danger fills stay
≥5:1). Dark theme unchanged.
2026-05-22 08:40:38 +02:00
Ilia Denisov e193f3ca88 feat(ui): default theme to system (follow OS light/dark)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Failing after 2m7s
Light has been signed off, so the theme store's default choice is now
`system` (it was `dark` during the incremental migration). This matches
the app.html pre-paint guard, which already resolved an unset choice via
prefers-color-scheme — removing the brief boot-time mismatch where the
store re-pinned dark. Users still pin light/dark via the account-menu
picker. Updates the store default + its test and the design-system /
finalize-plan docs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:36:17 +02:00
Ilia Denisov 642c5b7322 feat(ui): accessibility pass — WCAG 2.2 AA for login/lobby/shell (F2)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m9s
Add the a11y foundation and bring login, lobby, and the in-game shell to
WCAG 2.2 AA:

- Primitives: .sr-only + .skip-link (base.css), trapFocus (modal focus
  trap + restore) and restoreFocus (menu focus restore) actions, the
  --color-focus visible ring.
- In-game shell: skip link + focusable main landmark; WAI-ARIA sidebar
  tabs (roving tabindex, arrow/Home/End, tabpanel wiring); menu Escape +
  focus restore (account / view / turn-navigator / map-toggles /
  bottom-tabs); mail compose as a role=dialog modal with a focus trap.
- login / lobby / lobby-create: skip link + main landmark, field labels,
  role=alert / role=status live regions.
- Map canvas: aria-label naming it a visual overview, with its data
  reachable by keyboard via the sidebar inspector and tables (accessible
  alternative; in-canvas keyboard nav deferred).

Gates (chromium-desktop): tests/e2e/a11y-axe.spec.ts scans every
top-level view for WCAG 2.2 AA violations (zero); a11y-keyboard.spec.ts
covers the skip link, menu Escape+restore, and tab roving. Adds
@axe-core/playwright. Docs: ui/docs/a11y.md (+ index). Marks F1 and F2
done in ui/PLAN-finalize.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:25:14 +02:00
developer dcc655c7c4 Merge pull request 'feat(ui): visual design system — tokens + light/dark theming (F1)' (#26) from feature/ui-finalize-f1-tokens into development
Deploy · Dev / deploy (push) Successful in 37s
Tests · UI / test (push) Successful in 2m16s
2026-05-22 05:37:11 +00:00
Ilia Denisov 4ad96b0ef7 feat(ui): migrate all view bodies to design tokens (F1b)
Tests · UI / test (push) Successful in 2m11s
Tests · UI / test (pull_request) Successful in 2m7s
Tokenize every remaining component <style> — calculator, order tab,
inspectors, tables, report sections, lobby, auth, mail, battle viewer,
toasts, map overlays. A scripted pass handled the unambiguous core
palette (text/bg/surface/border/accent/danger/muted), the rest were
mapped to the semantic/grey tokens by role.

Remaining colour literals are the documented exceptions only: the
battle-scene SVG data-visualisation palette (fixed dark, like the WebGL
map canvas), overlay scrims (modal / map-canvas), and directional or
deliberate drop shadows. The default theme stays dark until light
coherence is signed off across the views.

Updates ui/docs/design-system.md (migration status + exceptions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:24:02 +02:00
Ilia Denisov 973480d812 feat(ui): design tokens + light/dark theming, migrate in-game chrome (F1a)
Tests · UI / test (push) Successful in 2m4s
Introduce the shared design-token system under
ui/frontend/src/lib/theme/: tokens.css (dark default + light palette,
plus spacing/radii/typography scales), base.css global baseline
(document background, text, token focus ring, selection), and
theme.svelte.ts (system/light/dark choice, persisted to localStorage,
applied via data-theme on <html>). A pre-paint guard in app.html
resolves the theme before the app boots to avoid a flash, and the theme
picker is wired into the previously-disabled account-menu stub.

Migrate the always-visible in-game chrome to the tokens (header, account
menu, sidebar, tab-bar, bottom-tabs, shell background): dark renders as
before, light comes for free. The default stays dark during the
incremental migration; the remaining view bodies migrate in F1b.

Docs: ui/docs/design-system.md (+ index entry). Test: tests/theme.test.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:02:13 +02:00
developer 44ed0a90eb Merge pull request 'docs(ui): finalize MVP plan structure + de-archaeologize topic docs' (#25) from feature/ui-finalization-plan into development 2026-05-21 21:25:06 +00:00
Ilia Denisov a89048f6c5 docs(ui): finalize MVP plan structure and de-archaeologize topic docs
MVP web client (Phases 1-30) is complete; reorganize planning + living docs around that.

- PLAN.md kept as the staged MVP record (1-30) with a status block + pointers; removed the 31-36 stages, regression scenarios, and deferred-TODO section (moved out); fixed a stale cross-machine plan path.

- ui/PLAN-finalize.md (new): active web-finalization plan in 8 stages (visual system, a11y, i18n, error UX, PWA, build hygiene, docs, owner manual-QA loop); absorbs former Phases 33 and 35.

- ui/ROADMAP.md (new): post-MVP (Wails, Capacitor, realistic projection, acceptance + regression scenarios) and triaged deferred follow-ups.

- ui/docs/README.md (new): grouped topic-doc index.

- De-archaeologized all 20 ui/docs topic docs + ui/README.md + ui/core/README.md: stripped Phase-N build history, rewritten as current-state; deferred work now points at ROADMAP.md / PLAN-finalize.md. Docs-only; no code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:17:51 +02:00
developer 51865b8cf4 Merge pull request 'Phase 30 — Ship Class Calculator (goal-seek, reach circles, planet build)' (#24) from feature/ui-calculator into development
Deploy · Dev / deploy (push) Successful in 38s
Tests · Go / test (push) Successful in 2m6s
Tests · Integration / integration (push) Successful in 1m43s
Tests · UI / test (push) Successful in 2m1s
2026-05-21 19:47:16 +00:00
Ilia Denisov 4d3cfd11a3 docs(ui): mark Phase 30 (ship-class calculator) done in PLAN.md
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m10s
Tests · UI / test (pull_request) Successful in 1m59s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:40:45 +02:00
Ilia Denisov b1b87c8521 feat(ui-calculator): input validation, load caps, ceil display, modernization layout
Tests · Go / test (push) Successful in 2m26s
Tests · UI / test (push) Successful in 2m26s
- custom load capped at cargo capacity (error when exceeded); full load shows the cargo capacity; zero cargo pins load to empty and disables the toggle

- per-input red border + tooltip for every invalid value (blocks, techs, load, MAT, modernization target); no value may be negative; locking a speed is disabled when drive is zero

- display every computed number (results + goal-seek back-solved input) rounded up to 3 decimals via a shared pkg/calc Ceil3 bridged to wasm; engine keeps its own round-to-nearest util.Fixed*

- modernization total upgrade cost spans two columns (single line)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:24:40 +02:00
Ilia Denisov 3ea29cf8b5 fix(ui-calculator): keep calculator state long-lived; don't eject on planet click
Tests · UI / test (push) Successful in 1m59s
Move the calculator's inputs into a page-level calculatorState singleton so they survive the sidebar unmounting the tab on a tab switch (the inspector auto-opens on a planet click). ensureGame resets the design when the active game changes.

While on the calculator, a planet click no longer switches to the inspector — the calculator consumes the selection in its planet area / reach circles. Halve the reach-circle stroke width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:29:08 +02:00
Ilia Denisov 9ae7b88b89 feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
Tests · UI / test (push) Successful in 2m14s
Tests · Go / test (push) Successful in 2m25s
Fuse the standalone ship-class designer (Phases 17/18) into a sidebar calculator: live mass/speed/attack/defence/bombing results, a planet build-rate readout, single-target goal-seek, a modernization-cost mode, and auto reach circles on the map for the selected planet.

pkg/calc becomes the single source for the new math (no mirroring): extract BombingPower from the engine model and the per-turn ship-production loop from controller.ProduceShip into pkg/calc (engine now delegates), and add inverse goal-seek solvers in pkg/calc/solve.go. Thin-bridge the combat, planet-build, and solver functions through ui/core/calc + ui/wasm and rebuild core.wasm.

Remove the standalone designer view/route; the ship-classes table and the view/bottom menus open the calculator via a shared request store.

Docs: rewrite ui/PLAN.md Phase 30, adjust Phase 34 (realistic forecast + CAP/COL ownership), add ui/docs/calculator-ux.md, extend calc-bridge.md, fix navigation.md; remove ui/CALCULATOR.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:04:07 +02:00
developer 00159ddf7c Merge pull request 'ci: per-job pnpm install dir to fix the host-runner setup-pnpm race' (#23) from feature/ci-pnpm-setup-isolation into development
Deploy · Dev / deploy (push) Successful in 40s
Tests · UI / test (push) Successful in 2m4s
2026-05-20 15:31:12 +00:00
Ilia Denisov b24d53b82f ci: install pnpm into a per-job dir to fix the host-runner setup race
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 1m56s
`pnpm/action-setup@v4` defaults to installing pnpm in the shared
`~/setup-pnpm`. On the single host-mode runner $HOME is shared across
concurrent jobs, so when two pnpm jobs overlap (e.g. a post-merge
`dev-deploy` and `ui-test`, which sit in different concurrency groups)
their self-installers race and one fails with
`ENOTEMPTY ... rmdir '~/setup-pnpm/node_modules/.bin/store/v11/files'`
before the tests even run.

Point each step's `dest` at `${{ runner.temp }}/setup-pnpm` (a per-job
isolated directory) so concurrent jobs never share the install location.
The action still adds `dest` to PATH, so setup-node's pnpm cache and
later `pnpm` calls are unaffected; the pnpm package store stays shared
(safe — pnpm locks it). Applied to the three workflows that set up pnpm:
ui-test, dev-deploy, prod-build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:26:49 +02:00
developer 6572f5b59d Merge pull request 'fix(ui-map): inverse stencil mask for the visibility fog (Safari pan perf, stage 2)' (#22) from feature/ui-map-fog-inverse-mask into development
Deploy · Dev / deploy (push) Successful in 31s
Tests · UI / test (push) Successful in 1m58s
2026-05-20 15:02:03 +00:00
Ilia Denisov a08f4f55b0 fix(ui-map): cut the visibility fog with an inverse stencil mask (Safari pan perf, stage 2)
Tests · UI / test (push) Successful in 1m57s
Tests · UI / test (pull_request) Successful in 1m56s
Stage 1 (render-on-demand) removed the idle / whole-system freeze, but
panning a loaded map with "visible hyperspace" on stayed heavy in Safari:
the fog still cut its visibility holes by opaque overpaint — on KNNTS041
that is ~260 near-world-sized opaque circles blended over the fog every
rendered frame, a fill-rate cliff for Safari's WebGPU / Apple's tile-based
GPU.

Replace the overpaint with an INVERSE stencil mask: setVisibilityFog now
draws the FOG_COLOR rectangle(s) into fogLayer and collects the visibility
circles into one Graphics set as fogLayer.setMask({ mask, inverse: true }),
so the fog shows everywhere except the union of the circles. Per-frame cost
drops from dozens of blended opaque circle fills to one rect fill + a
stencil pass (no colour writes), which Apple's TBDR GPU handles cheaply,
and the fog stays fully vector — crisp at any zoom.

fogPaintOps and its unit tests are unchanged (the circle ops now feed the
mask instead of an overpaint). Verified with a high-contrast screenshot
during development (fog field with a correct circle-union hole) plus the
existing fog / render-on-demand e2e green on chromium + webkit.

Docs: renderer.md fog section + PLAN.md Phase 29 decision 9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:53:54 +02:00
developer 44c18c3ef4 Merge pull request 'fix(ui-map): render-on-demand + drop pan inertia (Safari fog freeze, stage 1)' (#21) from feature/ui-map-render-on-demand into development
Deploy · Dev / deploy (push) Successful in 30s
Tests · UI / test (push) Successful in 1m57s
2026-05-20 14:43:57 +00:00
Ilia Denisov 51902b995f fix(ui-map): render-on-demand + drop pan inertia to stop the Safari fog freeze
Tests · UI / test (push) Successful in 1m55s
Tests · UI / test (pull_request) Successful in 2m4s
The Phase 29 visibility fog ("visible hyperspace") froze the whole UI on
large reports in Safari while staying smooth in Firefox. Root cause: the
fog is a layered overpaint (torus mode = 9 world-sized rects + 9xN
near-world-sized opaque circles, ~270 fills for KNNTS041) and Pixi's
continuous auto-render loop re-rasterised all of it every frame, even
while idle. Safari's WebGPU backend cannot sustain that fillrate, so the
main thread/compositor starved and the entire UI froze.

Stage 1 (vector-preserving, no rasterisation):

- Stop Pixi's auto-render loop (app.stop()) and paint on demand via a
  single Ticker.shared flush gated on viewport.dirty (camera) plus an
  internal requestRender() from every content mutation (fog / hide-set /
  extras / wrap mode / resize / pick overlay). An idle map now does zero
  GPU work per frame; plain hover paints nothing.
- Remove the decelerate (drag-inertia) plugin: a released drag stops
  instantly (owner request) and the viewport goes idle immediately.
- Expose RendererHandle.getRenderCount() / getMapRenderCount for
  deterministic e2e assertions.

Tests: new map-toggles e2e specs (idle map does not repaint; released
drag does not coast) green on all four Playwright projects incl. WebKit.
Docs: renderer.md (render-on-demand section; fog section corrected to the
current single-fogLayer model; FPS note) and PLAN.md Phase 29 decision 8.

If Safari pan is still heavy after this, stage 2 will cut the overpaint
itself with an inverse stencil mask of the circle union (kept vector).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:28:18 +02:00
developer 0da2f4b6fb Merge pull request #20 from feature/ui-map-toggles
Deploy · Dev / deploy (push) Successful in 30s
Tests · Integration / integration (push) Successful in 1m43s
Tests · Go / test (push) Successful in 2m5s
Tests · UI / test (push) Successful in 2m56s
Phase 29 — Map Toggles

Reviewed and verified visually on dev-deploy.
2026-05-19 22:37:29 +00:00
Ilia Denisov 53b892ae00 fix(ui-map): move fog overlay to a viewport-level layer below the copies
Tests · UI / test (push) Successful in 2m50s
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m5s
Two regressions surfaced once visible-hyperspace toggled on a real
dev-deploy map:

1. On the zero-turn map the bg holes painted ON TOP of the planet
   glyphs — every LOCAL planet looked like a hollow circle of
   background colour instead of the planet pixel inside an
   unfogged area.
2. On a legacy report with a drive tech that pushes the visibility
   radius well past the world dimensions the bg circles overlapped
   to cover the entire viewport. Combined with the wrong z-order
   the result was a uniformly black canvas with every primitive
   hidden.

The per-copy implementation added the fog container via
`copy.addChildAt(container, 0)` and trusted Pixi v8 to insert the
container at the start of the copy's children. Whether by a Pixi
quirk or by some interaction with how `populatePrimitives` orders
its `c.addChild(g)` calls, the fog ended up rendering after every
primitive in practice — the symptoms above are a perfect match for
that ordering.

Restructured the fog rendering so the z-order is structural
rather than relying on `addChildAt`:

- A single `fogLayer: Container` is added to the viewport BEFORE
  the nine torus copies. Pixi renders viewport children in order,
  so the layer is guaranteed to paint first; every copy renders
  on top.
- `fogPaintOps` now emits world-space coordinates with wrap
  offsets baked in (9 fog rects + 9 bg circles per visibility
  entry in torus mode, 1 + N in no-wrap mode). The renderer
  populates `fogLayer` with one `Graphics` per op — no per-copy
  iteration on the fog side.
- The previous `fogGraphics: Container[]` closure state is gone.
  Each `setVisibilityFog` flip drops every child of `fogLayer`
  and rebuilds it. The dispose path drops the children
  eagerly before `app.destroy({children: true})` walks the tree.

The fog-paint-ops test exercises the new contract: the no-wrap
path keeps one rect + N circles, the torus path expands to nine
rects + nine wrapped circles per entry (including the seam-fix
case at x = 950).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:26:06 +02:00
Ilia Denisov 00e84579ca fix(ui-map): split fog overlay into per-shape Graphics + torus-wrap circles
Tests · UI / test (push) Successful in 3m23s
Two visible regressions in the in-game map's fog overlay surfaced
on dev-deploy:

1. With three LOCAL planets close together, only the last planet
   glyph stayed visible inside the bg holes — the other two were
   obscured. The previous implementation stacked the fog rectangle
   plus every bg circle onto a single `Graphics` via repeated
   `g.rect(...).fill(...).circle(...).fill(...)...`. Pixi v8's
   multi-shape Graphics is supported in theory, but in practice
   only the last shape's fill seems to land, dropping the earlier
   bg holes (and the planet glyphs on top look like they vanished
   along with their hole). Splitting each op onto its own
   `Graphics` inside a per-copy `Container` removes the ambiguity
   — one shape, one fill, one render pass.

2. A planet near the right world edge produced a "sector" — the
   bg circle painted into the area past the seam, but the
   neighbouring tile's fog rectangle then overpainted that bleed,
   leaving a quarter-circle hole. In torus mode each visibility
   circle is now drawn at the nine wrapped positions
   (`(dx, dy) ∈ {-1, 0, 1}²`); the wrapped copies in the
   neighbour-tile-aligned positions keep the hole continuous
   across the seam. No-wrap mode keeps a single emission per
   circle, because wrapped circles would leak into the visible
   world rectangle as unwanted holes.

The `fogPaintOps` helper now takes the wrap mode as a parameter;
`tests/fog-paint-ops.test.ts` covers the torus expansion
(nine-wrap product per circle, the seam-fix case at x = 950) and
re-asserts the no-wrap path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:04:35 +02:00
Ilia Denisov 7ade838df8 test(ui-map): unit-cover the fog overlay's layered-overpaint contract
Tests · UI / test (push) Successful in 2m49s
Lifted the Phase 29 fog draw sequence out of `setVisibilityFog`
into a pure `fogPaintOps` helper that returns an ordered list of
fill operations (one fog rect, then one background-coloured
circle per visibility entry). The renderer now dispatches each op
straight onto a Pixi `Graphics`; the indirection lets the layered-
overpaint contract be tested without booting Pixi.

`tests/fog-paint-ops.test.ts` covers: empty input → no ops; single
circle → fog rect + bg circle in that order; multiple circles → N
bg circles after the fog rect; overlapping circles emitted
independently (the rendering order unions them); zero / negative
world dimensions → no ops.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:42:39 +02:00
Ilia Denisov 37580b7699 fix(ui-map): repaint fog as layered overpaint; rename to visibleHyperspace
Tests · UI / test (push) Waiting to run
The Phase 29 fog overlay rendered as a handful of random arc
segments instead of a clean union of holes around LOCAL planets
— Pixi v8's `Graphics.cut()` does not reliably subtract multiple
overlapping circles from a base path.

Replaced the cut-based approach with a layered overpaint: a
fog-tinted rectangle fills the world, then opaque background-
coloured circles are painted on top for every visibility circle.
The natural rendering order unions overlapping circles for free —
no geometry, no `cut()` quirks, one extra fill per circle.

Renamed the toggle from `visibilityFog` to `visibleHyperspace`
across the store, i18n strings, popover, tests, and docs. The
overlay still implements the visual "fog" effect at the renderer
level (FOG_COLOR, setVisibilityFog, getMapFog); the toggle is
named after the player-facing concept it controls — the portion
of the map that is visible (intelligence/scan coverage) — rather
than the obscured part.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:39:39 +02:00
Ilia Denisov 2f4dc01d54 fix(ui-map): apply wrap-mode flips in place instead of remounting
Tests · UI / test (push) Successful in 2m51s
The previous logic re-mounted the renderer whenever
`store.wrapMode` flipped, because the `sameSnapshot` gate
included `handle.getMode() === mode`. Pixi 8 does not reliably
re-initialise an `Application` on the same canvas — the symptom
showed up as the chromium tab silently closing during the
Phase 29 wrap-mode e2e ("Target page, context or browser has
been closed").

The renderer already exposes an in-place `setMode` that swaps
the wrap-clamp / torus-copy visibility synchronously while
preserving the camera; the playground-map.spec.ts wrap toggle
has been driving it for several phases without issue. Drop
mode from the snapshot gate and route the change through
`handle.setMode(mode)` instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:33:38 +02:00
Ilia Denisov 7c46aa4bec fix(ui-e2e): tighten Phase 29 effect tracking + radio wiring
Tests · UI / test (push) Failing after 7m19s
Run #217 surfaced three independent bugs that survived the first
fixup pass:

1. `visibleHighBitCount` masked the id with `(prim.id >>> 0) & 0xf…`,
   but JS bitwise AND always returns a signed int32 — the mask had
   to be re-converted with `>>> 0` AFTER the AND, not before. Result
   was always 0 on the previous run, masking the next two bugs by
   making the persistence test's high-bit-count assertions a
   tautology.
2. `applyVisibilityState` was wrapped in `untrack`, so the
   `toggles.X` reads inside `computeHiddenIds` / `computeFogCircles`
   never landed in the effect's dependency set — toggling fog or any
   marker / group / kind flag did not re-run the effect, so the
   renderer never received the new hide / fog input. Explicit
   `void toggles.X` reads now live at the top of the effect so every
   key is tracked synchronously.
3. The wrap-mode radios fired on `onchange`, which Svelte 5
   suppresses on a re-activation of an already-checked input — the
   Playwright `.click()` flake on the second wrap test reflected the
   missed event. Switched to `onclick` and short-circuited when the
   target mode is already active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:23:15 +02:00
Ilia Denisov 2528d63b51 fix(ui-e2e): Phase 29 map-toggles spec passes across all four projects
Tests · UI / test (push) Failing after 10m52s
Three independent bugs in `tests/e2e/map-toggles.spec.ts` made the
fresh-Phase-29 suite red on CI #216:

1. `visiblePlanets` filtered on `p.id < 1_000_000`, which JS interprets
   in signed space — high-bit-prefix primitives (cargo route 0x80…,
   battle 0xa0…, bombing 0xc0…) are stored as negative Numbers and
   leaked into the planet list. Filter switched to a `0 < id < 1e7`
   window that matches the engine planet-number range exactly.
2. The `visibleHighBitCount` helper now ToUint32-converts the id
   before masking so the bitmask comparison works regardless of
   whether the id is stored as positive or negative.
3. The fog and wrap-mode tests read the renderer state synchronously
   after the click — the Svelte effect re-runs asynchronously, so the
   tests saw stale state. Both now `waitForFunction` on the canonical
   "settled" signal: empty fog circles for the fog flip, and a new
   `getMapMode()` debug accessor for the wrap-mode remount.

Renderer side: registers a `MapModeProvider` next to the existing
camera / fog providers and exposes `getMapMode()` through the debug
surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:02:15 +02:00
Ilia Denisov 2bd1b54936 feat(ui): Phase 29 map visibility toggles
Tests · Go / test (push) Successful in 2m31s
Tests · UI / test (push) Failing after 8m7s
Adds the gear-icon popover on the map view with per-game persistence
of every category toggle plus the wrap-mode radio. Hide-by-id and
visibility-fog facilities land on the renderer so every flip applies
within one frame without a Pixi remount; the wrap-mode toggle keeps
its existing remount + camera-preserve path. A new server-side turn
force-resets every flag to defaults so a hidden category never makes
the player miss the next turn's news.

Also fixes the FligthDistance → FlightDistance typo in pkg/calc/race.go
(plus the single Go caller); the TS side keeps duplicating the formula
until a race-level WASM bridge lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:33:53 +02:00
developer 65c0fbb87d Merge pull request 'fix(generator): drop incorrect distinctness assert in TestPlanetRandomName' (#19) from feature/fix-flaky-planet-random-name into development
Deploy · Dev / deploy (push) Successful in 31s
Tests · Integration / integration (push) Successful in 1m35s
Tests · Go / test (push) Successful in 2m58s
2026-05-19 08:15:45 +00:00
Ilia Denisov 3d06f49f3c fix(generator): drop incorrect distinctness assert in TestPlanetRandomName
Tests · Go / test (push) Successful in 1m54s
Tests · Integration / integration (pull_request) Successful in 1m42s
Tests · Go / test (pull_request) Successful in 2m2s
`RandomName` builds the suffix as two independent `rand.Intn(1000)`
calls, so the two 4-digit halves collide on ~0.1% of runs. The
sub-test asserted `g[2] != g[3]`, which flakes whenever the same
value lands twice — once per ~1000 sub-runs per class, so across
the seven `PlanetClass` rows the integration suite hit it on
`#199 go-unit.yaml` against `feature/subscribe-events-heartbeat`
(`"0074"` collision).

Distinctness is not a property `RandomName` promises and is not
load-bearing for callers: `game/internal/controller/generate_game.go`
uses these names for planet labels and already tolerates duplicate
names across planets, so collisions inside one name are no worse
than collisions between names. Drop the assert; keep the format and
class-prefix checks, which are the actual contract.

Stress-tested with `-count=200`: 200 consecutive iterations × 7
classes = 1400 sub-runs without a single failure where the prior
version's flake probability would have surfaced ~once on average.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:13:25 +02:00
developer 08345606a5 Merge pull request 'fix(dev-deploy): explicit Cache-Control on the UI surface' (#18) from feature/caddy-cache-headers into development
Deploy · Dev / deploy (push) Successful in 27s
2026-05-19 08:11:40 +00:00
Ilia Denisov b85a9e1b9b fix(dev-deploy): explicit Cache-Control on the UI surface
Caddy's `file_server` did not set Cache-Control on the SvelteKit
build, so browsers fell back to heuristic caching keyed off
Last-Modified. On the long-lived dev environment the heuristic
window leaves the previous deploy's `index.html` cached for
minutes-to-hours, and Safari combined that with stale conditional
requests into a visible multi-second freeze on every reload (the
reproduction was "private window reloads instantly, normal window
hangs; clearing Safari caches restores normal speed"). Push
delivery itself works — heartbeat keeps the SubscribeEvents stream
alive — but the bundle path stalls behind the browser revalidating
a chain of stale chunks.

Mirror the standard SvelteKit cache split inside both Caddyfiles:

- `_app/immutable/*` — hash-named JS/CSS chunks Vite emits with
  content-addressed file names — `Cache-Control:
  public, max-age=31536000, immutable`. Safe to cache forever
  because the name changes whenever the content does, so the next
  deploy serves new files under new URLs.
- Everything else (`index.html` fallback via `try_files`,
  `env.js`, `version.json`, `core.wasm`, `wasm_exec.js`,
  `favicon.svg`) — `Cache-Control: no-cache, must-revalidate`.
  The browser still uses the cached body when the ETag matches,
  but it always asks first; a fresh deploy reaches the user on
  the next reload without a manual cache clear.

Smoke-tested locally: a docker-run Caddy with this config returns
the immutable header only for `/_app/immutable/*` and the
no-cache header for `/index.html`, `/env.js`, and the SPA-fallback
path `/some/route`. The Caddyfile passes `caddy validate` in
both `Caddyfile.dev` and `Caddyfile.prod`; the pre-existing
formatting warning on line 7 is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:11:09 +02:00
developer 8170abd5fa Merge pull request 'feat(gateway): unsigned gateway.heartbeat keeps Safari push streams alive' (#17) from feature/subscribe-events-heartbeat into development
Deploy · Dev / deploy (push) Successful in 33s
Tests · Integration / integration (push) Successful in 1m36s
Tests · Go / test (push) Successful in 3m18s
Tests · UI / test (push) Successful in 2m28s
2026-05-19 07:35:35 +00:00
Ilia Denisov 14b65389ef feat(gateway): unsigned gateway.heartbeat keeps Safari push streams alive
Tests · UI / test (push) Successful in 2m35s
Tests · Go / test (push) Successful in 1m56s
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m42s
Tests · Go / test (pull_request) Successful in 2m0s
Browser fetch-streaming layers close response bodies they consider
idle after roughly 15-30 s without incoming bytes. Safari is the
most aggressive, but the symptom matters everywhere: a quiet
SubscribeEvents stream (lobby, between turns, mailbox empty) gets
torn down by the browser, the EventStream singleton reconnects with
backoff, and any push event that fires inside the reconnect window
is lost because `push.Hub` queues are not persisted across
subscription closes. The user-visible failure mode is the
intermittent "Fetch API cannot load … due to access control checks"
console error (a misleading WebKit symptom — CORS headers are
actually present) plus missed turn-ready / mail-received toasts.

Server-side fix: a silence-based heartbeat at the
`authenticatedPushStreamService` wrapper layer. After the signed
`gateway.server_time` bootstrap event, gateway wraps the bound
stream with `heartbeatingStream`. Every tail Send (fan-out, future
variants) resets the silence timer; when the timer elapses, a
goroutine emits `gateway.heartbeat` with only `EventType` set —
everything else stays at proto3 defaults, so the wire frame is
~45 bytes amortised. A `sendMu` serialises the heartbeat goroutine
with tail Sends because grpc.ServerStream.Send is not goroutine-safe.

The heartbeat is intentionally UNSIGNED: heartbeats carry no
payload, dispatch to no handler on the client, and an injected
heartbeat trivially causes no user-visible state change. TLS still
protects the wire and real events keep the signed envelope
unchanged. Documented in `docs/ARCHITECTURE.md` § 15 alongside the
per-scale bandwidth projection (100…100 000 clients × 15…60 s).

Config: new `GATEWAY_PUSH_HEARTBEAT_INTERVAL` (default `15s`,
`0s` disables). Telemetry: new
`gateway.push.heartbeats_sent{outcome}` counter so operators can
budget bandwidth and spot a sudden `outcome=error` bump as an
upstream-failing-before-flush signal.

Client (`ui/frontend/src/api/events.svelte.ts`): early `continue`
on `event.eventType === "gateway.heartbeat"` before `verifyEvent`,
`verifyPayloadHash`, or dispatch — empty signature would otherwise
trip SignatureError and reconnect. A leading heartbeat still flips
`connectionStatus` to `connected` and resets backoff, because
receiving one is proof the stream is healthy.

Tests:
- `push_heartbeat_test.go`: unit tests for the wrapper — zero
  interval returns nil, heartbeat fires after silence, real Send
  resets the timer, Stop / context-cancel halt the goroutine,
  Send errors propagate.
- `server_test.go`: integration tests through the full gateway
  pipeline — heartbeat fires after the configured silence window,
  zero interval keeps the stream silent.
- `config_test.go`: default applied, env-override parsed,
  negative value rejected.
- `events.test.ts`: heartbeat skipped before verification + not
  dispatched to handlers; leading heartbeat still flips
  `connectionStatus` to `connected`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:29:29 +02:00
developer 8f84075c4b Merge pull request 'fix(battle-viewer): unblock synthetic-game battle load' (#16) from feature/synthetic-battle-loading-fix into development
Deploy · Dev / deploy (push) Successful in 34s
Tests · UI / test (push) Successful in 2m20s
2026-05-19 05:58:02 +00:00
Ilia Denisov bde01b1ce2 fix(battle-viewer): unblock synthetic-game battle load
Tests · UI / test (push) Successful in 2m18s
Tests · UI / test (pull_request) Waiting to run
The Phase 28 ConnectRPC migration of the battle viewer added a
guard in `lib/active-view/battle.svelte` that waits for the
surrounding layout to publish a `GalaxyClient` before issuing the
fetch. The in-game shell layout deliberately skips
`galaxyClient.set(...)` on the synthetic branch (gateway is not
reachable in synthetic mode), so for any battle opened from a
synthetic-report game the viewer sat on "loading battle…"
forever — `fetchBattle` was never called, so the synthetic-fixture
short-circuit it carries was unreachable.

Let the guard skip synthetic ids: `fetchBattle` already resolves
those through `lookupSyntheticBattle` and never touches the
client, so its signature widens to `GalaxyClient | null` and the
synthetic path passes `null`. The live path still waits for the
handle as before; a `null` client on the live path now fails
fast with a transport-level `BattleFetchError` instead of silently
sitting on `loading`.

Tests:
- Existing "loading placeholder" smoke now uses a non-synthetic
  game id so it keeps asserting the live-path wait.
- Two new cases pin the synthetic behaviour: missing fixture →
  `battle-not-found`; registered fixture → `BattleViewer` mounts.

Docs:
- `docs/FUNCTIONAL.md` §6.5 still described the pre-Phase-28
  raw REST path. Updated to the signed ConnectRPC command and
  noted the synthetic short-circuit. Russian mirror updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 07:52:26 +02:00
developer 82bdb6777a Merge pull request 'fix(dev-deploy): seed geoip onto a named volume' (#15) from feature/dev-deploy-geoip-volume into development
Deploy · Dev / deploy (push) Successful in 28s
2026-05-19 00:02:12 +00:00
Ilia Denisov f70258849f fix(dev-deploy): seed geoip onto a named volume
`docker restart galaxy-dev-backend` failed with "not a directory"
after every dev-deploy workflow run. Root cause: the compose file
bind-mounted the geoip database via a relative path
(`../../pkg/geoip/test-data/test-data/GeoIP2-Country-Test.mmdb`).
When the Gitea runner invoked `docker compose up`, the path
resolved against the runner's ephemeral workspace under
`/home/runner/.cache/act/<hash>/hostexecutor/...`. The bind source
baked into the running container therefore pointed at that
ephemeral path; the runner deleted the workspace once the workflow
finished, and any later `docker restart` could not remount.

Replace the bind with a named volume `galaxy-dev-geoip-data`,
seeded at deploy time:

- `tools/dev-deploy/docker-compose.yml`: mount
  `galaxy-dev-geoip-data:/var/lib/galaxy:ro` instead of a relative
  bind. Declare the volume in the top-level `volumes:` block.

- `.gitea/workflows/dev-deploy.yaml`: new `Seed geoip volume` step
  (placed right after the existing UI-volume seed) copies the
  fixture from `pkg/geoip/test-data/test-data/` into the named
  volume via an ephemeral alpine container, the same pattern UI
  seeding already uses.

- `tools/dev-deploy/Makefile`: new `seed-geoip` target performs
  the same copy from the persistent checkout. `up` and `rebuild`
  now depend on it, so a hand-run `make -C tools/dev-deploy up`
  populates the volume without operator action.

- `tools/dev-deploy/README.md`: updated the make-targets table to
  list `seed-geoip`.

- `tools/dev-deploy/KNOWN-ISSUES.md`: the entry for the restart
  failure is downgraded to a "fixed" postmortem; the symptom,
  cause, and where the fix lives are kept for future reference.

Verification on the dev host (this branch checked out):

  $ make -C tools/dev-deploy up                # populates the volume, brings stack healthy
  $ docker restart galaxy-dev-backend          # used to error "not a directory"
  $ until [ "$(docker inspect -f '{{.State.Health.Status}}' galaxy-dev-backend)" = "healthy" ]; do sleep 2; done
  $ echo "ok"                                   # backend up 6s, healthy

The pre-existing sandbox engine `galaxy-game-80f3ce86-...` survived
both `make up` and `docker restart` untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:59:38 +02:00
developer d19aa3aac5 Merge pull request 'fix(integration): scope preclean to galaxy.stack=integration' (#14) from feature/preclean-stack-scope into development
Tests · Integration / integration (push) Successful in 1m36s
2026-05-18 23:48:47 +00:00
Ilia Denisov a338ebf058 fix(integration): scope preclean to galaxy.stack=integration
Tests · Integration / integration (pull_request) Successful in 1m37s
Root cause for the long-standing "Dev Sandbox flips to cancelled
after dev-deploy" symptom in push-triggered cycles: when
`integration.yaml` runs in parallel with `dev-deploy.yaml`, its
`integration/scripts/preclean.sh` issues a `docker rm -f` over every
container labelled `galaxy.backend=1`. That label is stamped by the
backend's runtime adapter on every engine it spawns — including the
engines living in the long-lived dev-deploy environment on the same
Docker daemon. Each post-merge auto-deploy therefore had the
integration preclean wipe the dev-sandbox engine, and the new
backend's reconciler tick observed `container disappeared` and
cascaded the sandbox into `cancelled`.

Fix:

- `integration/testenv/backend.go` now sets
  `BACKEND_STACK_LABEL=integration` on every backend-under-test, so
  the engines spawned by integration carry
  `galaxy.stack=integration` in addition to `galaxy.backend=1`. The
  backend support for this env was added in the previous CI tidy-up
  PR (#13).

- `integration/scripts/preclean.sh` gains a multi-label AND filter
  helper and uses it to scope engine cleanup to the combination
  `galaxy.backend=1 AND galaxy.stack=integration`. dev-deploy and
  local-dev engines carry different `galaxy.stack` values, so the
  AND match leaves them alone.

- `docs/ARCHITECTURE.md` "Container labels" — refreshed to call out
  the AND-scoping rule and the new integration backend stamp.

- `tools/dev-deploy/KNOWN-ISSUES.md` — the sandbox-cancel entry
  gets an "Update" section recording the root cause and the fix; the
  status is downgraded to "partially fixed" because the solo
  `workflow_dispatch` reproduction (which does NOT trigger
  integration) remains unexplained.

- `tools/dev-deploy/KNOWN-ISSUES.md` — separately, document the
  `docker restart galaxy-dev-backend` failure caused by the
  runner-workspace bind-mount that surfaced while diagnosing this
  issue. Workaround: `make -C tools/dev-deploy up` from the
  persistent checkout. Real fix is a follow-up (bake fixture into
  image or copy to named volume).

Verification:

- `go build ./backend/... ./integration/...` — clean.
- `bash -n integration/scripts/preclean.sh` — syntax OK.
- Live AND-filter check on the dev host:
  `docker ps -aq --filter label=galaxy.backend=1 --filter label=galaxy.stack=integration`
  returns nothing while the dev-deploy engine
  `galaxy-game-80f3ce86-...` keeps running.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:37:55 +02:00
developer f91cf6eb41 Merge pull request 'chore(ci): tidy CI/dev infra — drop local-ci, lift migration rule' (#13) from feature/ci-tidy-up into development
Deploy · Dev / deploy (push) Successful in 28s
Tests · Go / test (push) Successful in 2m0s
Tests · Integration / integration (push) Successful in 1m40s
2026-05-18 23:10:21 +00:00
Ilia Denisov daed2690c1 fix(compose): keep galaxy.stack label on containers only
Tests · Integration / integration (pull_request) Successful in 1m41s
Tests · Go / test (pull_request) Successful in 2m0s
The previous commit stamped `galaxy.stack=<value>` on services,
volumes, and networks. Putting it on volumes/networks changes their
compose config-hash on every label revision, so `docker compose up`
tries to recreate them — which on the long-lived dev environment
either destroys the postgres data volume or deadlocks while trying
to remove `galaxy-dev-internal` with containers still bound to it.
Observed live: run #184 hung in compose recreate after the three
stateful services were stopped, with no recovery.

Containers alone are sufficient for the cleanup contract (we filter
containers, not volumes or networks). Roll back the label on volumes
and networks in both compose files and capture the rule in
docs/ARCHITECTURE.md so the next contributor does not reintroduce it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:00:21 +02:00
Ilia Denisov a9087691a3 chore(ci): tidy CI/dev infra — drop local-ci, lift migration rule, scope by galaxy.stack label
Tests · Go / test (push) Successful in 2m6s
Tests · Go / test (pull_request) Successful in 3m1s
Tests · Integration / integration (pull_request) Successful in 1m42s
Five connected cleanups across the dev/CI infrastructure:

1. Drop tools/local-ci/. The standalone Gitea + act_runner stack was
   the legacy "offline workflow validator"; the per-stage CI gate now
   runs on gitea.lan and the directory was only retained as a
   fallback. Removing it leaves no operational dependency: backend,
   gateway, and game code have no references; documentation that
   pointed at it (CLAUDE.md, docs/ARCHITECTURE.md, ui/docs/testing.md,
   tools/dev-deploy/README.md, tools/local-dev/README.md) is updated
   in this same change. Historical "Verified on local-ci run N"
   markers in ui/PLAN.md are preserved unchanged.

2. Lift the pre-production single-migration rule. The rule forced
   every schema delta into 00001_init.sql and required a manual
   make clean-data wipe on every backward-incompatible change in
   tools/dev-deploy/. Future schema deltas now land as additive
   sequence-numbered files (00002_*.sql, …) that goose applies
   automatically on backend startup; 00001_init.sql becomes an
   immutable baseline. Authoring conventions live in
   backend/internal/postgres/migrations/README.md. The chain may be
   squashed back into a fresh 00001 as a deliberate one-time
   operation before the first production deployment.

3. Document the deployment cadence. The dev environment is
   single-tenant: pushes to feature/* run the test workflows
   (go-unit, ui-test, integration) only; dev-deploy.yaml fires on
   push to development. A workflow_dispatch override on
   dev-deploy.yaml lets a developer preview a feature branch on the
   shared dev environment before merge; the next merge into
   development overwrites the manual deploy idempotently.

4. Scope compose-managed resources by an explicit
   galaxy.stack=<local-dev|dev-deploy> label. Both compose files
   stamp the label on every service, network, and named volume.
   Makefiles in tools/local-dev/ and tools/dev-deploy/ filter their
   engine-cleanup operations by (stack-label AND engine OCI title)
   so they never touch unrelated workloads on the same daemon.
   dev-deploy.yaml gains a pre-`compose up` step that reaps stale
   exited/dead containers under the dev-deploy stack label.

5. Backend now stamps the same galaxy.stack=<value> label on every
   engine container it spawns, sourced from a new BACKEND_STACK_LABEL
   env var (empty → label not applied; legacy-safe). Both compose
   files set it to their stack name (local-dev / dev-deploy). The
   contract is recorded in docs/ARCHITECTURE.md under
   "Container labels". A package-level test in
   backend/internal/runtime exercises both the label-present and
   label-absent paths.

No tests intentionally regressed: go test ./backend/internal/{config,
runtime,dockerclient} is green, both compose files validate cleanly,
and the backend, gateway, and game modules all build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:32:42 +02:00
developer 5eec7013ba Merge pull request 'chore(dev-deploy): KNOWN-ISSUES entry for sandbox-cancel after redispatch' (#12) from chore/dev-sandbox-cancel-todo into development 2026-05-16 21:17:37 +00:00
Ilia Denisov 49f614926a KNOWN-ISSUES: park sandbox-cancel; owner rejected host-side hypotheses
After the live investigation, the project owner confirms that none
of the host-side cleanup paths apply: no docker prune cron, no
manual `docker rm`, no `dockerd` restart in the window, and the
engine binary does not crash while idling on API calls.

Replace the host-side hypothesis list with a one-line note that
they were considered and rejected, narrow the open suspicion to
the `dev-deploy.yaml` job sequence (`docker build` + `docker
compose build` + the alpine `docker run --rm` for UI seeding +
`docker compose up -d --wait --remove-orphans`), and park the
entry. Reopen if the symptom recurs with a fresh
`docker events --since 0` capture armed before the deploy
starts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 23:16:51 +02:00
Ilia Denisov cadb72b412 KNOWN-ISSUES: rule out compose orphan reap; narrow to host-side reap
Tests · UI / test (push) Successful in 2m36s
Tests · Go / test (push) Successful in 2m38s
A live `docker inspect` of an engine container and two redispatch
runs with `docker events` captured confirm:

- Engine has no `com.docker.compose.*` labels and `AutoRemove=false`,
  so `--remove-orphans` cannot reap it.
- Two consecutive `dev-deploy.yaml` redispatches with an engine
  already running emitted `die` / `destroy` events only for
  `galaxy-dev-{backend,api,caddy}` — never for the engine.
- The reconciler tick that fires 60s after backend recreate
  correctly matched the surviving engine in both cases
  (`status=running` in both `games` and `runtime_records`).
- `runtime.Service` has no `Shutdown` that proactively removes
  engine containers, so a graceful backend exit also leaves them
  alone.

The repro window therefore needs a separate trigger that removed
the engine container outside of compose. The new hypotheses point
at host-side `docker prune` jobs, a `dockerd` restart that lost the
container, or an early `Engine.Init` failure that exited the engine
before `status=running` reached the runtime row. The investigation
list now leads with `journalctl -u docker` and the host crontab —
those are the cheapest checks to confirm or rule out next.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 23:10:13 +02:00
Ilia Denisov 5177fef2ef tools/dev-deploy: log the sandbox-cancellation TODO
Capture the diagnostic notes for the issue we hit after every
`dev-deploy.yaml` redispatch: the freshly-bootstrapped "Dev Sandbox"
game ends up `cancelled` ~15 minutes later, with the runtime
reconciler reporting "container disappeared". The engine never
shows up in `docker ps -a --filter label=galaxy-game-engine`, so
either it never spawned or it was removed before any host-side
snapshot.

`KNOWN-ISSUES.md` records the symptom, the log excerpt, three
working hypotheses (runtime spawn race, `--remove-orphans`
interaction, engine `--rm` lifecycle), and the investigation
checklist before opening an issue. The README gets a one-line
pointer so future redeploys land on the doc immediately.

No code change — this is the placeholder so the next person
investigating the cancellation pattern does not have to
rediscover the diagnostic from scratch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:56:25 +02:00
developer 823be9d980 Phase 28: diplomatic mail UI
Deploy · Dev / deploy (push) Successful in 29s
Tests · Go / test (push) Successful in 2m5s
Tests · Integration / integration (push) Successful in 1m45s
Tests · UI / test (push) Successful in 2m31s
Merges feat/ui-stage-28 into development.
2026-05-16 20:56:16 +00:00
Ilia Denisov 2119f825d6 mail UI: dedupe broadcast fan-out and drop in-game admin compose
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 2m6s
Tests · UI / test (pull_request) Successful in 2m24s
Two issues surfaced once the long-lived dev environment finally
reached the diplomail view:

1. `/sent` returns one row per recipient for broadcast and admin
   fan-outs (so the admin tooling can render the materialised
   audience). The list pane fed all rows into the stand-alone
   bucket, so the `{#each entries as e (entryKey(e))}` key in
   `thread-list.svelte` collapsed to the same `standalone:${id}`
   for every recipient and Svelte 5 aborted the render with
   `each_key_duplicate`. Dedupe stand-alones by `message_id` in
   `buildEntries`.

2. The compose dialog exposed an `admin` kind toggle gated on
   "owner of game". That was a Phase 28 plan decision, but admin
   compose is an operator tool (server admin), not an in-game
   action — every game owner should not be able to broadcast
   admin notifications. Drop the admin option, the audience
   sub-toggles, and the admin path through `submit`. The
   `MailStore.composeAdmin` wrapper and the backend RPC stay so
   the future admin UI can call them.

Vitest covers the fan-out dedup with three rows sharing one
`message_id` collapsing to a single stand-alone entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:38:59 +02:00
Ilia Denisov 57e6c1d253 gateway: CORS allow-list for the authenticated Connect-Web surface
Tests · Go / test (push) Successful in 2m9s
Tests · Go / test (pull_request) Successful in 2m9s
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · UI / test (pull_request) Successful in 2m52s
The public REST listener already exposes
`GATEWAY_PUBLIC_HTTP_CORS_ALLOWED_ORIGINS`; the authenticated
Connect-Web listener on the separate gRPC port had no equivalent.
That worked in `tools/local-dev` (Vite proxy makes everything
same-origin) and would work in production once UI and gateway share
a single hostname, but the long-lived dev environment serves the
UI from `https://www.galaxy.lan` and the gateway from
`https://api.galaxy.lan` — every `/galaxy.gateway.v1.EdgeGateway/*`
fetch failed in the browser with the WebKit "Load failed" generic
message because the response carried no `Access-Control-Allow-Origin`
header. Lobby rendered as "[unknown] Load failed" with no game.

Mirror the public-REST CORS surface for the authenticated handler:

- new env `GATEWAY_AUTHENTICATED_GRPC_CORS_ALLOWED_ORIGINS`;
- new `AuthenticatedGRPCConfig.CORSAllowedOrigins` field;
- new `grpcapi.withCORS` middleware wrapping the Connect mux;
- dev-deploy stack sets the env to `https://www.galaxy.lan`.

The middleware speaks plain net/http (the Connect handler is mounted
on a ServeMux, not gin), handles preflight 204 immediately, and
exposes the Connect-Web header set the browser needs to read the
response (`Grpc-Status`, `Grpc-Message`, `Connect-Protocol-Version`).
Empty allow-list disables the middleware — production stays at
"single hostname" by default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:15:11 +02:00
Ilia Denisov 4b2a949f12 dev-deploy Caddy: route Connect-Web traffic to gateway :9090
Tests · Integration / integration (pull_request) Successful in 1m44s
Tests · Go / test (pull_request) Successful in 2m6s
Tests · UI / test (pull_request) Successful in 2m27s
`api.galaxy.lan` was proxying every path to `galaxy-api:8080` (the
public REST listener), so authenticated Connect-Web calls
(`/galaxy.gateway.v1.EdgeGateway/ExecuteCommand`,
`/galaxy.gateway.v1.EdgeGateway/SubscribeEvents`) collapsed to a 404
from the public route table — the lobby loaded the static bundle
but every authenticated query failed silently.

Split routing by path: `/galaxy.gateway.v1.EdgeGateway/*` goes to
the authenticated listener on `:9090`, everything else stays on
`:8080`. Mirrors the Vite dev-server proxy in
`ui/frontend/vite.config.ts`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:03:55 +02:00
Ilia Denisov 81917acc3e dev-deploy: enable Dev Sandbox bootstrap and synthetic-report loader
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m4s
Tests · UI / test (pull_request) Successful in 2m23s
Two long-standing dev-environment ergonomics had not survived the
move from the bespoke local-dev stack to the CI-driven dev-deploy:

1. `BACKEND_DEV_SANDBOX_EMAIL` defaulted to an empty string in the
   dev-deploy compose, so the auto-provisioned "Dev Sandbox" game
   never appeared on `https://www.galaxy.lan`. Bake `dev@galaxy.lan`
   as the default — matches `.env.example` and lets a developer who
   logs in with that email find a ready-to-play game in the lobby.

2. The lobby's synthetic-report loader was gated on
   `import.meta.env.DEV`, which is true only for `vite dev` (the
   tools/local-dev path). The long-lived dev environment builds
   with `vite build` (production mode), so the section was always
   stripped from its bundle. Gate it on an explicit
   `VITE_GALAXY_DEV_AFFORDANCES` flag instead and set it both in
   `.env.development` (preserves `pnpm dev` behaviour) and in the
   `dev-deploy.yaml` build step. The `prod-build.yaml` build path
   leaves the flag unset, so production stays clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 21:46:24 +02:00
Ilia Denisov 859b157a59 auth dev-fixed-code bypasses attempts cap; dev-deploy gains manual dispatch
Tests · Go / test (pull_request) Successful in 2m9s
Tests · Go / test (push) Successful in 2m9s
Tests · Integration / integration (pull_request) Successful in 1m49s
Tests · UI / test (pull_request) Successful in 2m51s
Two problems showed up while trying to log into the long-lived dev
environment with the dev-fixed code `123456`:

1. `ConfirmEmailCode` checked the per-challenge attempts ceiling
   *before* the dev-fixed-code override. A developer who burned past
   `ChallengeMaxAttempts` on an existing un-consumed challenge (easy
   to trigger when the throttle reuses one challenge_id) hit
   `ErrTooManyAttempts` and the UI rendered "code expired or already
   used" even though the fixed code was correct. Reorder so the
   dev-fixed-code branch runs first and bypasses both the bcrypt
   verify and the attempts gate. Production stays unaffected
   because production loaders refuse to set `DevFixedCode`.

2. `dev-deploy.yaml` only fires on push to `development`, so the
   matching docker-compose default change for
   `BACKEND_AUTH_DEV_FIXED_CODE` could not reach the running stack
   before this PR merged. Add `workflow_dispatch: {}` so a developer
   can deploy any branch — typically a feature branch under review —
   from the Gitea Actions UI without waiting for the merge.

Covered by a new `TestConfirmEmailCodeDevFixedCodeBypassesAttemptsCeiling`
integration test that burns through the ceiling with wrong codes
then proves the dev-fixed code still produces a session.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 21:28:30 +02:00
Ilia Denisov 166baf4be0 battle-viewer e2e: mock user.games.battle ConnectRPC command
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 2m0s
Tests · UI / test (pull_request) Successful in 2m23s
Phase 28 moved the battle fetch off the REST passthrough onto the
signed envelope, so the Playwright spec's `page.route(...)` against
the old REST path no longer intercepts anything and the viewer
times out waiting for data. Update the spec to:

- Build a FlatBuffers `BattleReport` payload in
  `fixtures/battle-fbs.ts` (mirrors `report-fbs.ts`'s pattern).
- Add a `user.games.battle` case to the ExecuteCommand mock that
  decodes the FBS `GameBattleRequest`, returns the encoded report
  when the battle_id matches the seeded one, and surfaces a
  canonical `not_found` resultCode otherwise.
- Drop the obsolete REST route stubs.
- Drive the negative-path test with a real UUID that does not match
  the seeded one, so the gateway-side switch is the source of the
  404 (the old `missing-uuid` literal was no longer a valid wire
  shape for the UUID decoder).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 12:55:15 +02:00
Ilia Denisov ebd156ece2 battle-fetch: migrate to user.games.battle ConnectRPC command
Tests · UI / test (push) Has been cancelled
Tests · Go / test (pull_request) Successful in 2m6s
Tests · Go / test (push) Successful in 2m7s
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · UI / test (pull_request) Failing after 3m42s
The Phase 27 BattleViewer was the last UI surface still issuing raw
fetch() against the backend REST contract (`/api/v1/user/games/...
/battles/...`). The dev-deploy gateway never proxied that path, so
the viewer worked only in tools/local-dev/. Move it onto the signed
ConnectRPC channel every other authenticated surface already uses.

Wire pieces:
- FBS GameBattleRequest in pkg/schema/fbs/battle.fbs, regenerated
  Go + TS bindings.
- MessageTypeUserGamesBattle constant + GameBattleRequest struct in
  pkg/model/report/messages.go.
- pkg/transcoder/battle.go gains GameBattleRequestToPayload and
  PayloadToGameBattleRequest helpers.
- gateway games_commands.go switches on the new message type and
  GETs /api/v1/user/games/{id}/battles/{turn}/{battle_id}; the JSON
  response is re-encoded as a FlatBuffers BattleReport before being
  returned. 404 from backend surfaces as the canonical `not_found`
  gateway error.
- ui/frontend/src/api/battle-fetch.ts now builds the FBS request,
  calls GalaxyClient.executeCommand, and decodes the FBS response
  into the existing UI shape (Record<string,string> race/ship maps,
  string-form UUID). BattleFetchError carries an HTTP-style status
  derived from the result code so the active-view's not_found branch
  keeps working.
- battle.svelte pulls the GalaxyClient from the in-game shell
  context. While the layout's boot Promise.all is in flight the
  effect stays in `loading` until the client handle becomes
  non-null.
- ui/Makefile FBS_INPUTS gains battle.fbs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 12:41:54 +02:00
Ilia Denisov 8bc75fd71b dev-deploy: default BACKEND_AUTH_DEV_FIXED_CODE to 123456
The long-lived dev environment now opts into the bcrypt-bypass on a
fresh `up`/`rebuild` so a returning developer can sign in with `123456`
even after the matching browser session was cleared (the real emailed
code is single-use). Set the variable to an empty string in `.env` to
force real Mailpit codes (mail-flow QA).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 12:41:32 +02:00
Ilia Denisov 1556d36511 Phase 28: mark stage done after CI gate green
Tests · Integration / integration (pull_request) Successful in 1m43s
Tests · Go / test (pull_request) Successful in 2m4s
Tests · UI / test (pull_request) Successful in 2m20s
Gitea runs at commit 6d0272b:
- go-unit #134 → success
- ui-test #136 → success
- integration #135 → success

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:56:29 +02:00
Ilia Denisov 6d0272b078 Phase 28 (Step 11): Vitest coverage for MailStore threading
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m43s
Tests · Go / test (pull_request) Successful in 2m1s
Tests · UI / test (pull_request) Successful in 2m24s
`tests/mail-store.test.ts` exercises the `entries` derived rune
with handcrafted inbox + sent fixtures:

- personal messages exchanged with one race collapse into a
  per-race thread with messages sorted oldest → newest;
- system mail (`sender_kind=system`) and admin notifications
  (`sender_kind=admin`) surface as stand-alone items even when a
  race-name snapshot is present;
- the caller's own paid-tier broadcasts (`broadcast_scope=
  game_broadcast`) render as stand-alone outgoing items;
- `unreadCount` counts inbox rows with `readAt === null`.

The store fields are mutated directly to avoid wiring a fake
`GalaxyClient`; the underlying `$derived` rune fires whenever
those fields change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:50:01 +02:00
Ilia Denisov c48bc83890 Phase 28 (Step 10): docs — diplomail UI topic + FUNCTIONAL mirror
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 2m4s
- `ui/docs/diplomail-ui.md`: new topic doc covering the wire
  surface, recipient-by-race-name decision, threading model,
  translation toggle, push events, badge, layout, and
  accessibility.
- `docs/FUNCTIONAL.md` §11.4 grows a paragraph that records the
  UI's per-race threading rule, the absent read-receipt UX, and
  the recipient-by-race-name compose path. Mirrored verbatim into
  `docs/FUNCTIONAL_ru.md`.
- `ui/PLAN.md` Phase 28 marked done with a "Decisions during
  stage" block matching the implementation plan, and the artifact
  list updated to the actual file set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:48:16 +02:00
Ilia Denisov db81bd8e08 Phase 28 (Steps 7+8): header unread badge + push/init wiring
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 3m25s
Step 7 — header view-menu badge.

`view-menu.svelte` reads `mailStore.unreadCount` and renders an
inline pill next to the "diplomatic mail" entry whenever the
counter is non-zero. The badge styling matches the per-row dot in
`thread-list.svelte` so the two surfaces feel consistent.

Step 8 — push event handler + MailStore init in the in-game layout.

`routes/games/[id]/+layout.svelte`:

- registers a `diplomail.message.received` handler alongside the
  existing `game.turn.ready` / `game.paused` ones, parses the
  signed payload, calls `mailStore.applyPushEvent` to refresh the
  inbox for the matching game, and raises a toast with a "view"
  deep-link that navigates to `/games/:id/mail`;
- adds `mailStore.init({ client, cache, gameId })` to the boot
  `Promise.all` so the inbox + sent lists are warm by the time the
  view mounts, and the badge counter is populated before any user
  interaction;
- disposes the new subscription in the `onDestroy` block so a game
  switch does not leak handlers across navigations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:46:00 +02:00
Ilia Denisov f7300f25a3 Phase 28 (Steps 6+9): mail active view + i18n keys
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m36s
Tests · Go / test (pull_request) Successful in 3m19s
Tests · UI / test (pull_request) Waiting to run
Step 6 — mail active view + subcomponents.

- `lib/active-view/mail.svelte` replaces the Phase 10 stub with the
  list / detail layout: two-pane on desktop, one-pane stack on
  mobile (CSS media query, no separate route).
- `lib/active-view/mail/thread-list.svelte` renders per-race
  threads collapsed to their last message plus stand-alone
  system / admin / outgoing-broadcast items, with unread badges.
- `lib/active-view/mail/thread-pane.svelte` is the chat-style
  transcript for one race; bodies render through `textContent`,
  per-message Show original / translation toggles flip the
  rendering when a translated body is present, and a persistent
  reply box at the bottom calls `mailStore.composePersonal`.
- `lib/active-view/mail/system-item-pane.svelte` renders one
  stand-alone item read-only with the same translation toggle.
- `lib/active-view/mail/compose.svelte` is the compose dialog:
  recipient race picker fed from `report.races[]`, kind toggle
  (personal / broadcast / admin), admin sub-toggle for target
  user / all and recipient-scope picker. Server-side enforces
  paid-tier and owner gating; the UI surfaces 403 inline.
- `lib/active-view/mail/system-titles.ts` keeps the keyword →
  i18n-title mapping for lifecycle-hook system mail so both the
  list and the detail pane pick the same canonical title.

Step 9 — i18n strings (en + ru).

`game.mail.*`, `game.view.mail.badge`, `game.events.mail_new.*`,
`game.mail.system.*` keys added in lockstep across both locales
covering compose labels / validation copy / per-system titles /
translation toggle / reply / delete affordances.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:43:09 +02:00
Ilia Denisov fdd5fd193d Phase 28 (Step 5): MailStore reactive state
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m38s
Tests · Go / test (pull_request) Successful in 3m19s
Tests · UI / test (pull_request) Waiting to run
Adds `src/lib/mail-store.svelte.ts` — the reactive store that
coordinates the in-game mail view. Responsibilities:

- holds the inbox and sent listings for the current game and fires
  the initial parallel fetch (`fetchInbox` + `fetchSent`) on
  `setGame`;
- exposes a `entries` derived rune that builds the unified list
  pane: per-race threads merged from incoming + outgoing personal
  messages, plus stand-alone items for system / admin / own
  paid-tier broadcasts. Thread messages are sorted oldest → newest
  for chat-style rendering; the list itself sorts newest-first by
  the most-recent entry timestamp;
- derives `unreadCount` from `readAt === null` rows for the header
  view-menu badge;
- imperative `markRead` / `softDelete` actions with optimistic
  state flips and roll-back on RPC failure;
- compose actions for personal / paid-tier broadcast / owner-admin
  sends;
- `applyPushEvent(gameId)` hook called by the layout when a
  `diplomail.message.received` push frame arrives; refetches the
  inbox without trusting the preview payload;
- persists the most recent message id under
  `cache.diplomail/${gameId}/last-seen` so a returning session can
  pre-paint the badge without a network round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:37:32 +02:00
Ilia Denisov 7378d4c8ed Phase 28 (Step 4): UI api/diplomail.ts wrappers
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Has been cancelled
Tests · Go / test (pull_request) Successful in 2m20s
Tests · Integration / integration (pull_request) Successful in 1m43s
Adds typed wrappers around `GalaxyClient.executeCommand` for the
eight Phase 28 mail RPCs. Each wrapper builds the matching
FlatBuffers request, decodes the response, and surfaces backend
errors through a dedicated `MailError` (mirroring `LobbyError`).
The compose helpers accept the recipient race name directly so the
UI can feed it straight from `report.races[].name` without a
membership lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:35:21 +02:00
Ilia Denisov 4cb03736de Phase 28 (Step 3): gateway translators for user.games.mail.*
Tests · Go / test (push) Successful in 2m10s
Tests · Go / test (pull_request) Successful in 2m11s
Tests · Integration / integration (pull_request) Successful in 1m55s
Tests · UI / test (pull_request) Waiting to run
Adds the gateway-side translation layer that maps the eight new
ConnectRPC mail commands onto backend's
`/api/v1/user/games/{game_id}/mail/*` REST endpoints.

- `gateway/internal/backendclient/mail_commands.go` defines
  `ExecuteMailCommand` and one helper per command (inbox, sent,
  message.get, send, broadcast, admin, read, delete). Each helper
  decodes the FlatBuffers request envelope, issues the REST call
  via the existing `*RESTClient.do`, decodes the JSON body, and
  re-encodes a typed FlatBuffers response. Recipient identifiers
  travel through unchanged so the new `recipient_race_name`
  shortcut introduced in Step 1 reaches backend untouched.
- `routes.go` exposes a `MailRoutes` constructor and a matching
  `mailCommandClient` implementing `downstream.Client`.
- `cmd/gateway/main.go` registers the new routes alongside the
  existing user / lobby / game-engine routes.
- `mail_commands_test.go` covers the inbox, send-by-race-name, and
  read-state paths end-to-end against an `httptest.Server`,
  asserting request shapes (path, body, X-User-ID) and the
  decoded FlatBuffers response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:32:50 +02:00
Ilia Denisov 57d2286f5e Phase 28 (Step 3a): /sent returns full message detail per recipient
Tests · Go / test (push) Successful in 2m5s
Tests · Go / test (pull_request) Successful in 2m10s
Tests · Integration / integration (pull_request) Successful in 1m54s
Tests · UI / test (pull_request) Successful in 2m53s
Phase 28's in-game mail UI threads sent messages by the recipient
race name, so the bulk `/sent` endpoint now returns the same
`UserMailMessageDetail` shape as `/inbox` — single sends contribute
one row per message, broadcasts contribute one row per addressee
and the UI collapses them by `message_id` into a stand-alone item.

- `Store.ListSent` / `Service.ListSent` switched from `[]Message`
  to `[]InboxEntry`. SQL grows an INNER JOIN with
  `diplomail_recipients`.
- Handler emits `userMailMessageDetailWire` items; the deprecated
  `userMailSentSummaryWire` is removed.
- `openapi.yaml`: `UserMailSentList.items` now reference
  `UserMailMessageDetail`; the standalone `UserMailSentSummary`
  schema is dropped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:27:39 +02:00
Ilia Denisov fed282f2d2 Phase 28 (Step 2): FBS schemas + message-type constants for mail
Tests · UI / test (push) Has been cancelled
Tests · Go / test (pull_request) Successful in 2m4s
Tests · Go / test (push) Successful in 2m5s
Tests · Integration / integration (pull_request) Successful in 1m54s
Tests · UI / test (pull_request) Successful in 2m50s
Adds the wire schema for the eight `user.games.mail.*` ConnectRPC
commands together with the shared payload types (`MailMessage`,
`MailRecipientState`, `MailBroadcastReceipt`). Send-request tables
carry the optional `recipient_race_name` introduced in Step 1.

Drops:

- `pkg/schema/fbs/diplomail.fbs` — schema sources;
- `pkg/schema/fbs/diplomail/*.go` — generated Go bindings (flatc
  `--go --go-module-name galaxy/schema/fbs`);
- `pkg/model/diplomail/diplomail.go` — message-type catalog used by
  the gateway router;
- `ui/frontend/src/proto/galaxy/fbs/diplomail/*.ts` — generated TS
  bindings consumed by the upcoming UI client wrapper;
- `ui/Makefile` `FBS_INPUTS` extended to pick the new schema up on
  the next `make -C ui fbs-ts` run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:21:23 +02:00
Ilia Denisov 7b43ce5844 Phase 28 (Step 1): backend support for race-name mail send
Tests · Go / test (push) Successful in 1m56s
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m2s
Phase 28's in-game mail UI groups personal threads by the other
party's race. To support that without an extra membership-listing
RPC, the diplomail subsystem now:

- accepts `recipient_race_name` on `POST /messages` and
  `POST /admin` (target=user) as an alternative to
  `recipient_user_id`; the service resolves it via the existing
  `Memberships.ListMembers(gameID, "active")` and rejects with
  `forbidden` when the matching member is no longer active;
- snapshots `diplomail_messages.sender_race_name` at send time for
  every player sender (admin / system rows stay NULL). The UI keys
  per-race threading on this column.

Schema, openapi, README, and a focused e2e test for the new path
(happy path + dual / missing / unknown / kicked errors) land in
this commit; the gateway + UI legs follow in subsequent commits on
this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:07:48 +02:00
developer 74c1e7ab24 Merge pull request 'diplomail (Stage A→D): backend in-game diplomatic mail' (#10) from feature/diplomail-backend into development
Deploy · Dev / deploy (push) Successful in 39s
Tests · Go / test (push) Successful in 1m59s
Tests · Integration / integration (push) Successful in 1m42s
2026-05-15 18:43:27 +00:00
Ilia Denisov 2d36b54b8d diplomail (Stage F): docs + edge-case tests + LibreTranslate recipe
Tests · Go / test (push) Successful in 2m2s
Tests · Go / test (pull_request) Successful in 2m4s
Tests · Integration / integration (pull_request) Successful in 1m37s
Closes the documentation gaps from the freshly-audited diplomail
implementation. FUNCTIONAL.md gains a §11 "Diplomatic mail" with
the full user-facing story across all five stages, mirrored into
FUNCTIONAL_ru.md as the project conventions require. A new
backend/docs/diplomail-translator-setup.md captures the
LibreTranslate operational recipe (Docker image, env wiring,
manual smoke test, troubleshooting). The package README gains a
"Multi-instance posture" note documenting the deliberate absence
of FOR UPDATE in the worker pickup query — single-instance is
safe today; multi-instance scaling will revisit the claim
mechanism.

Two small edge-case tests round things out: malformed
LibreTranslate response bodies (single string, short array,
empty array, missing field) must surface as errors so the worker
falls back instead of crashing; and an empty translation queue
must produce zero events on three consecutive Worker.Tick calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:35:36 +02:00
Ilia Denisov 9f7c9099bc diplomail (Stage E): LibreTranslate client + async translation worker
Tests · Go / test (push) Successful in 1m59s
Tests · Go / test (pull_request) Successful in 2m1s
Tests · Integration / integration (pull_request) Successful in 1m37s
Synchronous translation on read (Stage D) blocks the HTTP handler on
translator I/O. Stage E switches to "send moments-fast, deliver
when translated": recipients whose preferred_language differs from
the detected body_lang are inserted with available_at=NULL, and an
async worker turns them on once a LibreTranslate call materialises
the cache row (or fails terminally after 5 retries).

Schema delta on diplomail_recipients: available_at,
translation_attempts, next_translation_attempt_at, plus a snapshot
recipient_preferred_language so the worker queries do not need a
join. Read paths (ListInbox, GetMessage, UnreadCount) filter on
available_at IS NOT NULL. Push fan-out is moved from Service to the
worker so the recipient only sees the toast when the inbox row is
actually visible.

Translator backend is now a configurable choice: empty
BACKEND_DIPLOMAIL_TRANSLATOR_URL → noop (deliver original);
populated → LibreTranslate HTTP client. Per-attempt timeout, max
attempts, and worker interval all live in DiplomailConfig. The HTTP
client itself is unit-tested via httptest (happy path, BCP47
normalisation, unsupported pair, 5xx, identical src/dst, missing
URL); worker delivery + fallback paths are covered by the
testcontainers-backed e2e tests in diplomail_e2e_test.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:15:28 +02:00
Ilia Denisov e22f4b7800 diplomail (Stage D): language detection + lazy translation cache
Tests · Go / test (push) Successful in 1m59s
Tests · Go / test (pull_request) Successful in 2m0s
Tests · Integration / integration (pull_request) Successful in 1m35s
Replaces the LangUndetermined placeholder with whatlanggo-backed
body detection on every send path, then adds a translation cache
keyed on (message_id, target_lang) populated lazily on the
per-message read endpoint. The noop translator that ships with
Stage D returns engine="noop", which the service treats as
"translation unavailable" — wiring a real backend (LibreTranslate
HTTP client is the documented next step) is a one-file swap.

GetMessage and ListInbox now accept a targetLang argument; the HTTP
layer resolves the caller's accounts.preferred_language and
forwards it. Inbox uses the cache only (never calls the
translator) so bulk reads stay fast under future SaaS backends.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:16:12 +02:00
Ilia Denisov 362f92e520 diplomail (Stage C): paid-tier broadcast + multi-game + cleanup
Tests · Go / test (pull_request) Successful in 1m59s
Tests · Go / test (push) Successful in 1m59s
Tests · Integration / integration (pull_request) Successful in 1m36s
Closes out the producer-side of the diplomail surface. Paid-tier
players can fan out one personal message to the rest of the active
roster (gated on entitlement_snapshots.is_paid). Site admins gain a
multi-game broadcast (POST /admin/mail/broadcast with `selected` /
`all_running` scopes) and the bulk-purge endpoint that wipes
diplomail rows tied to games finished more than N years ago. An
admin listing (GET /admin/mail/messages) rounds out the
observability surface.

EntitlementReader and GameLookup are new narrow deps wired from
`*user.Service` and `*lobby.Service` in cmd/backend/main; the lobby
service grows a one-off `ListFinishedGamesBefore` helper for the
cleanup path (the cache evicts terminal-state games so the cache
walk is not enough). Stage D will swap LangUndetermined for an
actual body-language detector and add the translation cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:02:46 +02:00
Ilia Denisov b3f24cc440 diplomail (Stage B): admin/owner sends + lifecycle hooks
Tests · Go / test (push) Successful in 1m52s
Tests · Go / test (pull_request) Successful in 1m53s
Tests · Integration / integration (pull_request) Successful in 1m36s
Item 7 of the spec wants game-state and membership-state changes to
land as durable inbox entries the affected players can re-read after
the fact — push alone times out of the 5-minute ring buffer. Stage B
adds the admin-kind send matrix (owner-driven via /user, site-admin
driven via /admin) plus the lobby lifecycle hooks: paused / cancelled
emit a broadcast system mail to active members, kick / ban emit a
single-recipient system mail to the affected user (which they keep
read access to even after the membership row is revoked, per item 8).

Migration relaxes diplomail_messages_kind_sender_chk so an owner
sending kind=admin keeps sender_kind=player; the new
LifecyclePublisher dep on lobby.Service is wired through a thin
adapter in cmd/backend/main, mirroring how lobby's notification
publisher is plumbed today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:47:54 +02:00
Ilia Denisov 535e27008f diplomail (Stage A): add in-game personal mail subsystem
Tests · Go / test (push) Successful in 1m44s
Tests · Integration / integration (pull_request) Successful in 1m44s
Tests · Go / test (pull_request) Successful in 2m45s
Phase 28 of ui/PLAN.md needs a persistent player-to-player mail
channel; the existing `mail` package is a transactional email
outbox and the `notification` catalog is one-way platform events.
Stage A lands the schema (diplomail_messages / _recipients /
_translations), a single-recipient personal send/read/delete
service path, a `diplomail.message.received` push kind plumbed
through the notification pipeline, and an unread-counts endpoint
that drives the lobby badge. Admin / system mail, lifecycle hooks,
paid-tier broadcast, multi-game broadcast, bulk purge and language
detection / translation cache come in stages B–D.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:28:55 +02:00
developer 77cb7c78b6 Merge pull request #9: ui-test singleton queue
Tests · UI / test (push) Successful in 2m14s
Replaces per-sha cancel-in-progress (which fired spurious self-cancels) with a singleton queueing group. ui-test #74 (push) and #75 (pull_request) both green at ~2m, queue-not-cancel verified.
2026-05-15 06:57:09 +00:00
Ilia Denisov 1a0e3e992f ci/ui-test: queue runs in one bucket instead of cancelling
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m20s
`cancel-in-progress: true` killed run #73 even though it was the
only ui-test in its concurrency group — Gitea appears to cancel the
in-progress job on its own under that setting in some edge cases.

Switch to a singleton group with `cancel-in-progress: false`. The
new behaviour is simple queueing: only one ui-test workflow runs at
a time across the repository, the rest wait. Vite-on-:5173 cannot
collide because there is never a second ui-test alive. The wall-time
hit is bounded — ui-test is ~2 minutes — and bursts are rare enough
that queueing is cheap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:51:54 +02:00
developer faf598b2cd Merge pull request #8: Playwright tuning + concurrency for ui-test
Tests · UI / test (push) Failing after 6s
Deploy · Dev / deploy (push) Successful in 32s
Caps Playwright at 4 workers + 4 retries to absorb the host-mode flake budget, and serialises ui-test runs by head sha so push and pull_request events for the same commit cannot collide on Vite :5173.
2026-05-15 06:49:06 +00:00
Ilia Denisov 6e6186a571 ci/ui-test: key concurrency by head sha, not gitea.ref
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 2m17s
`gitea.ref` differs between push (`refs/heads/<branch>`) and
pull_request (`refs/pull/N/head`) events even for the same commit,
so the two parallel runs land in different concurrency groups and
the Vite-on-:5173 collision is not suppressed. Switching the key to
the head sha (`gitea.event.pull_request.head.sha || gitea.sha`)
collapses both events into one bucket, leaving exactly one ui-test
alive per commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:46:00 +02:00
Ilia Denisov e3bb30201d ci/ui-test: serialise per-ref + clear stale Vite before Playwright
Tests · UI / test (pull_request) Failing after 6s
Tests · UI / test (push) Successful in 2m21s
Two ui-test jobs cannot coexist on the same host: Playwright's
`webServer` spec spawns `pnpm dev` on :5173, and on a host-mode
runner the port lives in the host namespace shared by every job.
ui-test #67 hit "Error: http://localhost:5173 is already used"
because a parallel job's Vite still held the port.

Two changes:

1. `concurrency: ui-test-${{ gitea.ref }}` with `cancel-in-progress:
   true`. New push/PR runs against the same ref kill any earlier
   ui-test before starting, so we never have two `pnpm dev`s alive
   at once.
2. `pkill -f 'vite dev' || true` plus `fuser -k 5173/tcp` right
   before Playwright. Defence in depth in case the concurrency
   cancellation does not reap the spawned shell promptly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:42:08 +02:00
Ilia Denisov 7ff81de2b6 ui/frontend: cap Playwright at 4 workers, retry 4 times
Tests · UI / test (pull_request) Failing after 26s
Tests · UI / test (push) Successful in 2m21s
Under host-mode runner the default 6 workers + 1 retry consistently
land on ~7 flakies and an occasional hard fail per ui-test run
(ui-test #59 most recently). Workers share CPU and the host Docker
daemon with gitea, the long-lived dev stack, and the user's host
Caddy; the extra wall time from contention pushes individual
expectations past their timeouts.

Lower the worker cap to 4 to keep parallelism but give each worker
real CPU headroom, and raise retries to 4 so the rare slow page is
absorbed without surfacing as failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:39:22 +02:00
developer 9d65bf5157 Merge pull request #7: flaky RandomSuffix + CORS allow-list
Deploy · Dev / deploy (push) Successful in 34s
Tests · Go / test (push) Successful in 1m40s
Tests · Integration / integration (push) Successful in 1m40s
Fixes the birthday-collision flake in TestRandomSuffixGenerator and adds an env-driven CORS allow-list on the public gateway so the dev UI on https://www.galaxy.lan can reach https://api.galaxy.lan.
2026-05-15 06:35:58 +00:00
Ilia Denisov 1855e43699 gateway: add CORS allow-list for the public REST surface
Tests · Go / test (push) Successful in 1m42s
Tests · Go / test (pull_request) Successful in 1m45s
Tests · Integration / integration (pull_request) Successful in 1m36s
Adds a `GATEWAY_PUBLIC_HTTP_CORS_ALLOWED_ORIGINS` env-driven allow-list
on the public REST server so the dev UI on https://www.galaxy.lan can
call https://api.galaxy.lan without the browser blocking the
cross-origin response. Defaults to empty (no CORS) so the production
posture stays closed.

The middleware mounts before route classification and anti-abuse, so
OPTIONS preflights never charge against per-class rate-limit buckets.

`tools/dev-deploy/docker-compose.yml` opts the dev gateway into a
single allowed origin (`https://www.galaxy.lan`); local-dev keeps the
defaults because Vite proxies through the same origin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:58:14 +02:00
Ilia Denisov 7bce67462c pkg/util: harden TestRandomSuffixGenerator against birthday collisions
The previous test asserted that no two adjacent samples from a
~10 000-element space were equal across 100 iterations. The birthday
math gives that adjacency check a ~1 % flake rate per run; with the
new gitea.lan CI volume that turned into observable random failures
(go-unit #51 on feature/enable-actions-cache hit "Should not be:
'6635'").

Replace adjacency with a distinctness floor over a wider 200-sample
draw. A stuck generator (single value) lands at 1 unique; a
256-element range lands at ~196; the natural full-range generator
lands at ~198. A floor of 150 catches the failure modes the test was
actually written to guard against and never trips on legitimate
randomness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:58:02 +02:00
developer 2be7e5c110 Merge pull request #6: re-enable actions cache
Deploy · Dev / deploy (push) Successful in 27s
Tests · Go / test (push) Successful in 1m43s
Tests · Integration / integration (push) Successful in 1m42s
Tests · UI / test (push) Failing after 2m10s
Cache service answers on 10.200.0.1:43513 after the nftables fix. setup-go/setup-node opt back into cache: true / cache: pnpm. Cache hit verified in run #55 (ui-test on PR head).
2026-05-15 05:46:57 +00:00
Ilia Denisov 2a95bf4a50 ci: re-enable actions cache now that the runner serves it
Tests · UI / test (push) Successful in 2m20s
Tests · Go / test (push) Failing after 2m21s
Tests · Go / test (pull_request) Successful in 1m40s
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · UI / test (pull_request) Successful in 2m2s
The Gitea Actions cache service now answers on 10.200.0.1:43513
(post nftables fix on the runner side). Turn `cache: true` and
`cache: pnpm` back on so setup-go/setup-node can use it for
cross-job tarball caching on top of the host-persistent caches we
already rely on.

The setup-* actions still tolerate the cache being unavailable, so
this is reversible to `cache: false` if the service goes away again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:39:39 +02:00
developer fd071260ec Merge pull request #5: drop cache: setting in setup-go / setup-node
Deploy · Dev / deploy (push) Successful in 31s
Tests · Go / test (push) Successful in 1m44s
Tests · Integration / integration (push) Successful in 1m42s
Tests · UI / test (push) Successful in 2m11s
Avoids the zombie reserveCache retries against the unreachable Gitea Actions cache service on :43513. Host-mode runner keeps caches warm in $HOME without needing actions/cache plumbing.
2026-05-14 04:47:56 +00:00
Ilia Denisov 8058f26397 ci: drop cache: setting in setup-go/setup-node
Tests · Go / test (push) Successful in 2m21s
Tests · UI / test (push) Successful in 2m22s
Tests · Go / test (pull_request) Successful in 3m14s
Tests · Integration / integration (pull_request) Successful in 1m37s
Tests · UI / test (pull_request) Successful in 2m7s
`cache: true` (setup-go) and `cache: pnpm` (setup-node) make the
actions push and pull tarballs through the Gitea Actions cache
service at 192.168.0.222:43513. That endpoint currently does not
answer, so every workflow burns minutes per run on reserveCache
retries before the action gives up.

In host-mode the real caches live under the runner user's $HOME
(~/go/pkg/mod, ~/.cache/go-build, ~/.local/share/pnpm,
~/.cache/ms-playwright) and persist between jobs without any
actions/cache plumbing. Switching cache: off avoids the zombie
retries and uses the local disk caches the runner already has warm.

Reviving the cache service is a separate TODO. Until then this is
the simpler and faster baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:39:22 +02:00
developer 660044559c Merge pull request #4: cleanup after host-mode runner
Deploy · Dev / deploy (push) Successful in 28s
Tests · Go / test (push) Successful in 1m41s
Tests · Integration / integration (push) Successful in 1m45s
Tests · UI / test (push) Successful in 2m14s
Drops the docker-in-docker workarounds (GIT_SSL_NO_VERIFY env, GeoIP image bake, playwright --with-deps) now that act_runner executes jobs natively on the host.
2026-05-14 04:31:27 +00:00
Ilia Denisov 9135991887 ci/ui-test: drop --with-deps now that runner is host-mode
Tests · Go / test (pull_request) Successful in 2m6s
Tests · UI / test (push) Failing after 2m32s
Tests · Integration / integration (pull_request) Successful in 1m52s
Tests · UI / test (pull_request) Successful in 2m3s
`playwright install --with-deps` shells out to `sudo apt-get install`
for the system libraries that headless browsers need. In a job
container that runs as root this is silent; on a host-mode runner the
non-interactive sudo prompts for a password, fails three times, and
the step exits 1.

Drop --with-deps. The system .so libraries are installed once on the
host via `pnpm exec playwright install-deps` (or the equivalent
apt-get incantation); workflow runs only need to fetch the browser
binaries themselves, which lives under the runner user's home and
needs no privilege.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:59:45 +02:00
Ilia Denisov bb74e3336e dev-deploy: restore GeoIP bind-mount, drop image bake
Tests · Integration / integration (pull_request) Successful in 2m14s
Tests · Go / test (pull_request) Successful in 2m19s
Tests · UI / test (pull_request) Failing after 51m17s
With the runner in host-mode, compose bind-mount paths resolve to
real host paths the Docker daemon can see, so the GeoIP file no
longer needs to be baked into the backend image to survive CI. Bring
back the bind-mount of `pkg/geoip/test-data/.../mmdb`, matching how
local-dev sources it. Image now only carries the backend binary,
symmetric with the production `backend/Dockerfile`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:04:11 +02:00
Ilia Denisov 4a88b24f4b ci: drop GIT_SSL_NO_VERIFY now that runner is host-mode
The act_runner now executes jobs natively on the host (no per-job
container), so actions/checkout uses the host's system CA store,
which already trusts the host-Caddy root CA. The workaround that
disabled TLS verification for `git fetch` is no longer needed and
just hides legitimate cert issues if they ever appear.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:04:11 +02:00
developer fe8ad6a02a Merge pull request 'dev-deploy: fix backend startup in CI' (#3) from feature/dev-deploy-followups into development
Tests · Integration / integration (push) Successful in 2m16s
Tests · Go / test (push) Successful in 2m39s
Tests · UI / test (push) Successful in 12m29s
Deploy · Dev / deploy (push) Successful in 44s
Reviewed-on: https://gitea.dev/developer/galaxy-game/pulls/3
2026-05-13 22:42:03 +00:00
Ilia Denisov 9ebb2e7f0f ci: rename workflows for Gitea UI readability
Tests · Go / test (push) Successful in 2m31s
Tests · Integration / integration (pull_request) Successful in 2m23s
Tests · Go / test (pull_request) Successful in 2m50s
Tests · UI / test (push) Successful in 13m2s
Tests · UI / test (pull_request) Successful in 13m22s
Switches the `name:` field on every workflow to the bulleted style:

  Tests · Go            (go-unit.yaml)
  Tests · UI            (ui-test.yaml)
  Tests · Integration   (integration.yaml)
  Deploy · Dev          (dev-deploy.yaml)
  Build · Prod          (prod-build.yaml)
  Deploy · Prod         (deploy-prod.yaml)

File names stay the same so existing path filters and any URL
references continue to work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:22:53 +02:00
Ilia Denisov 0da360a644 dev-deploy: fix backend startup in CI
Two bugs surfaced on the first real merge into development:

1. `${{ env.HOME }}` evaluates to empty string at the workflow stage,
   so GALAXY_DEV_GAME_STATE_DIR became `/.galaxy-dev/game-state`.
   Resolve in the shell instead of YAML.

2. The compose bind-mount of GeoIP2-Country-Test.mmdb referenced a
   path inside the runner's workspace volume, which the host Docker
   daemon cannot see — it created an empty directory and the backend
   crashed with "geoip database: is a directory" in a restart loop.
   Bake the file into the backend image so dev-deploy no longer needs
   a bind-mount; local-dev compose still mounts it on top for swap-in
   during development.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:22:16 +02:00
781 changed files with 141154 additions and 41699 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Build core.wasm
description: >-
Install TinyGo (cached) and build ui/core to frontend/static/core.wasm
and wasm_exec.js via `make -C ui wasm`. The binaries are no longer
committed, so every workflow that builds or serves the frontend bundle
(ui-test, dev-deploy, prod-build) runs this first. Requires Go to be
set up by the caller — TinyGo shells out to the Go toolchain.
runs:
using: composite
steps:
- name: Restore TinyGo cache
uses: actions/cache@v4
with:
path: ~/.cache/galaxy-tinygo
key: tinygo-0.41.1-linux-amd64
- name: Install TinyGo
shell: bash
run: |
set -euo pipefail
version="0.41.1"
root="$HOME/.cache/galaxy-tinygo/tinygo"
if [ ! -x "$root/bin/tinygo" ]; then
mkdir -p "$HOME/.cache/galaxy-tinygo"
curl -fsSL "https://github.com/tinygo-org/tinygo/releases/download/v${version}/tinygo${version}.linux-amd64.tar.gz" \
| tar -xz -C "$HOME/.cache/galaxy-tinygo"
fi
echo "$root/bin" >> "$GITHUB_PATH"
- name: Build core.wasm
shell: bash
run: make -C ui wasm
+2 -2
View File
@@ -1,4 +1,4 @@
name: deploy-prod
name: Deploy · Prod
# Placeholder for the production rollout workflow. Today it only proves
# the manual entry point works; the actual `docker save | ssh prod
@@ -28,4 +28,4 @@ jobs:
echo " 2. scp the .tar.gz bundles to the production host."
echo " 3. ssh prod 'docker load -i ...' for backend / gateway / engine."
echo " 4. ssh prod 'docker compose -f /opt/galaxy/docker-compose.yml up -d'."
echo " 5. Probe https://api.galaxy.com/healthz and roll back on failure."
echo " 5. Probe https://<public host>/healthz and roll back on failure."
+185 -12
View File
@@ -1,4 +1,4 @@
name: dev-deploy
name: Deploy · Dev
# Builds the Galaxy stack and (re)deploys it into the long-lived dev
# environment on the host running this Gitea Actions runner. Triggered
@@ -7,6 +7,12 @@ name: dev-deploy
# `integration` as part of the PR that produced this push, so this
# workflow does not re-run those tests — it focuses on packaging and
# rollout.
#
# `workflow_dispatch` is also accepted so a developer can deploy any
# branch (typically a feature branch under active review) into the
# shared dev environment from the Gitea Actions UI without waiting for
# the PR to merge first. The deploy job picks up whatever the chosen
# ref is — same packaging + healthcheck steps as the merge path.
on:
push:
@@ -18,17 +24,13 @@ on:
- 'game/**'
- 'pkg/**'
- 'ui/**'
- 'site/**'
- 'go.work'
- 'go.work.sum'
- 'tools/dev-deploy/**'
- '.gitea/workflows/dev-deploy.yaml'
- '!**/*.md'
env:
# See go-unit.yaml for the rationale; this disables TLS verify for
# actions/checkout against the LAN Gitea host signed by host-Caddy's
# internal CA.
GIT_SSL_NO_VERIFY: "true"
workflow_dispatch: {}
jobs:
deploy:
@@ -52,6 +54,11 @@ jobs:
uses: pnpm/action-setup@v4
with:
version: 11.0.7
# Install pnpm into a per-job directory so concurrent jobs on
# the shared host runner do not race on the default
# `~/setup-pnpm` (the self-installer otherwise fails with
# `ENOTEMPTY` while cleaning a sibling job's install).
dest: ${{ runner.temp }}/setup-pnpm
- name: Set up Node
uses: actions/setup-node@v4
@@ -64,10 +71,22 @@ jobs:
working-directory: ui
run: pnpm install --frozen-lockfile
- name: Build core.wasm
uses: ./.gitea/actions/build-wasm
- name: Build UI frontend
working-directory: ui/frontend
env:
VITE_GATEWAY_BASE_URL: https://api.galaxy.lan
# Single-origin deployment: an empty base URL means the
# gateway shares the document origin (REST at /api, Connect at
# /rpc). The game UI is served under the /game/ base path.
VITE_GATEWAY_BASE_URL: ""
BASE_PATH: /game
# Surface the synthetic-report loader and similar dev-only
# affordances in the long-lived dev bundle. The prod build
# path (`prod-build.yaml`) leaves this flag unset so the
# production bundle keeps the same affordances stripped.
VITE_GALAXY_DEV_AFFORDANCES: "true"
run: |
# The response-signing public key is committed in
# `.env.development` alongside its private counterpart in
@@ -77,6 +96,14 @@ jobs:
export VITE_GATEWAY_RESPONSE_PUBLIC_KEY="$(grep -E '^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=' .env.development | cut -d= -f2)"
pnpm build
- name: Install site dependencies
working-directory: site
run: pnpm install --frozen-lockfile
- name: Build project site
working-directory: site
run: pnpm build
- name: Build galaxy-engine image
working-directory: ${{ gitea.workspace }}
run: |
@@ -98,12 +125,155 @@ jobs:
-v "${{ gitea.workspace }}/ui/frontend/build:/src:ro" \
alpine sh -c 'rm -rf /dst/* /dst/.??* 2>/dev/null; cp -a /src/. /dst/'
- name: Seed site volume
run: |
docker volume create galaxy-dev-site-dist >/dev/null
docker run --rm \
-v galaxy-dev-site-dist:/dst \
-v "${{ gitea.workspace }}/site/.vitepress/dist:/src:ro" \
alpine sh -c 'rm -rf /dst/* /dst/.??* 2>/dev/null; cp -a /src/. /dst/'
- name: Seed geoip volume
run: |
# Copy the GeoIP test fixture into a named volume so the
# backend can mount it as /var/lib/galaxy. A bind-mount with
# a relative path would resolve against this runner's
# ephemeral workspace under /home/runner/.cache/act/<hash>/,
# which the runner deletes once the workflow ends — the next
# `docker restart galaxy-dev-backend` would then fail with
# "not a directory" because the mount source vanished.
docker volume create galaxy-dev-geoip-data >/dev/null
docker run --rm \
-v galaxy-dev-geoip-data:/dst \
-v "${{ gitea.workspace }}/pkg/geoip/test-data/test-data:/src:ro" \
alpine sh -c 'cp /src/GeoIP2-Country-Test.mmdb /dst/geoip.mmdb'
- name: Seed mailpit relay config
env:
GALAXY_DEV_MAIL_RELAY_USERNAME: ${{ secrets.GALAXY_DEV_MAIL_RELAY_USERNAME }}
GALAXY_DEV_MAIL_RELAY_PASSWORD: ${{ secrets.GALAXY_DEV_MAIL_RELAY_PASSWORD }}
run: |
# Render the Mailpit relay upstream config from the template,
# substituting the Gmail App Password from a Gitea secret, then
# seed it into a named volume (same rationale as the geoip seed:
# a workspace bind-mount would vanish with the runner workspace).
# The secret never lands in git or a committed file; it is
# rendered to a tmpfile outside the repo and removed after. Gmail
# App Passwords are [a-z]{16}, so the `|` sed delimiter is safe.
# When the secret is unset the creds render empty and the compose
# default relay-match is non-routable, so the stack only captures.
rendered="$(mktemp)"
sed -e "s|\${GALAXY_DEV_MAIL_RELAY_USERNAME}|${GALAXY_DEV_MAIL_RELAY_USERNAME}|g" \
-e "s|\${GALAXY_DEV_MAIL_RELAY_PASSWORD}|${GALAXY_DEV_MAIL_RELAY_PASSWORD}|g" \
"${{ gitea.workspace }}/tools/dev-deploy/mailpit/relay.conf.tmpl" > "$rendered"
docker volume create galaxy-dev-mailpit-config >/dev/null
docker run --rm \
-v galaxy-dev-mailpit-config:/dst \
-v "$rendered:/src/relay.conf:ro" \
alpine sh -c 'cp /src/relay.conf /dst/relay.conf && chmod 600 /dst/relay.conf'
rm -f "$rendered"
- name: Recycle engine containers on image drift
run: |
# Compare the freshly-built `galaxy-engine:dev` SHA against
# every running `galaxy-game-*` container. The backend
# reconciler adopts pre-existing labelled engine containers
# without checking image drift, so a running game would
# otherwise keep serving the previous engine code until the
# container is recycled by hand. This step makes the recycle
# automatic but only when it is actually needed:
#
# * BuildKit cache hit on the `Build galaxy-engine image`
# step → `galaxy-engine:dev` keeps its previous SHA →
# no drift → no-op (no engine source change to deploy).
# * engine source change → fresh SHA → for each drifted
# container we stop the backend, remove the container,
# wipe its bind-mounted state directory (Engine.Init()
# writes turn-0 over any pre-existing `turn-N` files —
# silent state corruption otherwise), and cascade-delete
# the lobby `games` row (the FKs in `00001_init.sql`
# drop the matching `runtime_records`, `memberships`,
# `player_mappings`, etc. in the same write).
#
# Backend is stopped first to keep the reconciler from
# racing the recycle (mid-stream adoption / restart). The
# subsequent `Bring up the stack` step restarts it.
set -u
new_sha=$(docker image inspect galaxy-engine:dev --format '{{.Id}}')
echo "fresh galaxy-engine:dev = $new_sha"
drift=()
for c in $(docker ps --filter "name=galaxy-game-" --format '{{.Names}}'); do
cur=$(docker inspect "$c" --format '{{.Image}}')
if [ "$cur" != "$new_sha" ]; then
drift+=("${c#galaxy-game-}")
echo " drift: $c was on $cur"
else
echo " match: $c"
fi
done
if [ ${#drift[@]} -eq 0 ]; then
echo "no drift detected — recycle skipped"
else
docker stop -t 30 galaxy-dev-backend >/dev/null 2>&1 || true
state_root="$HOME/.galaxy-dev/game-state"
for gid in "${drift[@]}"; do
echo "recycling $gid"
docker rm -f "galaxy-game-$gid" >/dev/null 2>&1 || true
# Wipe the per-game state dir as root inside a throwaway
# container so we can remove files left behind by the
# engine container even when its uid differs from the
# runner's.
docker run --rm -v "$state_root:/state" alpine \
sh -c "rm -rf -- /state/$gid"
done
ids_csv=$(printf "'%s'," "${drift[@]}")
ids_csv=${ids_csv%,}
docker exec galaxy-dev-postgres psql -v ON_ERROR_STOP=1 \
-U galaxy -d galaxy_backend \
-c "DELETE FROM backend.games WHERE game_id IN (${ids_csv});"
fi
- name: Reap stray dev-deploy containers
run: |
# Remove any non-running compose-managed containers from
# earlier deploys before `compose up`. Filter by the stack
# label so we never touch unrelated workloads on the same
# daemon. Running engine containers spawned by backend with
# the same label are left intact when their image SHA still
# matches the freshly-built `galaxy-engine:dev` (handled by
# the preceding `Recycle engine containers on image drift`
# step); the reconciler reattaches them on backend boot.
ids=$(docker ps -aq \
--filter "label=galaxy.stack=dev-deploy" \
--filter "status=exited" \
--filter "status=created" \
--filter "status=dead")
if [ -n "$ids" ]; then
echo "reaping: $ids"
docker rm -f $ids
fi
- name: Bring up the stack
working-directory: tools/dev-deploy
env:
GALAXY_DEV_GAME_STATE_DIR: ${{ env.HOME }}/.galaxy-dev/game-state
# Recipient regex Mailpit auto-relays to the owner's Gmail.
# Unset/empty → the compose default (non-routable) keeps the
# stack capture-only.
GALAXY_DEV_MAIL_RELAY_MATCH: ${{ vars.GALAXY_DEV_MAIL_RELAY_MATCH }}
# Grafana admin password; unset/empty -> compose default 'admin'.
GALAXY_DEV_GRAFANA_ADMIN_PASSWORD: ${{ secrets.GALAXY_DEV_GRAFANA_ADMIN_PASSWORD }}
run: |
# Resolve in the shell, not in YAML expressions — `env.HOME`
# is empty at the workflow-evaluation stage.
export GALAXY_DEV_GAME_STATE_DIR="$HOME/.galaxy-dev/game-state"
mkdir -p "$GALAXY_DEV_GAME_STATE_DIR"
# Seed the monitoring config to a stable, reboot-surviving host
# path (compose binds \${GALAXY_DEV_MONITORING_DIR} read-only).
export GALAXY_DEV_MONITORING_DIR="$HOME/.galaxy-dev/monitoring"
rm -rf "$GALAXY_DEV_MONITORING_DIR"
mkdir -p "$GALAXY_DEV_MONITORING_DIR"
cp -r monitoring/. "$GALAXY_DEV_MONITORING_DIR/"
docker compose up -d --wait --remove-orphans
- name: Probe the stack
@@ -114,9 +284,12 @@ jobs:
# `tls internal`) terminates and forwards into the edge
# network. We accept the host's internal CA via -k because
# the runner image has no reason to trust it.
curl -sk --max-time 10 https://api.galaxy.lan/healthz \
curl -sk --max-time 10 https://galaxy.lan/healthz \
| tee /tmp/healthz
test -s /tmp/healthz
curl -sk --max-time 10 -o /dev/null -w '%{http_code}\n' \
https://www.galaxy.lan/ | tee /tmp/www_status
grep -qE '^(200|304)$' /tmp/www_status
https://galaxy.lan/ | tee /tmp/site_status
grep -qE '^(200|304)$' /tmp/site_status
curl -sk --max-time 10 -o /dev/null -w '%{http_code}\n' \
https://galaxy.lan/game/ | tee /tmp/game_status
grep -qE '^(200|304)$' /tmp/game_status
+80
View File
@@ -0,0 +1,80 @@
name: Tests · FBS codegen
# Guards that the committed FlatBuffers bindings (Go under
# pkg/schema/fbs/<schema>/ and TS under ui/frontend/src/proto/galaxy/fbs/)
# are exactly what the pinned flatc produces from the .fbs schemas.
# Catches both "changed a schema but forgot to regenerate" and
# "regenerated with the wrong flatc version" (e.g. a distro's older
# flatbuffers-compiler), which silently churns output and can flip
# nullable-scalar wire defaults. Path-filtered so it only runs when the
# schemas, the generated trees, the fbs Makefiles, or this workflow change.
on:
push:
paths:
- 'pkg/schema/fbs/**'
- 'ui/frontend/src/proto/galaxy/fbs/**'
- 'ui/Makefile'
- '.gitea/workflows/fbs-codegen.yaml'
pull_request:
paths:
- 'pkg/schema/fbs/**'
- 'ui/frontend/src/proto/galaxy/fbs/**'
- 'ui/Makefile'
- '.gitea/workflows/fbs-codegen.yaml'
concurrency:
group: fbs-codegen-${{ github.ref }}
cancel-in-progress: true
env:
FLATC_VERSION: 25.9.23
jobs:
codegen:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Cache flatc
id: cache-flatc
uses: actions/cache@v4
with:
path: ${{ runner.temp }}/flatc-bin
key: flatc-${{ env.FLATC_VERSION }}-linux-g++13
- name: Install pinned flatc
if: steps.cache-flatc.outputs.cache-hit != 'true'
run: |
mkdir -p "${{ runner.temp }}/flatc-bin"
curl -sSL -o /tmp/flatc.zip \
"https://github.com/google/flatbuffers/releases/download/v${FLATC_VERSION}/Linux.flatc.binary.g++-13.zip"
python3 -m zipfile -e /tmp/flatc.zip "${{ runner.temp }}/flatc-bin"
chmod +x "${{ runner.temp }}/flatc-bin/flatc"
- name: Add flatc to PATH
run: echo "${{ runner.temp }}/flatc-bin" >> "$GITHUB_PATH"
- name: Verify flatc version
run: flatc --version
- name: Regenerate Go + TS bindings
run: |
make -C pkg/schema/fbs fbs-go
make -C ui fbs-ts
- name: Assert no drift
run: |
if ! git diff --exit-code || [ -n "$(git status --porcelain)" ]; then
echo "::error::Committed FlatBuffers bindings differ from a flatc ${FLATC_VERSION} regeneration."
echo "Run 'make -C pkg/schema/fbs fbs-go' and 'make -C ui fbs-ts' with flatc ${FLATC_VERSION} and commit the result."
git status --porcelain
git --no-pager diff
exit 1
fi
+1 -10
View File
@@ -1,4 +1,4 @@
name: go-unit
name: Tests · Go
# Fast unit tests for the Go side of the monorepo. Runs on every push
# and pull request whose path filter matches a Go source directory.
@@ -30,15 +30,6 @@ on:
- '.gitea/workflows/go-unit.yaml'
- '!**/*.md'
env:
# The Gitea host serves https://gitea.iliadenisov.ru with a cert
# signed by host-Caddy's internal CA. The runner-image's CA bundle
# does not include that root, so actions/checkout fails on `git
# fetch`. Disabling SSL verify is acceptable for this LAN-only
# infrastructure; the long-term fix is to mount the Caddy root CA
# into the runner image.
GIT_SSL_NO_VERIFY: "true"
jobs:
test:
runs-on: ubuntu-latest
+1 -7
View File
@@ -1,4 +1,4 @@
name: integration
name: Tests · Integration
# Full integration suite (testcontainers-driven, ~510 minutes). Heavy
# enough that we do not run it on every push to a feature branch — only
@@ -37,12 +37,6 @@ on:
- '.gitea/workflows/integration.yaml'
- '!**/*.md'
env:
# See go-unit.yaml for the rationale; this disables TLS verify for
# actions/checkout against the LAN Gitea host signed by host-Caddy's
# internal CA.
GIT_SSL_NO_VERIFY: "true"
jobs:
integration:
runs-on: ubuntu-latest
+25 -8
View File
@@ -1,4 +1,4 @@
name: prod-build
name: Build · Prod
# Builds the production-grade Docker images and the UI bundle on every
# merge into `main`, then saves the artifacts so a future
@@ -16,17 +16,12 @@ on:
- 'game/**'
- 'pkg/**'
- 'ui/**'
- 'site/**'
- 'go.work'
- 'go.work.sum'
- '.gitea/workflows/prod-build.yaml'
- '!**/*.md'
env:
# See go-unit.yaml for the rationale; this disables TLS verify for
# actions/checkout against the LAN Gitea host signed by host-Caddy's
# internal CA.
GIT_SSL_NO_VERIFY: "true"
jobs:
build:
runs-on: ubuntu-latest
@@ -49,6 +44,11 @@ jobs:
uses: pnpm/action-setup@v4
with:
version: 11.0.7
# Install pnpm into a per-job directory so concurrent jobs on
# the shared host runner do not race on the default
# `~/setup-pnpm` (the self-installer otherwise fails with
# `ENOTEMPTY` while cleaning a sibling job's install).
dest: ${{ runner.temp }}/setup-pnpm
- name: Set up Node
uses: actions/setup-node@v4
@@ -88,10 +88,17 @@ jobs:
working-directory: ui
run: pnpm install --frozen-lockfile
- name: Build core.wasm
uses: ./.gitea/actions/build-wasm
- name: Build UI bundle
working-directory: ui/frontend
env:
VITE_GATEWAY_BASE_URL: https://api.galaxy.com
# Single-origin deployment: an empty base URL means the
# gateway shares the document origin (REST at /api, Connect at
# /rpc). The game UI is served under the /game/ base path.
VITE_GATEWAY_BASE_URL: ""
BASE_PATH: /game
run: |
# Production response-signing public key is not in the repo
# yet (the dev key in `tools/local-dev/keys/` is for dev
@@ -102,6 +109,14 @@ jobs:
export VITE_GATEWAY_RESPONSE_PUBLIC_KEY="$(grep -E '^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=' .env.development | cut -d= -f2)"
pnpm build
- name: Install site dependencies
working-directory: site
run: pnpm install --frozen-lockfile
- name: Build project site
working-directory: site
run: pnpm build
- name: Save images as artifact bundles
run: |
mkdir -p artifacts
@@ -113,6 +128,8 @@ jobs:
| gzip >"artifacts/game-engine-${{ steps.tag.outputs.tag }}.tar.gz"
tar -C ui/frontend -czf \
"artifacts/ui-dist-${{ steps.tag.outputs.tag }}.tar.gz" build
tar -C site/.vitepress -czf \
"artifacts/site-dist-${{ steps.tag.outputs.tag }}.tar.gz" dist
- name: Upload images
uses: actions/upload-artifact@v4
+47
View File
@@ -0,0 +1,47 @@
name: Build · Site
# Builds the VitePress project site so a broken site change fails its PR.
# The dev-deploy / prod-build workflows build and ship the site
# separately; this is the fast PR gate. No `!**/*.md` exclusion — the
# site is Markdown, so content changes must be exercised too.
on:
push:
paths:
- 'site/**'
- '.gitea/workflows/site-build.yaml'
pull_request:
paths:
- 'site/**'
- '.gitea/workflows/site-build.yaml'
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 11.0.7
dest: ${{ runner.temp }}/setup-pnpm
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: site/pnpm-lock.yaml
- name: Install site dependencies
working-directory: site
run: pnpm install --frozen-lockfile
- name: Build project site
working-directory: site
run: pnpm build
+62 -7
View File
@@ -1,4 +1,4 @@
name: ui-test
name: Tests · UI
# UI-side unit and end-to-end tests (Vitest + Playwright). The Go side
# of the workspace is tested in `go-unit.yaml`. Both workflows can run
@@ -16,11 +16,17 @@ on:
- '.gitea/workflows/ui-test.yaml'
- '!**/*.md'
env:
# See go-unit.yaml for the rationale; this disables TLS verify for
# actions/checkout against the LAN Gitea host signed by host-Caddy's
# internal CA.
GIT_SSL_NO_VERIFY: "true"
# Playwright launches its own `pnpm dev` on :5173, and in host-mode
# the runner shares the host's port namespace with every other job,
# so two parallel ui-test runs collide on EADDRINUSE. Serialise via a
# singleton concurrency group with queueing — new runs wait their
# turn instead of cancelling the in-progress one. cancel-in-progress
# is explicitly false because Gitea has shown spurious self-cancel
# behaviour under cancel-in-progress: true even when no other run
# shares the group.
concurrency:
group: ui-test-singleton
cancel-in-progress: false
jobs:
test:
@@ -38,6 +44,11 @@ jobs:
uses: pnpm/action-setup@v4
with:
version: 11.0.7
# Install pnpm into a per-job directory so concurrent jobs on
# the shared host runner do not race on the default
# `~/setup-pnpm` (the self-installer otherwise fails with
# `ENOTEMPTY` while cleaning a sibling job's install).
dest: ${{ runner.temp }}/setup-pnpm
- name: Set up Node
uses: actions/setup-node@v4
@@ -50,18 +61,49 @@ jobs:
working-directory: ui
run: pnpm install --frozen-lockfile
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.work
cache: true
- name: Build core.wasm
uses: ./.gitea/actions/build-wasm
- name: Install Playwright browsers
# `--with-deps` would shell out to `sudo apt-get install` for
# the system .so libraries, which the host-mode runner cannot
# run non-interactively. The host has the deps installed once,
# globally; we only need to fetch the browser binaries here.
# If a future run fails with missing libraries, install them
# on the host via `pnpm exec playwright install-deps` (one
# shot, requires sudo).
working-directory: ui/frontend
run: pnpm exec playwright install --with-deps
run: pnpm exec playwright install
- name: Run Vitest
working-directory: ui/frontend
run: pnpm test
- name: Clear stale Vite from :5173
# Defence in depth in case a previous job's webServer survived
# the concurrency-cancel — `pkill` does not fail when there is
# nothing to kill, and `fuser -k` cleans up anything else
# holding the port.
run: |
pkill -f 'vite dev' || true
fuser -k 5173/tcp 2>/dev/null || true
- name: Run Playwright
working-directory: ui/frontend
run: pnpm exec playwright test
- name: Run PWA tests
# Builds + previews the production bundle (the service worker only
# precaches a real build) and checks manifest / SW / offline.
working-directory: ui/frontend
run: pnpm test:pwa
- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@v4
@@ -77,3 +119,16 @@ jobs:
name: playwright-traces
path: ui/frontend/test-results/
retention-days: 14
- name: Remove root-owned build artifacts
if: always()
# In host-mode the job runs as root, so vite (test:pwa),
# svelte-kit and Playwright write these outputs root-owned into
# the shared host workspace. The act_runner (non-root) then
# cannot remove them at teardown ("unlinkat ... permission
# denied"), which spuriously fails this or a sibling job that
# inherits the dirty workspace (observed on go-unit). Clean them
# here while the step still has root, after the uploads above.
run: |
rm -rf ui/frontend/build ui/frontend/.svelte-kit \
ui/frontend/test-results ui/frontend/playwright-report
+38 -17
View File
@@ -16,6 +16,12 @@ This repository hosts the Galaxy Game project.
mirrored into `docs/FUNCTIONAL_ru.md` in the same patch (translate
the changed paragraphs only, do not re-translate the whole file).
A full re-translation only happens on explicit owner request.
- `site/ru/rules.md` — the player-facing game rules (ported from the
former `game/rules.txt`). **Russian is authoritative here**, inverting
the usual English-first rule: the game's rules and lore are
Russian-native, so `site/ru/rules.md` leads and the English
`site/rules.md` is its mirror. Mirror point edits the same way as
`docs/FUNCTIONAL.md`, but RU → EN.
- `docs/TESTING.md` — testing layers (unit / integration), the
integration runbook, and the principles every test must follow
(no-op observability for testcontainers, `t.Fatal` on
@@ -44,9 +50,10 @@ Branches:
is manual through `deploy-prod.yaml`.
- `development` — long-lived dev integration branch. Every merge into
it auto-deploys to the dev environment via `dev-deploy.yaml`
(reachable at `https://www.galaxy.lan` / `https://api.galaxy.lan`).
(single origin `https://galaxy.lan`: site at `/`, game at `/game/`,
gateway REST at `/api`).
- `feature/*` — short-lived branches off `development`. Merged back
via PR; only then do they reach the dev environment.
via PR; only then do they reach the dev environment automatically.
Workflows in `.gitea/workflows/`:
@@ -55,10 +62,24 @@ Workflows in `.gitea/workflows/`:
| `go-unit.yaml` | push + PR matching Go paths | Fast Go unit tests. |
| `ui-test.yaml` | push + PR matching `ui/**` | Vitest + Playwright. |
| `integration.yaml` | PR to `development`/`main`; push to `development` | testcontainers integration suite. |
| `dev-deploy.yaml` | push to `development` | Build images + (re)deploy to `tools/dev-deploy/`. |
| `dev-deploy.yaml` | push to `development`; `workflow_dispatch` on any ref | Build images + (re)deploy to `tools/dev-deploy/`. |
| `prod-build.yaml` | push to `main` | Build prod images and `docker save` into artifacts. |
| `deploy-prod.yaml` | `workflow_dispatch` | Manual rollout (placeholder until prod host exists). |
### Deployment cadence
The long-lived dev environment (`tools/dev-deploy/`) is single-tenant:
one live deployment, redeployed on every merge into `development`.
While a PR is open the dev environment stays on whatever was last
merged — pushes to `feature/*` only fire the test workflows
(`go-unit`, `ui-test`, `integration`), not `dev-deploy.yaml`.
To preview an unmerged feature branch on the shared dev environment,
trigger `dev-deploy.yaml` manually from the Gitea UI
(**Actions → Deploy · Dev → Run workflow**) and pick the feature ref.
The deploy is idempotent: the next merge into `development` simply
overwrites whatever the manual dispatch left behind.
## Per-stage CI gate
Every completed stage from any `PLAN.md` (per-service or `ui/PLAN.md`)
@@ -72,10 +93,6 @@ short version:
4. Only after every workflow that fired is `success` may the stage be
marked done in the corresponding `PLAN.md`.
`tools/local-ci/` is now an opt-in fallback for testing workflow
changes without `gitea.lan` (offline iterations, runner-isolation
debugging). It is no longer required for the per-stage gate.
## Decisions during stage implementation
Stages from `PLAN.md` produce decisions. Those decisions never live in a
@@ -102,18 +119,22 @@ The existing codebase of `galaxy/<service>` may be modified or extended when a
plan stage requires it. All such changes must be covered by new or updated tests
and reflected in documentation when they affect documented behavior.
## Pre-production migration rule
## Migrations
The platform is not yet in production. Schema changes for `backend` go
into the existing `backend/internal/postgres/migrations/00001_init.sql`
file rather than into new `00002_*`-prefixed files. Local databases and
integration test harnesses are recreated from scratch on every pull.
Schema changes for `backend` go into a new `0000N_*.sql` file under
`backend/internal/postgres/migrations/` with a monotonically increasing
prefix. `00001_init.sql` is the historical baseline and stays
immutable; every subsequent change is its own additive migration with
matching Up/Down sides. `pressly/goose/v3` (embedded into the backend
binary) applies pending migrations on startup, so the long-lived dev
environment picks up schema deltas without a manual reset.
**This rule is removed before the first production deployment.** From
that point on every schema change becomes a new migration file with a
monotonically increasing prefix, and `00001_init.sql` becomes immutable
history. See `backend/internal/postgres/migrations/README.md` for
details.
Before the first production deployment the migration chain may be
squashed back into a single fresh `00001_init.sql` for a clean slate;
plan that work as an explicit task when it lands. See
`backend/internal/postgres/migrations/README.md` for the local
authoring conventions (file naming, transactional vs. non-transactional
sections, backward-compatible deletes, rollback expectations).
## Documentation discipline
+24 -7
View File
@@ -27,10 +27,16 @@ The implementation specification lives in `PLAN.md`.
| ------------------ | ----------------------------------------------- | ------------------------------------- |
| `/api/v1/public/*` | none | Registration, code confirmation |
| `/api/v1/user/*` | `X-User-ID` injected by gateway | Authenticated end users |
| `/api/v1/admin/*` | HTTP Basic Auth against `admin_accounts` | Platform administrators |
| `/api/v1/admin/*` | HTTP Basic Auth against `admin_accounts` | Platform administrators (JSON) |
| `/_gm`, `/_gm/*` | HTTP Basic Auth against `admin_accounts` | Operator console (server-rendered HTML)|
| `/healthz` | none | Liveness probe |
| `/readyz` | none | Readiness probe |
The `/_gm` operator console is the human-facing surface for the admin
operations; it reuses the admin Basic Auth verifier, renders with
`html/template`, and is the only admin surface exposed publicly (through
the gateway). See `docs/admin-console.md`.
The full contract is documented in `openapi.yaml` and validated at
runtime by the contract tests under `internal/server/`.
@@ -45,6 +51,7 @@ backend/
│ ├── admin/ # admin_accounts, Basic Auth verifier, admin operations
│ ├── auth/ # email-code challenges, device sessions, Ed25519 keys
│ ├── config/ # env-var loader, Validate
│ ├── diplomail/ # diplomatic-mail messages, recipients, translations
│ ├── dockerclient/ # docker/docker wrapper for container ops
│ ├── engineclient/ # net/http client to galaxy-game containers
│ ├── geo/ # geoip lookup, declared_country, per-user counters
@@ -99,6 +106,7 @@ fast.
| `BACKEND_GAME_STATE_ROOT` | yes | — | Host directory bind-mounted into engine containers. |
| `BACKEND_ADMIN_BOOTSTRAP_USER` | no | — | Initial admin username; idempotent insert. |
| `BACKEND_ADMIN_BOOTSTRAP_PASSWORD` | no | — | Initial admin password; required if user is set. |
| `BACKEND_ADMIN_CONSOLE_CSRF_KEY` | no | random per-process | Secret keying the `/_gm` console CSRF token. Set a shared value across replicas; unset uses a per-process random key (forms reset on restart). |
| `BACKEND_GEOIP_DB_PATH` | yes | — | Filesystem path to GeoLite2 Country `.mmdb`. |
| `BACKEND_OTEL_TRACES_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`. |
| `BACKEND_OTEL_METRICS_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`, `prometheus`. |
@@ -128,9 +136,16 @@ fast.
| `BACKEND_RUNTIME_CONTAINER_PIDS_LIMIT` | no | `256` | Engine container `--pids-limit`. |
| `BACKEND_RUNTIME_CONTAINER_STATE_MOUNT` | no | `/var/lib/galaxy-game` | Absolute in-container path for the per-game state bind mount. |
| `BACKEND_RUNTIME_STOP_GRACE_PERIOD` | no | `10s` | SIGTERM-to-SIGKILL grace period for engine container stop. |
| `BACKEND_STACK_LABEL` | no | — | Optional value stamped as `galaxy.stack=<value>` on every engine container backend spawns. Lets host-side tooling (Makefile / CI) scope cleanup to one dev stack. Empty → label is not applied. |
| `BACKEND_NOTIFICATION_ADMIN_EMAIL` | no | — | Recipient address for admin-channel notifications (`runtime.*` kinds). When empty, admin-channel routes are recorded as `skipped` and the catalog is partially silenced. |
| `BACKEND_NOTIFICATION_WORKER_INTERVAL` | no | `5s` | Notification route worker scan interval. |
| `BACKEND_NOTIFICATION_MAX_ATTEMPTS` | no | `8` | Notification route delivery attempts before dead-lettering. |
| `BACKEND_DIPLOMAIL_MAX_BODY_BYTES` | no | `4096` | Maximum size of `diplomail_messages.body` enforced at send time. Tune at runtime without a migration. |
| `BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` | no | `256` | Maximum size of `diplomail_messages.subject`. Subject is optional; empty is always accepted. |
| `BACKEND_DIPLOMAIL_TRANSLATOR_URL` | no | — | Base URL of a LibreTranslate-compatible instance (`http://libretranslate:5000`). Empty → translator falls through to no-op (recipients are delivered with the original body). |
| `BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT` | no | `10s` | Per-request HTTP timeout for the translation worker. |
| `BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS` | no | `5` | Number of failed HTTP attempts before the worker delivers the message with the original body (fallback). |
| `BACKEND_DIPLOMAIL_WORKER_INTERVAL` | no | `2s` | How often the async translation worker scans for pending pairs. The worker processes one pair per tick. |
If `BACKEND_ADMIN_BOOTSTRAP_USER` is set without
`BACKEND_ADMIN_BOOTSTRAP_PASSWORD`, `Validate()` fails. If neither is
@@ -146,10 +161,10 @@ seeded `admin_accounts` ahead of time.
before the HTTP listener opens. The startup path also issues a
`CREATE SCHEMA IF NOT EXISTS backend` so a fresh database does not
trip goose's bookkeeping table on the first migration.
- Pre-production uses one migration file (`00001_init.sql`) covering
every backend domain (auth, user, admin, lobby, runtime, mail,
notification, geo). Future migrations are sequence-numbered and
additive.
- Migrations are sequence-numbered (`0000N_*.sql`) and applied
additively. `00001_init.sql` is the historical baseline; every
schema change after it is a new file with a higher prefix. See
`internal/postgres/migrations/README.md` for the authoring rules.
- Queries are written through `go-jet/jet/v2`. The generated code is in
`internal/postgres/jet/backend/` and is committed; `internal/postgres/jet/jet.go`
carries package metadata that survives regeneration.
@@ -249,11 +264,13 @@ introduce its own request/response types.
Endpoints used:
- `POST /api/v1/admin/init`
- `POST /api/v1/admin/init` — the runtime worker passes the canonical
`game_id` (the same UUID that names the engine container and the
host bind-mount directory) in the request body so the engine's
`state.json` shares identity with the backend's `games.game_id`.
- `GET /api/v1/admin/status`
- `PUT /api/v1/admin/turn`
- `POST /api/v1/admin/race/banish`
- `PUT /api/v1/command`
- `PUT /api/v1/order`
- `GET /api/v1/report`
- `GET /healthz`
+361 -19
View File
@@ -12,6 +12,7 @@ import (
"os"
"os/signal"
"syscall"
"time"
// time/tzdata embeds the IANA timezone database so time.LoadLocation
// works in container images without /usr/share/zoneinfo (distroless
@@ -21,10 +22,13 @@ import (
_ "time/tzdata"
"galaxy/backend/internal/admin"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/app"
"galaxy/backend/internal/auth"
"galaxy/backend/internal/config"
"galaxy/backend/internal/devsandbox"
"galaxy/backend/internal/diplomail"
"galaxy/backend/internal/diplomail/detector"
"galaxy/backend/internal/diplomail/translator"
"galaxy/backend/internal/dockerclient"
"galaxy/backend/internal/engineclient"
"galaxy/backend/internal/geo"
@@ -33,6 +37,7 @@ import (
"galaxy/backend/internal/mail"
"galaxy/backend/internal/metricsapi"
"galaxy/backend/internal/notification"
"galaxy/backend/internal/opsstatus"
backendpostgres "galaxy/backend/internal/postgres"
"galaxy/backend/push"
"galaxy/backend/internal/runtime"
@@ -131,6 +136,7 @@ func run(ctx context.Context) (err error) {
lobbyCascade := &lobbyCascadeAdapter{}
userNotifyCascade := &userNotificationCascadeAdapter{}
lobbyNotifyPublisher := &lobbyNotificationPublisherAdapter{}
lobbyDiplomailPublisher := &lobbyDiplomailPublisherAdapter{}
runtimeNotifyPublisher := &runtimeNotificationPublisherAdapter{}
userSvc := user.NewService(user.Deps{
@@ -197,6 +203,7 @@ func run(ctx context.Context) (err error) {
Cache: lobbyCache,
Runtime: runtimeGateway,
Notification: lobbyNotifyPublisher,
Diplomail: lobbyDiplomailPublisher,
Entitlement: &userEntitlementAdapter{svc: userSvc},
Config: cfg.Lobby,
Logger: logger,
@@ -266,29 +273,18 @@ func run(ctx context.Context) (err error) {
)
runtimeGateway.svc = runtimeSvc
// Run a single reconciliation pass before the dev-sandbox
// bootstrap so any runtime row pointing at a vanished engine
// container (host reboot wiped /tmp/galaxy-game-state/<uuid>;
// `tools/local-dev`'s `prune-broken-engines` target reaped the
// husk) is already cascaded through `markRemoved` → lobby
// `cancelled` by the time the bootstrap walks the sandbox list.
// Without this pre-tick the bootstrap would reuse the
// soon-to-be-cancelled game and force the developer into a
// second `make up` cycle to land a healthy sandbox. Failures are
// Run a single reconciliation pass at startup so any runtime row
// pointing at a vanished engine container (a host reboot wiped
// /tmp/galaxy-game-state/<uuid>; `tools/local-dev`'s
// `prune-broken-engines` target reaped the husk) is cascaded
// through `markRemoved` → lobby `cancelled` before the server
// starts serving requests. Failures are
// non-fatal: the periodic ticker started later catches up, and
// the worst case degrades to the legacy two-cycle recovery.
if err := runtimeSvc.Reconciler().Tick(ctx); err != nil {
logger.Warn("pre-bootstrap reconciler tick failed", zap.Error(err))
}
if err := devsandbox.Bootstrap(ctx, devsandbox.Deps{
Users: userSvc,
Lobby: lobbySvc,
EngineVersions: engineVersionSvc,
}, cfg.DevSandbox, logger); err != nil {
return fmt.Errorf("dev sandbox bootstrap: %w", err)
}
notifStore := notification.NewStore(db)
notifSvc := notification.NewService(notification.Deps{
Store: notifStore,
@@ -301,6 +297,25 @@ func run(ctx context.Context) (err error) {
userNotifyCascade.svc = notifSvc
lobbyNotifyPublisher.svc = notifSvc
runtimeNotifyPublisher.svc = notifSvc
diplomailStore := diplomail.NewStore(db)
diplomailTranslator, err := buildDiplomailTranslator(cfg.Diplomail, logger)
if err != nil {
return fmt.Errorf("build diplomail translator: %w", err)
}
diplomailSvc := diplomail.NewService(diplomail.Deps{
Store: diplomailStore,
Memberships: &diplomailMembershipAdapter{lobby: lobbySvc, users: userSvc},
Notification: &diplomailNotificationPublisherAdapter{svc: notifSvc},
Entitlements: &diplomailEntitlementAdapter{users: userSvc},
Games: &diplomailGameAdapter{lobby: lobbySvc},
Detector: detector.New(),
Translator: diplomailTranslator,
Config: cfg.Diplomail,
Logger: logger,
})
lobbyDiplomailPublisher.svc = diplomailSvc
diplomailWorker := diplomail.NewWorker(diplomailSvc)
if email := cfg.Notification.AdminEmail; email == "" {
logger.Info("notification admin email not configured (BACKEND_NOTIFICATION_ADMIN_EMAIL); admin-channel routes will be skipped")
} else {
@@ -325,14 +340,42 @@ func run(ctx context.Context) (err error) {
adminEngineVersionsHandlers := backendserver.NewAdminEngineVersionsHandlers(engineVersionSvc, logger)
adminRuntimesHandlers := backendserver.NewAdminRuntimesHandlers(runtimeSvc, logger)
adminMailHandlers := backendserver.NewAdminMailHandlers(mailSvc, logger)
adminDiplomailHandlers := backendserver.NewAdminDiplomailHandlers(diplomailSvc, logger)
adminNotificationsHandlers := backendserver.NewAdminNotificationsHandlers(notifSvc, logger)
adminGeoHandlers := backendserver.NewAdminGeoHandlers(geoSvc, logger)
userGamesHandlers := backendserver.NewUserGamesHandlers(runtimeSvc, engineCli, logger)
userMailHandlers := backendserver.NewUserMailHandlers(diplomailSvc, lobbySvc, userSvc, logger)
ready := func() bool {
return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready()
}
var consoleCSRF *adminconsole.CSRF
if cfg.AdminConsole.CSRFKey != "" {
consoleCSRF = adminconsole.NewCSRF([]byte(cfg.AdminConsole.CSRFKey))
} else {
consoleCSRF, err = adminconsole.NewRandomCSRF()
if err != nil {
return fmt.Errorf("init admin console CSRF: %w", err)
}
logger.Warn("admin console CSRF key not set; using a per-process random key (forms reset on restart, not valid across replicas)",
zap.String("env", "BACKEND_ADMIN_CONSOLE_CSRF_KEY"))
}
adminConsoleHandlers := backendserver.NewAdminConsoleHandlers(backendserver.AdminConsoleDeps{
CSRF: consoleCSRF,
Monitor: opsstatus.NewStore(db),
Ready: ready,
Users: userSvc,
Games: lobbySvc,
Runtime: runtimeSvc,
EngineVersions: engineVersionSvc,
Operators: adminSvc,
Mail: mailSvc,
Notifications: notifSvc,
Diplomail: diplomailSvc,
Logger: logger,
})
handler, err := backendserver.NewRouter(backendserver.RouterDependencies{
Logger: logger,
Telemetry: telemetryRT,
@@ -356,9 +399,12 @@ func run(ctx context.Context) (err error) {
AdminRuntimes: adminRuntimesHandlers,
AdminEngineVersions: adminEngineVersionsHandlers,
AdminMail: adminMailHandlers,
AdminDiplomail: adminDiplomailHandlers,
AdminNotifications: adminNotificationsHandlers,
AdminGeo: adminGeoHandlers,
UserGames: userGamesHandlers,
UserMail: userMailHandlers,
AdminConsole: adminConsoleHandlers,
})
if err != nil {
return fmt.Errorf("build backend router: %w", err)
@@ -374,7 +420,7 @@ func run(ctx context.Context) (err error) {
runtimeScheduler := runtimeSvc.SchedulerComponent()
runtimeReconciler := runtimeSvc.Reconciler()
components := []app.Component{httpServer, pushServer, mailWorker, notifWorker, lobbySweeper, runtimeWorkers, runtimeScheduler, runtimeReconciler}
components := []app.Component{httpServer, pushServer, mailWorker, notifWorker, diplomailWorker, lobbySweeper, runtimeWorkers, runtimeScheduler, runtimeReconciler}
if metricsServer.Enabled() {
components = append(components, metricsServer)
}
@@ -456,6 +502,17 @@ func (a *userEntitlementAdapter) GetMaxRegisteredRaceNames(ctx context.Context,
return snap.MaxRegisteredRaceNames, nil
}
func (a *userEntitlementAdapter) IsPaid(ctx context.Context, userID uuid.UUID) (bool, error) {
if a == nil || a.svc == nil {
return false, nil
}
snap, err := a.svc.GetEntitlementSnapshot(ctx, userID)
if err != nil {
return false, err
}
return snap.IsPaid, nil
}
// runtimeGatewayAdapter implements `lobby.RuntimeGateway` by
// delegating to `*runtime.Service`. The svc pointer is patched after
// the services are constructed — runtime depends on lobby
@@ -579,3 +636,288 @@ func (a *runtimeNotificationPublisherAdapter) PublishRuntimeEvent(ctx context.Co
}
return a.svc.RuntimeAdapter().PublishRuntimeEvent(ctx, kind, idempotencyKey, payload)
}
// diplomailMembershipAdapter implements `diplomail.MembershipLookup`
// by walking the lobby cache (for active rows) and the lobby service
// (for any-status rows) and stitching each membership row to the
// immutable `accounts.user_name` resolved through `*user.Service`.
type diplomailMembershipAdapter struct {
lobby *lobby.Service
users *user.Service
}
func (a *diplomailMembershipAdapter) GetActiveMembership(ctx context.Context, gameID, userID uuid.UUID) (diplomail.ActiveMembership, error) {
if a == nil || a.lobby == nil || a.users == nil {
return diplomail.ActiveMembership{}, diplomail.ErrNotFound
}
cache := a.lobby.Cache()
if cache == nil {
return diplomail.ActiveMembership{}, diplomail.ErrNotFound
}
game, ok := cache.GetGame(gameID)
if !ok {
return diplomail.ActiveMembership{}, diplomail.ErrNotFound
}
var found *lobby.Membership
for _, m := range cache.MembershipsForGame(gameID) {
if m.UserID == userID {
mm := m
found = &mm
break
}
}
if found == nil {
return diplomail.ActiveMembership{}, diplomail.ErrNotFound
}
account, err := a.users.GetAccount(ctx, userID)
if err != nil {
return diplomail.ActiveMembership{}, err
}
return diplomail.ActiveMembership{
UserID: userID,
GameID: gameID,
GameName: game.GameName,
UserName: account.UserName,
RaceName: found.RaceName,
PreferredLanguage: account.PreferredLanguage,
}, nil
}
func (a *diplomailMembershipAdapter) GetMembershipAnyStatus(ctx context.Context, gameID, userID uuid.UUID) (diplomail.MemberSnapshot, error) {
if a == nil || a.lobby == nil || a.users == nil {
return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
}
game, ok := a.lobby.Cache().GetGame(gameID)
if !ok {
return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
}
members, err := a.lobby.ListMembershipsForGame(ctx, gameID)
if err != nil {
return diplomail.MemberSnapshot{}, err
}
var found *lobby.Membership
for _, m := range members {
if m.UserID == userID {
mm := m
found = &mm
break
}
}
if found == nil {
return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
}
account, err := a.users.GetAccount(ctx, userID)
if err != nil {
return diplomail.MemberSnapshot{}, err
}
return diplomail.MemberSnapshot{
UserID: userID,
GameID: gameID,
GameName: game.GameName,
UserName: account.UserName,
RaceName: found.RaceName,
Status: found.Status,
PreferredLanguage: account.PreferredLanguage,
}, nil
}
func (a *diplomailMembershipAdapter) ListMembers(ctx context.Context, gameID uuid.UUID, scope string) ([]diplomail.MemberSnapshot, error) {
if a == nil || a.lobby == nil || a.users == nil {
return nil, diplomail.ErrNotFound
}
game, ok := a.lobby.Cache().GetGame(gameID)
if !ok {
return nil, diplomail.ErrNotFound
}
members, err := a.lobby.ListMembershipsForGame(ctx, gameID)
if err != nil {
return nil, err
}
matches := func(status string) bool {
switch scope {
case diplomail.RecipientScopeActive:
return status == lobby.MembershipStatusActive
case diplomail.RecipientScopeActiveAndRemoved:
return status == lobby.MembershipStatusActive || status == lobby.MembershipStatusRemoved
case diplomail.RecipientScopeAllMembers:
return true
default:
return status == lobby.MembershipStatusActive
}
}
out := make([]diplomail.MemberSnapshot, 0, len(members))
for _, m := range members {
if !matches(m.Status) {
continue
}
account, err := a.users.GetAccount(ctx, m.UserID)
if err != nil {
return nil, fmt.Errorf("resolve user_name for %s: %w", m.UserID, err)
}
out = append(out, diplomail.MemberSnapshot{
UserID: m.UserID,
GameID: gameID,
GameName: game.GameName,
UserName: account.UserName,
RaceName: m.RaceName,
Status: m.Status,
PreferredLanguage: account.PreferredLanguage,
})
}
return out, nil
}
// lobbyDiplomailPublisherAdapter implements `lobby.DiplomailPublisher`
// by translating each lobby.LifecycleEvent into the diplomail
// vocabulary and delegating to `*diplomail.Service.PublishLifecycle`.
// The svc pointer is patched once diplomailSvc exists — diplomail
// depends on lobby through MembershipLookup, so the lobby service
// is constructed first and patched up.
type lobbyDiplomailPublisherAdapter struct {
svc *diplomail.Service
}
func (a *lobbyDiplomailPublisherAdapter) PublishLifecycle(ctx context.Context, ev lobby.LifecycleEvent) error {
if a == nil || a.svc == nil {
return nil
}
return a.svc.PublishLifecycle(ctx, diplomail.LifecycleEvent{
GameID: ev.GameID,
Kind: ev.Kind,
Actor: ev.Actor,
Reason: ev.Reason,
TargetUser: ev.TargetUser,
})
}
// buildDiplomailTranslator selects the diplomail translator backend
// from configuration: a non-empty `TranslatorURL` constructs the
// LibreTranslate HTTP client; an empty URL falls through to the
// noop translator so deployments without a translation service still
// boot and deliver mail with the fallback path.
func buildDiplomailTranslator(cfg config.DiplomailConfig, logger *zap.Logger) (translator.Translator, error) {
if cfg.TranslatorURL == "" {
logger.Info("diplomail translator URL not configured, using noop translator")
return translator.NewNoop(), nil
}
return translator.NewLibreTranslate(translator.LibreTranslateConfig{
URL: cfg.TranslatorURL,
Timeout: cfg.TranslatorTimeout,
})
}
// diplomailEntitlementAdapter implements
// `diplomail.EntitlementReader` by reading the user-service
// entitlement snapshot. The IsPaid flag mirrors the per-tier policy
// defined in `internal/user`, so updates to the tier set (monthly,
// yearly, permanent, …) flow through without changes here.
type diplomailEntitlementAdapter struct {
users *user.Service
}
func (a *diplomailEntitlementAdapter) IsPaidTier(ctx context.Context, userID uuid.UUID) (bool, error) {
if a == nil || a.users == nil {
return false, nil
}
snap, err := a.users.GetEntitlementSnapshot(ctx, userID)
if err != nil {
return false, err
}
return snap.IsPaid, nil
}
// diplomailGameAdapter implements `diplomail.GameLookup`. The
// running-games and finished-games queries walk the lobby cache so
// the admin multi-game broadcast and bulk-purge endpoints do not
// fan out a per-game DB query each time. GetGame falls back to the
// cache; an unknown id is surfaced as ErrNotFound (the diplomail
// sentinel).
type diplomailGameAdapter struct {
lobby *lobby.Service
}
func (a *diplomailGameAdapter) ListRunningGames(_ context.Context) ([]diplomail.GameSnapshot, error) {
if a == nil || a.lobby == nil || a.lobby.Cache() == nil {
return nil, nil
}
var out []diplomail.GameSnapshot
for _, game := range a.lobby.Cache().ListGames() {
if !isRunningStatus(game.Status) {
continue
}
out = append(out, gameSnapshot(game))
}
return out, nil
}
func (a *diplomailGameAdapter) ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]diplomail.GameSnapshot, error) {
if a == nil || a.lobby == nil {
return nil, nil
}
games, err := a.lobby.ListFinishedGamesBefore(ctx, cutoff)
if err != nil {
return nil, err
}
out := make([]diplomail.GameSnapshot, 0, len(games))
for _, g := range games {
out = append(out, gameSnapshot(g))
}
return out, nil
}
func (a *diplomailGameAdapter) GetGame(_ context.Context, gameID uuid.UUID) (diplomail.GameSnapshot, error) {
if a == nil || a.lobby == nil || a.lobby.Cache() == nil {
return diplomail.GameSnapshot{}, diplomail.ErrNotFound
}
game, ok := a.lobby.Cache().GetGame(gameID)
if !ok {
return diplomail.GameSnapshot{}, diplomail.ErrNotFound
}
return gameSnapshot(game), nil
}
func gameSnapshot(g lobby.GameRecord) diplomail.GameSnapshot {
out := diplomail.GameSnapshot{
GameID: g.GameID,
GameName: g.GameName,
Status: g.Status,
}
if g.FinishedAt != nil {
f := *g.FinishedAt
out.FinishedAt = &f
}
return out
}
func isRunningStatus(status string) bool {
switch status {
case lobby.GameStatusReadyToStart, lobby.GameStatusStarting, lobby.GameStatusRunning, lobby.GameStatusPaused:
return true
default:
return false
}
}
// diplomailNotificationPublisherAdapter implements
// `diplomail.NotificationPublisher` by translating each
// DiplomailNotification into a notification.Intent and routing it
// through `*notification.Service.Submit`. The publisher leaves the
// `diplomail.message.received` catalog entry to handle channel
// fan-out (push only in Stage A).
type diplomailNotificationPublisherAdapter struct {
svc *notification.Service
}
func (a *diplomailNotificationPublisherAdapter) PublishDiplomailEvent(ctx context.Context, ev diplomail.DiplomailNotification) error {
if a == nil || a.svc == nil {
return nil
}
intent := notification.Intent{
Kind: ev.Kind,
IdempotencyKey: ev.IdempotencyKey,
Recipients: []uuid.UUID{ev.Recipient},
Payload: ev.Payload,
}
_, err := a.svc.Submit(ctx, intent)
return err
}
+128
View File
@@ -0,0 +1,128 @@
# Operator console (`/_gm`)
The operator console is a server-rendered web UI for the platform's admin
operations. It is the human-facing counterpart to the JSON admin API under
`/api/v1/admin/*`: both call the same service layer, but the console renders
HTML pages an operator drives in a browser, while the JSON API stays internal
to the deployment for programmatic and test use.
## Design choices
- **Server-rendered, no client framework.** Pages are rendered with the
standard library's `html/template`. Navigation is by ordinary links and
query parameters; every state change is an HTML form `POST` answered with a
Post/Redirect/Get redirect. There is no build step, no JavaScript framework,
and no separate asset pipeline — a single embedded stylesheet under
`/_gm/assets/`.
- **Reuses the existing admin auth.** The console mounts behind the same
`basicauth.Middleware(admin.Service)` verifier that gates `/api/v1/admin/*`,
so there is one credential store (`admin_accounts`, bcrypt-12) and no second
secret to manage.
- **Lives in the backend.** The backend owns the admin domain and the data, so
rendering there lets the console call the service layer directly. The gateway
stays a thin proxy.
## Request path
```
Browser ── /_gm/* ──► edge Caddy ──► gateway (public listener)
gateway: anti-abuse `admin` class (per-IP rate limit, body + method limits)
└─► reverse proxy ──► backend /_gm/*
backend: basicauth.Middleware(admin.Service)
└─► CSRF guard (state-changing methods)
└─► console handler ──► admin service layer ──► html/template
```
The gateway preserves the inbound `Host` and relays the backend's `401` Basic
Auth challenge unchanged, so the browser shows its native credential dialog.
The gateway adds only the edge anti-abuse layer; authentication and every state
change are enforced by the backend. The gateway answers `502` when the backend
is unreachable. See the gateway README "Operator Console Proxy" section for the
`admin` route-class env vars.
## Components (package `internal/adminconsole`)
The package is framework-agnostic (no gin) so it unit-tests in isolation:
- `Renderer` — parses the embedded layout plus one content page per route and
renders a named page wrapped in the shared layout. Rendering goes through an
intermediate buffer, so a template failure never emits a partial document.
- `CSRF` — issues and verifies the stateless anti-CSRF token: HMAC-SHA256 over
the authenticated username, keyed by `BACKEND_ADMIN_CONSOLE_CSRF_KEY`. When
the key is unset a per-process random key is used (secure, but forms reset on
restart and do not validate across replicas — set a shared key for
multi-replica deployments).
- `Assets` — the embedded stylesheet filesystem served under `/_gm/assets/`.
The gin glue (route group, Basic Auth, the CSRF guard middleware, the per-page
handlers) lives in `internal/server/handlers_admin_console.go` and
`internal/server/router.go` (`registerAdminConsoleRoutes`).
## CSRF protection
Because the console is sessionless (HTTP Basic Auth, whose credentials the
browser replays automatically), state-changing requests are double-guarded:
1. A stateless per-operator token (`_csrf` form field) that a cross-site page
cannot read or forge.
2. A same-origin `Origin`/`Referer` check (when the browser sends one), which
relies on the gateway preserving the inbound `Host`.
Safe methods (`GET`/`HEAD`/`OPTIONS`) pass without a token.
## Monitoring
The dashboard is the console landing page. It surfaces backend-visible
operational state — service health, game-runtime status, and queue depths —
read through the existing service and persistence layers. Richer cross-service
metrics are out of scope for the console itself: the `/metrics` Prometheus
exporters on `backend` and `gateway` are wired and enabled in the dev
deployment so a future Prometheus + Grafana stack can scrape them without code
changes.
## Pages
| Path | Method | Purpose |
| --------------------------------- | -------- | -------------------------------------------------------------- |
| `/_gm`, `/_gm/` | GET | Dashboard: health, runtime/mail/notification status, queues. |
| `/_gm/assets/*` | GET | Embedded stylesheet. |
| `/_gm/users` | GET | Paginated account list. |
| `/_gm/users/{id}` | GET | Account detail: profile, entitlement, active sanctions. |
| `/_gm/users/{id}/block` | POST | Apply a permanent block (reason required). |
| `/_gm/users/{id}/entitlement` | POST | Set the entitlement tier. |
| `/_gm/users/{id}/soft-delete` | POST | Soft-delete the account (cascades). |
| `/_gm/games` | GET/POST | Paginated game list; POST creates a public game. |
| `/_gm/games/{id}` | GET | Game detail with the runtime snapshot. |
| `/_gm/games/{id}/force-start` | POST | Force-start the game. |
| `/_gm/games/{id}/force-stop` | POST | Force-stop the game. |
| `/_gm/games/{id}/ban-member` | POST | Ban a member (user id + reason). |
| `/_gm/games/{id}/runtime/restart` | POST | Restart the engine container. |
| `/_gm/games/{id}/runtime/patch` | POST | Patch the runtime to a target version. |
| `/_gm/games/{id}/runtime/force-next-turn` | POST | Force the next turn now. |
| `/_gm/engine-versions` | GET/POST | Version registry; POST registers a version. |
| `/_gm/engine-versions/{ver}/disable` | POST | Disable a registered version. |
| `/_gm/operators` | GET/POST | Admin-account list; POST creates an operator. |
| `/_gm/operators/{user}/disable` | POST | Disable an operator. |
| `/_gm/operators/{user}/enable` | POST | Re-enable an operator. |
| `/_gm/operators/{user}/reset-password` | POST | Reset an operator's password. |
| `/_gm/mail` | GET | Mail deliveries (paginated) + a dead-letter snapshot. |
| `/_gm/mail/deliveries/{id}` | GET | Delivery detail with its attempts. |
| `/_gm/mail/deliveries/{id}/resend`| POST | Re-enqueue a non-sent delivery. |
| `/_gm/notifications` | GET | Notifications, dead-letters, and malformed intents overview. |
| `/_gm/broadcast` | GET/POST | Admin multi-game diplomatic broadcast. |
Each page reuses the same service layer as the corresponding `/api/v1/admin/*`
JSON endpoint; the console adds no business logic. Collection-mutating POSTs are
mounted on the collection path (`POST /_gm/games`, `POST /_gm/engine-versions`)
so a static action segment never collides with a path parameter in the gin
router. Unblocking a user is not yet available because the JSON admin API
exposes no remove-sanction endpoint.
## Configuration
| Variable | Where | Notes |
| --------------------------------- | ------- | ------------------------------------------------------------ |
| `BACKEND_ADMIN_CONSOLE_CSRF_KEY` | backend | CSRF token key; unset → per-process random key. |
| `BACKEND_ADMIN_BOOTSTRAP_USER` | backend | Bootstrap operator account (shared with the JSON admin API). |
| `BACKEND_ADMIN_BOOTSTRAP_PASSWORD`| backend | Bootstrap operator password. |
| `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_*` | gateway | `admin` route-class rate-limit and body budgets. |
+164
View File
@@ -0,0 +1,164 @@
# LibreTranslate setup for diplomatic mail
This document describes how to run the LibreTranslate backend that the
diplomatic-mail subsystem uses for body translation. The instructions
target three audiences: developers spinning up LibreTranslate
alongside `tools/local-dev`, operators preparing a real deployment,
and reviewers verifying the end-to-end translation flow by hand.
## When you need LibreTranslate
The diplomatic-mail worker runs unconditionally — `make up` and `make
test` both work without any translator. With
`BACKEND_DIPLOMAIL_TRANSLATOR_URL` unset, the noop translator
short-circuits the pipeline: messages are delivered in the original
language, and the inbox handler returns the original body to every
reader.
You only need LibreTranslate when you want to exercise the cross-
language path: sender writes in language X, recipient's
`accounts.preferred_language` is Y, the worker is expected to fetch
a Y rendering. The pipeline is otherwise identical and unaware of
which engine is producing translations.
## Running a local instance
LibreTranslate ships a public Docker image at
`libretranslate/libretranslate`. The image is ~3 GB on first pull
because it bundles every supported language model; subsequent runs
reuse the layer cache.
The simplest setup is a one-shot container:
```bash
docker run --rm -d --name libretranslate \
-p 5000:5000 \
-e LT_LOAD_ONLY=en,ru \
libretranslate/libretranslate:latest
```
The `LT_LOAD_ONLY` whitelist trims the loaded model set so the
container fits in ~600 MB of RAM. Drop the variable to load every
language pair LibreTranslate ships.
LibreTranslate boots in ~30 seconds (cold) or ~5 seconds (warm
model cache). Wait until `curl -s http://localhost:5000/languages`
returns a JSON array before pointing backend at it.
## Wiring backend at it
Add three env vars to the backend process:
```
BACKEND_DIPLOMAIL_TRANSLATOR_URL=http://localhost:5000
BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT=10s
BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS=5
```
When backend lives inside the `tools/local-dev` Docker network and
LibreTranslate runs on the host, replace `localhost` with the host's
docker-bridge address (`http://host.docker.internal:5000` on
Docker Desktop; `http://172.17.0.1:5000` on a Linux bridge by
default).
For a stack-internal deployment, drop LibreTranslate into the same
Docker compose file alongside backend and reach it by its service
name:
```yaml
services:
libretranslate:
image: libretranslate/libretranslate:latest
environment:
LT_LOAD_ONLY: "en,ru"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:5000/languages"]
interval: 5s
timeout: 2s
retries: 12
backend:
environment:
BACKEND_DIPLOMAIL_TRANSLATOR_URL: "http://libretranslate:5000"
depends_on:
libretranslate:
condition: service_healthy
```
## Manual smoke test
Once both services are up:
1. Register two accounts via the public auth flow. Set the second
account's `preferred_language` to a value that differs from the
sender's writing language (e.g. sender writes in English, second
account is `ru`).
2. Create a private game with the first account, invite the second,
land both as active members.
3. Send a personal message: `POST /api/v1/user/games/{id}/mail/messages`
with the body in English.
4. Watch backend logs for the diplomail worker. After ~2 seconds you
should see `translator attempt succeeded` (or equivalent INFO
line) and the recipient flipped to `available_at`.
5. As the second account, fetch
`GET /api/v1/user/games/{id}/mail/messages/{message_id}`. The
response should carry both `body` (English original) and
`translated_body` (Russian) along with the `translation_lang`
and `translator` fields.
## Operational notes
- **Resource budget.** With `LT_LOAD_ONLY=en,ru` the container peaks
around 800 MB resident; with all languages, ~3 GB. Plan accordingly.
- **CPU.** LibreTranslate is CPU-bound. One translation of a 200-
word body takes ~200 ms on a modern x86 core; the diplomail worker
is single-threaded by design, so steady-state throughput is
`1 / avg_latency` per backend instance.
- **Outage behaviour.** A LibreTranslate outage stalls delivery of
pending pairs by at most ~31 seconds per pair (the worker's
exponential backoff schedule), then falls back to the original
body. Inbox listings never depend on the translator's
availability.
- **API key.** Backend does not send an API key. Self-hosted
deployments without `LT_API_KEYS` configured accept anonymous
POSTs by default, which matches our deployment posture
(LibreTranslate sits on the internal docker network, not
reachable from outside).
- **Models.** Adding a new target language is an operator-side
task: install the corresponding Argos model into the
LibreTranslate container (`argospm install …`) and either restart
the container or send a SIGHUP. The diplomail pipeline notices
the new language pair automatically — there is no allow-list
inside backend.
## Troubleshooting
- **`translator: do request: dial tcp ...: connect: connection refused`.**
LibreTranslate is not listening on the configured address. Verify
with `curl http://${URL}/languages`. On Docker setups, double-
check the bridge address discussion above.
- **`translator: libretranslate http 400`** in worker logs but the
language pair clearly exists.
Make sure the request used the two-letter codes (`en`, not
`en-US`). Backend normalises before sending; if you see a region
subtag in the log, file an issue against `internal/diplomail`
the normalisation should be unconditional.
- **`translator: libretranslate http 503`.**
Container is still loading models. Wait for `/languages` to
respond `200`. The worker retries with backoff, so steady-state
recovers automatically.
- **Worker logs only "noop translator returned, delivering
fallback".**
`BACKEND_DIPLOMAIL_TRANSLATOR_URL` is empty in the backend
process. Confirm with `docker compose exec backend env | grep
DIPLOMAIL`.
## Future work
- Adding an OpenTelemetry counter and histogram for translator
outcomes is tracked in the diplomail package README; the metrics
will surface in Grafana once LibreTranslate is deployed.
- Email-alerting on prolonged outage (e.g. ≥ N consecutive failures
in M minutes) is planned through a new
`diplomail.translator.unhealthy` notification kind. Not wired
yet — current monitoring lives in zap logs.
+2 -2
View File
@@ -234,8 +234,8 @@ sequenceDiagram
Workers->>Docker: pull / create / start engine container
Docker-->>Workers: container id
Workers->>Engine: POST /api/v1/admin/init
Engine-->>Workers: ok / error
Workers->>Engine: POST /api/v1/admin/init {gameId, races}
Engine-->>Workers: StateResponse{id == gameId} / error
Workers->>Runtime: write runtime_records (running or start_failed)
Workers->>Lobby: OnRuntimeJobResult
+5 -4
View File
@@ -28,10 +28,11 @@ test stack. The list mirrors the steady-state behaviour documented in
## Migrations
`pressly/goose/v3` applies embedded migrations from
`internal/postgres/migrations/`. The pre-production set ships as
`00001_init.sql` plus additive numbered files. Backend always runs
`CREATE SCHEMA IF NOT EXISTS backend` before goose so a fresh database
does not trip the bookkeeping table on the first migration.
`internal/postgres/migrations/`. Migrations are additive,
sequence-numbered files (`00001_init.sql` is the baseline). Backend
always runs `CREATE SCHEMA IF NOT EXISTS backend` before goose so a
fresh database does not trip the bookkeeping table on the first
migration.
`internal/postgres/migrations_test.go` asserts that the migration
produces the expected table set; adding a table without updating the
+4 -1
View File
@@ -141,7 +141,10 @@ boot).
polls the engine `/healthz` until the listener is bound (Docker
marks a container running as soon as the entrypoint starts; the
Go binary inside takes a moment to bind its TCP port). Only after
`/healthz` succeeds does the worker call `/admin/init`.
`/healthz` succeeds does the worker call `/admin/init`, passing the
same `game_id` the backend uses to mount the engine's storage
directory; the engine echoes it back in `StateResponse.id`. The
engine rejects a mismatched gameId with `409 Conflict`.
- **Runtime scheduler** (`internal/runtime.SchedulerComponent`) —
`pkg/cronutil` schedule per running game; each tick invokes the
engine `admin/turn`. Force-next-turn flips a one-shot skip flag in
+1
View File
@@ -7,6 +7,7 @@ require (
galaxy/model v0.0.0
galaxy/postgres v0.0.0
galaxy/util v0.0.0-00010101000000-000000000000
github.com/abadojack/whatlanggo v1.0.1
github.com/disciplinedware/go-confusables v0.1.1
github.com/getkin/kin-openapi v0.135.0
github.com/gin-gonic/gin v1.12.0
+2
View File
@@ -10,6 +10,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/XSAM/otelsql v0.42.0 h1:Li0xF4eJUxG2e0x3D4rvRlys1f27yJKvjTh7ljkUP5o=
github.com/XSAM/otelsql v0.42.0/go.mod h1:4mOrEv+cS1KmKzrvTktvJnstr5GtKSAK+QHvFR9OcpI=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
@@ -0,0 +1,103 @@
/* Admin console stylesheet. Deliberately small and dependency-free: the
console is an internal operator tool, not a public surface. */
:root {
--bg: #11151c;
--panel: #1b2230;
--panel-hi: #232c3d;
--ink: #e6ebf2;
--ink-dim: #9aa7ba;
--line: #2c3850;
--accent: #5aa9ff;
--danger: #ff6b6b;
--ok: #4ecb8d;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 15px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.topbar {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.6rem 1.2rem;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
.topbar .brand { font-weight: 700; letter-spacing: 0.04em; }
.topbar .mainnav { display: flex; gap: 1rem; flex: 1; }
.topbar .mainnav a.active { color: var(--ink); border-bottom: 2px solid var(--accent); }
.topbar .who { color: var(--ink-dim); }
.content { padding: 1.5rem; max-width: 1100px; margin: 0 auto; }
h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
.lede { color: var(--ink-dim); margin-top: 0; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-top: 1.5rem; }
.card {
display: block;
padding: 1rem 1.2rem;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
color: var(--ink);
}
.card:hover { background: var(--panel-hi); text-decoration: none; }
.card h2 { font-size: 1.05rem; margin: 0 0 0.3rem; color: var(--accent); }
.card p { margin: 0; color: var(--ink-dim); font-size: 0.9rem; }
.panel {
padding: 0.9rem 1.1rem;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
margin-bottom: 1rem;
}
.panel h2 { font-size: 1rem; margin: 0 0 0.6rem; color: var(--ink); }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
.grid .panel { margin-bottom: 0; }
.kv { list-style: none; margin: 0; padding: 0; }
.kv li { padding: 0.15rem 0; color: var(--ink-dim); }
.counts { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
.counts td { padding: 0.2rem 0; border-bottom: 1px solid var(--line); color: var(--ink-dim); }
.counts td.num { text-align: right; color: var(--ink); font-variant-numeric: tabular-nums; }
.bignum { font-size: 1.6rem; margin: 0; color: var(--ink); }
.note { color: var(--ink-dim); font-style: italic; margin: 0.2rem 0; }
.errors { border-color: var(--danger); }
.errors ul { margin: 0; padding-left: 1.1rem; color: var(--danger); }
.ok { color: var(--ok); }
.bad { color: var(--danger); }
.list { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin-bottom: 1rem; }
.list th, .list td { text-align: left; padding: 0.35rem 0.6rem; border-bottom: 1px solid var(--line); }
.list th { color: var(--ink-dim); font-weight: 600; }
.list tr:hover td { background: var(--panel-hi); }
.pager { display: flex; gap: 1rem; align-items: center; color: var(--ink-dim); }
.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.8rem; }
.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
.form input, .form select {
background: var(--bg);
color: var(--ink);
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.35rem 0.5rem;
font: inherit;
}
button {
background: var(--accent);
color: #06121f;
border: 0;
border-radius: 6px;
padding: 0.4rem 0.9rem;
font: inherit;
font-weight: 600;
cursor: pointer;
}
button:hover { filter: brightness(1.1); }
button.danger { background: var(--danger); color: #1a0606; }
code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
.actions form { margin: 0; }
.subnav { color: var(--ink-dim); margin: -0.3rem 0 1rem; font-size: 0.9rem; }
+54
View File
@@ -0,0 +1,54 @@
package adminconsole
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
)
// CSRF issues and verifies the stateless anti-CSRF token used by the admin
// console. The token is an HMAC-SHA256 over the authenticated operator's
// username keyed by a process secret, so a cross-site request cannot forge it
// without already being able to read an authenticated page. The console is
// sessionless (HTTP Basic Auth), which makes a stateless, per-operator token
// the natural fit.
type CSRF struct {
key []byte
}
// NewCSRF returns a CSRF signer keyed by key. A shared key across backend
// replicas lets a form rendered by one replica validate on another; callers
// that pass a per-process random key (see NewRandomCSRF) accept that forms do
// not survive a restart or span replicas.
func NewCSRF(key []byte) *CSRF {
return &CSRF{key: key}
}
// NewRandomCSRF returns a CSRF signer keyed by a fresh 32-byte random secret.
// It is the secure default when no shared key is configured.
func NewRandomCSRF() (*CSRF, error) {
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("generate admin console CSRF key: %w", err)
}
return &CSRF{key: key}, nil
}
// Token returns the anti-CSRF token bound to username.
func (c *CSRF) Token(username string) string {
mac := hmac.New(sha256.New, c.key)
mac.Write([]byte(username))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// Verify reports whether token is the valid anti-CSRF token for username. The
// comparison runs in constant time relative to the token bytes.
func (c *CSRF) Verify(username, token string) bool {
if token == "" {
return false
}
expected := c.Token(username)
return hmac.Equal([]byte(token), []byte(expected))
}
@@ -0,0 +1,42 @@
package adminconsole
import "testing"
func TestCSRFTokenRoundTrip(t *testing.T) {
signer := NewCSRF([]byte("shared-secret"))
token := signer.Token("alice")
if !signer.Verify("alice", token) {
t.Fatal("valid token rejected")
}
if signer.Verify("bob", token) {
t.Fatal("token accepted for a different operator")
}
if signer.Verify("alice", "") {
t.Fatal("empty token accepted")
}
if signer.Verify("alice", token+"x") {
t.Fatal("tampered token accepted")
}
}
func TestCSRFKeySeparation(t *testing.T) {
a := NewCSRF([]byte("key-a"))
b := NewCSRF([]byte("key-b"))
if a.Token("operator") == b.Token("operator") {
t.Fatal("tokens collide across distinct keys")
}
if b.Verify("operator", a.Token("operator")) {
t.Fatal("token minted under one key verified under another")
}
}
func TestRandomCSRFRoundTrip(t *testing.T) {
signer, err := NewRandomCSRF()
if err != nil {
t.Fatalf("NewRandomCSRF: %v", err)
}
if !signer.Verify("operator", signer.Token("operator")) {
t.Fatal("random-key token failed to round-trip")
}
}
@@ -0,0 +1,24 @@
package adminconsole
// StatusCount pairs a status label with its current row count for the
// dashboard's per-status tables. It is the view-layer counterpart of the
// data gathered by the ops-status reader; the server handler maps between
// them so this package stays free of database concerns.
type StatusCount struct {
Status string
Count int64
}
// DashboardData is the view model for the console landing page. MonitorAvailable
// is false when no ops-status reader is wired, in which case the monitoring
// panels are omitted. Errors carries non-fatal probe failures for display.
type DashboardData struct {
MonitorAvailable bool
BackendReady bool
PostgresHealthy bool
Runtimes []StatusCount
MailDeliveries []StatusCount
NotificationRoutes []StatusCount
NotificationMalformed int64
Errors []string
}
+18
View File
@@ -0,0 +1,18 @@
// Package adminconsole renders the server-side operator console mounted by the
// backend under the `/_gm` route group.
//
// The console is a multi-page, server-rendered surface built on the standard
// library's html/template package: navigation is driven by request path and
// query, state changes are submitted with HTML forms and answered with a
// Post/Redirect/Get redirect. The package owns three concerns and nothing
// transport-specific:
//
// - Renderer composes the shared layout with one content page per route.
// - CSRF issues and verifies the stateless anti-CSRF token embedded in every
// state-changing form.
// - Assets exposes the embedded stylesheet served under `/_gm/assets/`.
//
// The gin glue (route registration, Basic Auth, the CSRF guard middleware, and
// the per-page handlers) lives in package server; this package stays free of
// the web framework so it can be unit-tested in isolation.
package adminconsole
+67
View File
@@ -0,0 +1,67 @@
package adminconsole
// GameRow is one line in the games list table.
type GameRow struct {
GameID string
GameName string
Visibility string
Status string
Owner string
Players string
TurnSchedule string
CreatedAt string
}
// GamesListData is the view model for the paginated games list.
type GamesListData struct {
Items []GameRow
Page int
PageSize int
Total int
HasPrev bool
HasNext bool
PrevPage int
NextPage int
}
// GameDetailData is the view model for a single game, combining the lobby
// record with the runtime snapshot and the available actions.
type GameDetailData struct {
GameID string
GameName string
Description string
Visibility string
Status string
Owner string
MinPlayers int32
MaxPlayers int32
StartGapHours int32
StartGapPlayers int32
TurnSchedule string
TargetEngineVersion string
EnrollmentEndsAt string
CreatedAt string
StartedAt string
FinishedAt string
HasRuntime bool
RuntimeStatus string
CurrentEngineVersion string
EngineHealth string
CurrentTurn int32
NextGenerationAt string
Paused bool
}
// EngineVersionRow is one line in the engine-version registry table.
type EngineVersionRow struct {
Version string
ImageRef string
Enabled bool
CreatedAt string
}
// EngineVersionsData is the view model for the engine-version registry page.
type EngineVersionsData struct {
Items []EngineVersionRow
}
+86
View File
@@ -0,0 +1,86 @@
package adminconsole
// MailDeliveryRow is one line in the mail deliveries table.
type MailDeliveryRow struct {
DeliveryID string
Template string
Status string
Attempts int32
NextAttempt string
Created string
}
// MailDeadLetterRow is one line in the mail dead-letters table.
type MailDeadLetterRow struct {
DeliveryID string
Reason string
Archived string
}
// MailData is the view model for the mail page: a paginated deliveries list
// plus a snapshot of dead-letters.
type MailData struct {
Deliveries []MailDeliveryRow
DeadLetters []MailDeadLetterRow
Page int
PageSize int
Total int64
HasPrev bool
HasNext bool
PrevPage int
NextPage int
}
// MailAttemptRow is one delivery attempt on the mail detail page.
type MailAttemptRow struct {
AttemptNo int32
Outcome string
Started string
Finished string
Error string
}
// MailDeliveryDetail is the view model for a single delivery.
type MailDeliveryDetail struct {
DeliveryID string
Template string
Status string
Attempts int32
NextAttempt string
LastError string
Created string
Sent string
DeadLettered string
CanResend bool
AttemptRows []MailAttemptRow
}
// NotificationRow is one line in the notifications table.
type NotificationRow struct {
NotificationID string
Kind string
UserID string
Created string
}
// NotificationDeadLetterRow is one line in the notification dead-letters table.
type NotificationDeadLetterRow struct {
NotificationID string
RouteID string
Reason string
Archived string
}
// MalformedRow is one line in the malformed-intents table.
type MalformedRow struct {
ID string
Reason string
Received string
}
// NotificationsData is the view model for the notifications overview page.
type NotificationsData struct {
Notifications []NotificationRow
DeadLetters []NotificationDeadLetterRow
Malformed []MalformedRow
}
+11
View File
@@ -0,0 +1,11 @@
package adminconsole
// MessageData is the view model for the generic message page used to render
// not-found, validation, and operation-failure notices. Class selects the CSS
// styling (for example "bad" for errors); BackHref, when set, renders a link
// back to a relevant page.
type MessageData struct {
Message string
Class string
BackHref string
}
@@ -0,0 +1,14 @@
package adminconsole
// OperatorRow is one line in the operators (admin accounts) table.
type OperatorRow struct {
Username string
CreatedAt string
LastUsedAt string
Disabled bool
}
// OperatorsData is the view model for the operators page.
type OperatorsData struct {
Items []OperatorRow
}
+107
View File
@@ -0,0 +1,107 @@
package adminconsole
import (
"bytes"
"embed"
"fmt"
"html/template"
"io"
"io/fs"
"path"
"strings"
)
//go:embed templates
var templatesFS embed.FS
//go:embed assets
var assetsFS embed.FS
// Renderer holds the parsed admin console templates. It composes one template
// set per content page, each combining the shared layout (defining the page
// chrome and the "layout" entry template) with that page's "content" block, so
// rendering a page is a single ExecuteTemplate call against the "layout" name.
type Renderer struct {
pages map[string]*template.Template
}
// PageData is the view model passed to every admin console page. Title is the
// document title; Username is the authenticated operator; CSRFToken is the
// per-operator token embedded into state-changing forms; ActiveNav marks the
// highlighted navigation entry; Data carries the page-specific payload.
type PageData struct {
Title string
Username string
CSRFToken string
ActiveNav string
Data any
}
// NewRenderer parses the embedded layout and every content page under
// templates/pages, returning a Renderer ready to serve them. It fails when a
// template cannot be parsed.
func NewRenderer() (*Renderer, error) {
base, err := template.New("layout").ParseFS(templatesFS, "templates/layout.gohtml")
if err != nil {
return nil, fmt.Errorf("parse admin console layout: %w", err)
}
pageFiles, err := fs.Glob(templatesFS, "templates/pages/*.gohtml")
if err != nil {
return nil, fmt.Errorf("enumerate admin console pages: %w", err)
}
if len(pageFiles) == 0 {
return nil, fmt.Errorf("admin console: no page templates found under templates/pages")
}
pages := make(map[string]*template.Template, len(pageFiles))
for _, file := range pageFiles {
name := strings.TrimSuffix(path.Base(file), ".gohtml")
clone, err := base.Clone()
if err != nil {
return nil, fmt.Errorf("clone admin console layout for %q: %w", name, err)
}
if _, err := clone.ParseFS(templatesFS, file); err != nil {
return nil, fmt.Errorf("parse admin console page %q: %w", name, err)
}
pages[name] = clone
}
return &Renderer{pages: pages}, nil
}
// MustNewRenderer is like NewRenderer but panics on error. The templates are
// embedded at build time, so a parse failure is a programmer error rather than
// a runtime condition.
func MustNewRenderer() *Renderer {
renderer, err := NewRenderer()
if err != nil {
panic(err)
}
return renderer
}
// Render writes the named page, wrapped in the shared layout, to w using data.
// It returns an error when page is unknown or template execution fails; the
// page is rendered into an intermediate buffer first so a mid-render failure
// never emits a partial document to w.
func (r *Renderer) Render(w io.Writer, page string, data PageData) error {
tmpl, ok := r.pages[page]
if !ok {
return fmt.Errorf("admin console: unknown page %q", page)
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
return fmt.Errorf("render admin console page %q: %w", page, err)
}
_, err := buf.WriteTo(w)
return err
}
// Assets returns the embedded static asset tree rooted at the assets directory,
// suitable for serving under `/_gm/assets/`.
func Assets() (fs.FS, error) {
return fs.Sub(assetsFS, "assets")
}
@@ -0,0 +1,67 @@
package adminconsole
import (
"bytes"
"io/fs"
"strings"
"testing"
)
func TestRendererRendersDashboard(t *testing.T) {
renderer, err := NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
var buf bytes.Buffer
err = renderer.Render(&buf, "dashboard", PageData{
Title: "Dashboard",
Username: "ops-bob",
ActiveNav: "dashboard",
})
if err != nil {
t.Fatalf("Render: %v", err)
}
out := buf.String()
for _, want := range []string{
"<!DOCTYPE html>",
"Dashboard",
"ops-bob",
`href="/_gm/users"`,
"/_gm/assets/console.css",
} {
if !strings.Contains(out, want) {
t.Errorf("rendered page missing %q\n--- page ---\n%s", want, out)
}
}
}
func TestRendererUnknownPage(t *testing.T) {
renderer := MustNewRenderer()
if err := renderer.Render(&bytes.Buffer{}, "does-not-exist", PageData{}); err == nil {
t.Fatal("expected an error rendering an unknown page")
}
}
func TestRendererEscapesUsername(t *testing.T) {
renderer := MustNewRenderer()
var buf bytes.Buffer
if err := renderer.Render(&buf, "dashboard", PageData{Username: "<script>evil</script>"}); err != nil {
t.Fatalf("Render: %v", err)
}
if strings.Contains(buf.String(), "<script>evil</script>") {
t.Error("username was not HTML-escaped in the rendered page")
}
}
func TestAssetsContainsStylesheet(t *testing.T) {
fsys, err := Assets()
if err != nil {
t.Fatalf("Assets: %v", err)
}
if _, err := fs.Stat(fsys, "console.css"); err != nil {
t.Fatalf("console.css missing from embedded assets: %v", err)
}
}
@@ -0,0 +1,30 @@
{{define "layout" -}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{{.Title}} · Galaxy GM</title>
<link rel="stylesheet" href="/_gm/assets/console.css">
</head>
<body>
<header class="topbar">
<span class="brand">Galaxy · GM</span>
<nav class="mainnav">
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a>
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
<a href="/_gm/operators"{{if eq .ActiveNav "operators"}} class="active"{{end}}>Operators</a>
<a href="/_gm/mail"{{if eq .ActiveNav "mail"}} class="active"{{end}}>Mail</a>
<a href="/_gm/grafana/" target="_blank" rel="noopener">Grafana</a>
<a href="/_gm/mailpit/" target="_blank" rel="noopener">Mailpit</a>
</nav>
<span class="who">{{.Username}}</span>
</header>
<main class="content">
{{template "content" .}}
</main>
</body>
</html>
{{- end}}
@@ -0,0 +1,21 @@
{{define "content" -}}
{{$csrf := .CSRFToken}}
<h1>Broadcast</h1>
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
<section class="panel">
<h2>Admin broadcast</h2>
<form method="post" action="/_gm/broadcast" class="form">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>Scope
<select name="scope"><option value="all_running">all running games</option><option value="selected">selected games</option></select>
</label>
<label>Game IDs (comma-separated, for "selected") <input type="text" name="game_ids" placeholder="uuid,uuid"></label>
<label>Recipients
<select name="recipients"><option value="active">active members</option><option value="active_and_removed">active and removed</option><option value="all_members">all members</option></select>
</label>
<label>Subject <input type="text" name="subject"></label>
<label>Body <input type="text" name="body" required></label>
<button type="submit">Send broadcast</button>
</form>
</section>
{{- end}}
@@ -0,0 +1,69 @@
{{define "content" -}}
<h1>Dashboard</h1>
<p class="lede">Signed in as <strong>{{.Username}}</strong>.</p>
{{with .Data}}
<section class="panel">
<h2>Health</h2>
<ul class="kv">
<li>Backend ready: {{if .BackendReady}}<span class="ok">yes</span>{{else}}<span class="bad">no</span>{{end}}</li>
<li>Postgres: {{if .PostgresHealthy}}<span class="ok">healthy</span>{{else}}<span class="bad">unreachable</span>{{end}}</li>
</ul>
</section>
{{if .MonitorAvailable}}
<div class="grid">
<section class="panel">
<h2>Game runtimes</h2>
{{template "statuscounts" .Runtimes}}
</section>
<section class="panel">
<h2>Mail deliveries</h2>
{{template "statuscounts" .MailDeliveries}}
</section>
<section class="panel">
<h2>Notification routes</h2>
{{template "statuscounts" .NotificationRoutes}}
</section>
<section class="panel">
<h2>Malformed notifications</h2>
<p class="bignum {{if gt .NotificationMalformed 0}}bad{{end}}">{{.NotificationMalformed}}</p>
</section>
</div>
{{if .Errors}}
<section class="panel errors">
<h2>Collection errors</h2>
<ul>{{range .Errors}}<li>{{.}}</li>{{end}}</ul>
</section>
{{end}}
{{else}}
<p class="note">Monitoring is not wired in this deployment.</p>
{{end}}
{{end}}
<section class="cards">
<a class="card" href="/_gm/users">
<h2>Users</h2>
<p>Accounts, sanctions, entitlements, soft-delete.</p>
</a>
<a class="card" href="/_gm/games">
<h2>Games &amp; runtimes</h2>
<p>Lobby state, engine versions, turn control.</p>
</a>
<a class="card" href="/_gm/operators">
<h2>Operators</h2>
<p>Admin accounts: create, disable, reset password.</p>
</a>
<a class="card" href="/_gm/mail">
<h2>Mail &amp; notifications</h2>
<p>Deliveries, dead-letters, broadcasts.</p>
</a>
</section>
{{- end}}
{{define "statuscounts" -}}
{{if .}}
<table class="counts"><tbody>
{{range .}}<tr><td>{{.Status}}</td><td class="num">{{.Count}}</td></tr>{{end}}
</tbody></table>
{{else}}
<p class="note">none</p>
{{end}}
{{- end}}
@@ -0,0 +1,30 @@
{{define "content" -}}
{{$csrf := .CSRFToken}}
<h1>Engine versions</h1>
{{with .Data}}
<table class="list">
<thead><tr><th>Version</th><th>Image</th><th>Enabled</th><th>Created</th><th></th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td>{{.Version}}</td>
<td><code>{{.ImageRef}}</code></td>
<td>{{if .Enabled}}<span class="ok">yes</span>{{else}}<span class="bad">no</span>{{end}}</td>
<td>{{.CreatedAt}}</td>
<td>{{if .Enabled}}<form method="post" action="/_gm/engine-versions/{{.Version}}/disable" onsubmit="return confirm('Disable {{.Version}}?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Disable</button></form>{{end}}</td>
</tr>
{{else}}<tr><td colspan="5"><span class="note">no engine versions</span></td></tr>{{end}}
</tbody>
</table>
{{end}}
<section class="panel">
<h2>Register version</h2>
<form method="post" action="/_gm/engine-versions" class="form">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>Version <input type="text" name="version" placeholder="semver e.g. 0.1.0" required></label>
<label>Image ref <input type="text" name="image_ref" required></label>
<label>Enabled <input type="checkbox" name="enabled" value="true" checked></label>
<button type="submit">Register</button>
</form>
</section>
{{- end}}
@@ -0,0 +1,65 @@
{{define "content" -}}
{{$csrf := .CSRFToken}}
{{with .Data}}
<p><a href="/_gm/games">&laquo; all games</a></p>
<h1>{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}</h1>
<section class="panel">
<h2>Game</h2>
<ul class="kv">
<li>Game ID: <code>{{.GameID}}</code></li>
<li>Visibility: {{.Visibility}}</li>
<li>Status: {{.Status}}</li>
<li>Owner: {{.Owner}}</li>
<li>Players: {{.MinPlayers}}{{.MaxPlayers}}</li>
<li>Start gap: {{.StartGapHours}}h / {{.StartGapPlayers}} players</li>
<li>Turn schedule: {{.TurnSchedule}}</li>
<li>Target engine: {{.TargetEngineVersion}}</li>
<li>Enrollment ends: {{.EnrollmentEndsAt}}</li>
<li>Created: {{.CreatedAt}}</li>
<li>Started: {{if .StartedAt}}{{.StartedAt}}{{else}}—{{end}}</li>
<li>Finished: {{if .FinishedAt}}{{.FinishedAt}}{{else}}—{{end}}</li>
</ul>
{{if .Description}}<p>{{.Description}}</p>{{end}}
<div class="actions">
<form method="post" action="/_gm/games/{{.GameID}}/force-start" onsubmit="return confirm('Force-start this game?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Force start</button></form>
<form method="post" action="/_gm/games/{{.GameID}}/force-stop" onsubmit="return confirm('Force-stop this game?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Force stop</button></form>
</div>
</section>
<section class="panel">
<h2>Runtime</h2>
{{if .HasRuntime}}
<ul class="kv">
<li>Status: {{.RuntimeStatus}}</li>
<li>Engine version: {{.CurrentEngineVersion}}</li>
<li>Engine health: {{.EngineHealth}}</li>
<li>Current turn: {{.CurrentTurn}}</li>
<li>Next generation: {{if .NextGenerationAt}}{{.NextGenerationAt}}{{else}}—{{end}}</li>
<li>Paused: {{if .Paused}}yes{{else}}no{{end}}</li>
</ul>
<div class="actions">
<form method="post" action="/_gm/games/{{.GameID}}/runtime/restart" onsubmit="return confirm('Restart the engine container?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Restart</button></form>
<form method="post" action="/_gm/games/{{.GameID}}/runtime/force-next-turn" onsubmit="return confirm('Force the next turn now?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Force next turn</button></form>
</div>
<form method="post" action="/_gm/games/{{.GameID}}/runtime/patch" class="form">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>Patch to version <input type="text" name="target_version" placeholder="e.g. 0.1.1" required></label>
<button type="submit">Patch</button>
</form>
{{else}}
<p class="note">No runtime record for this game yet.</p>
{{end}}
</section>
<section class="panel">
<h2>Ban member</h2>
<form method="post" action="/_gm/games/{{.GameID}}/ban-member" class="form" onsubmit="return confirm('Ban this member from the game?');">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>User ID <input type="text" name="user_id" required></label>
<label>Reason <input type="text" name="reason"></label>
<button type="submit" class="danger">Ban member</button>
</form>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,43 @@
{{define "content" -}}
{{$csrf := .CSRFToken}}
<h1>Games</h1>
{{with .Data}}
<table class="list">
<thead><tr><th>Name</th><th>Visibility</th><th>Status</th><th>Owner</th><th>Players</th><th>Schedule</th><th>Created</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td><a href="/_gm/games/{{.GameID}}">{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}</a></td>
<td>{{.Visibility}}</td>
<td>{{.Status}}</td>
<td>{{.Owner}}</td>
<td>{{.Players}}</td>
<td>{{.TurnSchedule}}</td>
<td>{{.CreatedAt}}</td>
</tr>
{{else}}<tr><td colspan="7"><span class="note">no games</span></td></tr>{{end}}
</tbody>
</table>
<nav class="pager">
{{if .HasPrev}}<a href="/_gm/games?page={{.PrevPage}}&amp;page_size={{.PageSize}}">&laquo; prev</a>{{end}}
<span>page {{.Page}} · {{.Total}} total</span>
{{if .HasNext}}<a href="/_gm/games?page={{.NextPage}}&amp;page_size={{.PageSize}}">next &raquo;</a>{{end}}
</nav>
{{end}}
<section class="panel">
<h2>Create public game</h2>
<form method="post" action="/_gm/games" class="form">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>Name <input type="text" name="game_name" required></label>
<label>Description <input type="text" name="description"></label>
<label>Min players <input type="number" name="min_players" value="2" min="1"></label>
<label>Max players <input type="number" name="max_players" value="8" min="1"></label>
<label>Start gap hours <input type="number" name="start_gap_hours" value="0" min="0"></label>
<label>Start gap players <input type="number" name="start_gap_players" value="0" min="0"></label>
<label>Enrollment ends <input type="datetime-local" name="enrollment_ends_at" required></label>
<label>Turn schedule <input type="text" name="turn_schedule" placeholder="e.g. @every 24h" required></label>
<label>Engine version <input type="text" name="target_engine_version" placeholder="e.g. 0.1.0" required></label>
<button type="submit">Create</button>
</form>
</section>
{{- end}}
@@ -0,0 +1,32 @@
{{define "content" -}}
<h1>Mail</h1>
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
{{with .Data}}
<section class="panel">
<h2>Deliveries</h2>
<table class="list">
<thead><tr><th>Delivery</th><th>Template</th><th>Status</th><th>Attempts</th><th>Next attempt</th><th>Created</th></tr></thead>
<tbody>
{{range .Deliveries}}
<tr><td><a href="/_gm/mail/deliveries/{{.DeliveryID}}"><code>{{.DeliveryID}}</code></a></td><td>{{.Template}}</td><td>{{.Status}}</td><td>{{.Attempts}}</td><td>{{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}</td><td>{{.Created}}</td></tr>
{{else}}<tr><td colspan="6"><span class="note">no deliveries</span></td></tr>{{end}}
</tbody>
</table>
<nav class="pager">
{{if .HasPrev}}<a href="/_gm/mail?page={{.PrevPage}}&amp;page_size={{.PageSize}}">&laquo; prev</a>{{end}}
<span>page {{.Page}} · {{.Total}} total</span>
{{if .HasNext}}<a href="/_gm/mail?page={{.NextPage}}&amp;page_size={{.PageSize}}">next &raquo;</a>{{end}}
</nav>
</section>
<section class="panel">
<h2>Dead-letters</h2>
<table class="list">
<thead><tr><th>Delivery</th><th>Reason</th><th>Archived</th></tr></thead>
<tbody>
{{range .DeadLetters}}<tr><td><a href="/_gm/mail/deliveries/{{.DeliveryID}}"><code>{{.DeliveryID}}</code></a></td><td>{{.Reason}}</td><td>{{.Archived}}</td></tr>
{{else}}<tr><td colspan="3"><span class="note">no dead-letters</span></td></tr>{{end}}
</tbody>
</table>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,33 @@
{{define "content" -}}
{{$csrf := .CSRFToken}}
{{with .Data}}
<p><a href="/_gm/mail">&laquo; mail</a></p>
<h1>Delivery</h1>
<section class="panel">
<ul class="kv">
<li>Delivery ID: <code>{{.DeliveryID}}</code></li>
<li>Template: {{.Template}}</li>
<li>Status: {{.Status}}</li>
<li>Attempts: {{.Attempts}}</li>
<li>Next attempt: {{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}</li>
<li>Created: {{.Created}}</li>
<li>Sent: {{if .Sent}}{{.Sent}}{{else}}—{{end}}</li>
<li>Dead-lettered: {{if .DeadLettered}}{{.DeadLettered}}{{else}}—{{end}}</li>
<li>Last error: {{if .LastError}}{{.LastError}}{{else}}—{{end}}</li>
</ul>
{{if .CanResend}}
<form method="post" action="/_gm/mail/deliveries/{{.DeliveryID}}/resend" class="form" onsubmit="return confirm('Resend this delivery?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Resend</button></form>
{{else}}<p class="note">Already sent — resend is not available.</p>{{end}}
</section>
<section class="panel">
<h2>Attempts</h2>
<table class="list">
<thead><tr><th>#</th><th>Outcome</th><th>Started</th><th>Finished</th><th>Error</th></tr></thead>
<tbody>
{{range .AttemptRows}}<tr><td>{{.AttemptNo}}</td><td>{{.Outcome}}</td><td>{{.Started}}</td><td>{{if .Finished}}{{.Finished}}{{else}}—{{end}}</td><td>{{.Error}}</td></tr>
{{else}}<tr><td colspan="5"><span class="note">no attempts</span></td></tr>{{end}}
</tbody>
</table>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,7 @@
{{define "content" -}}
<h1>{{.Title}}</h1>
{{with .Data}}
<p class="{{.Class}}">{{.Message}}</p>
{{if .BackHref}}<p><a href="{{.BackHref}}">&laquo; back</a></p>{{end}}
{{end}}
{{- end}}
@@ -0,0 +1,27 @@
{{define "content" -}}
<h1>Notifications</h1>
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
{{with .Data}}
<section class="panel">
<h2>Recent notifications</h2>
<table class="list"><thead><tr><th>ID</th><th>Kind</th><th>User</th><th>Created</th></tr></thead><tbody>
{{range .Notifications}}<tr><td><code>{{.NotificationID}}</code></td><td>{{.Kind}}</td><td>{{.UserID}}</td><td>{{.Created}}</td></tr>
{{else}}<tr><td colspan="4"><span class="note">none</span></td></tr>{{end}}
</tbody></table>
</section>
<section class="panel">
<h2>Dead-letters</h2>
<table class="list"><thead><tr><th>Notification</th><th>Route</th><th>Reason</th><th>Archived</th></tr></thead><tbody>
{{range .DeadLetters}}<tr><td><code>{{.NotificationID}}</code></td><td><code>{{.RouteID}}</code></td><td>{{.Reason}}</td><td>{{.Archived}}</td></tr>
{{else}}<tr><td colspan="4"><span class="note">none</span></td></tr>{{end}}
</tbody></table>
</section>
<section class="panel">
<h2>Malformed intents</h2>
<table class="list"><thead><tr><th>ID</th><th>Reason</th><th>Received</th></tr></thead><tbody>
{{range .Malformed}}<tr><td><code>{{.ID}}</code></td><td>{{.Reason}}</td><td>{{.Received}}</td></tr>
{{else}}<tr><td colspan="3"><span class="note">none</span></td></tr>{{end}}
</tbody></table>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,38 @@
{{define "content" -}}
{{$csrf := .CSRFToken}}
<h1>Operators</h1>
{{with .Data}}
<table class="list">
<thead><tr><th>Username</th><th>Status</th><th>Created</th><th>Last used</th><th>Actions</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td>{{.Username}}</td>
<td>{{if .Disabled}}<span class="bad">disabled</span>{{else}}<span class="ok">active</span>{{end}}</td>
<td>{{.CreatedAt}}</td>
<td>{{if .LastUsedAt}}{{.LastUsedAt}}{{else}}—{{end}}</td>
<td>
<div class="actions">
{{if .Disabled}}
<form method="post" action="/_gm/operators/{{.Username}}/enable"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Enable</button></form>
{{else}}
<form method="post" action="/_gm/operators/{{.Username}}/disable" onsubmit="return confirm('Disable {{.Username}}?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Disable</button></form>
{{end}}
<form method="post" action="/_gm/operators/{{.Username}}/reset-password" class="form"><input type="hidden" name="_csrf" value="{{$csrf}}"><input type="password" name="password" placeholder="new password" required><button type="submit">Reset</button></form>
</div>
</td>
</tr>
{{else}}<tr><td colspan="5"><span class="note">no operators</span></td></tr>{{end}}
</tbody>
</table>
{{end}}
<section class="panel">
<h2>Create operator</h2>
<form method="post" action="/_gm/operators" class="form">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>Username <input type="text" name="username" required></label>
<label>Password <input type="password" name="password" required></label>
<button type="submit">Create</button>
</form>
</section>
{{- end}}
@@ -0,0 +1,68 @@
{{define "content" -}}
{{$csrf := .CSRFToken}}
{{with .Data}}
<p><a href="/_gm/users">&laquo; all users</a></p>
<h1>{{.Email}}</h1>
{{if .Deleted}}<p class="bad">This account is soft-deleted.</p>{{end}}
<section class="panel">
<h2>Account</h2>
<ul class="kv">
<li>User ID: <code>{{.UserID}}</code></li>
<li>User name: {{.UserName}}</li>
<li>Display name: {{.DisplayName}}</li>
<li>Preferred language: {{.PreferredLanguage}}</li>
<li>Time zone: {{.TimeZone}}</li>
<li>Declared country: {{.DeclaredCountry}}</li>
<li>Status: {{if .Blocked}}<span class="bad">blocked</span>{{else}}<span class="ok">active</span>{{end}}</li>
<li>Created: {{.CreatedAt}}</li>
<li>Updated: {{.UpdatedAt}}</li>
</ul>
</section>
<section class="panel">
<h2>Entitlement</h2>
<ul class="kv">
<li>Tier: <strong>{{.Tier}}</strong> ({{if .IsPaid}}paid{{else}}free{{end}})</li>
<li>Source: {{.EntitlementSource}}</li>
<li>Reason: {{.EntitlementReason}}</li>
<li>Ends: {{if .EntitlementEnds}}{{.EntitlementEnds}}{{else}}—{{end}}</li>
</ul>
<form method="post" action="/_gm/users/{{.UserID}}/entitlement" class="form">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>Tier
<select name="tier">{{range .Tiers}}<option value="{{.}}">{{.}}</option>{{end}}</select>
</label>
<label>Source <input type="text" name="source" value="admin"></label>
<label>Reason <input type="text" name="reason_code" placeholder="optional"></label>
<button type="submit">Update entitlement</button>
</form>
</section>
<section class="panel">
<h2>Active sanctions</h2>
{{if .Sanctions}}
<table class="counts"><tbody>
{{range .Sanctions}}<tr><td>{{.SanctionCode}}</td><td>{{.Scope}}</td><td>{{.ReasonCode}}</td><td>{{.AppliedAt}}</td></tr>{{end}}
</tbody></table>
{{else}}<p class="note">none</p>{{end}}
{{if .Blocked}}
<p class="note">User is permanently blocked. Unblock is not available in the current admin API.</p>
{{else}}
<form method="post" action="/_gm/users/{{.UserID}}/block" class="form" onsubmit="return confirm('Permanently block this user?');">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>Reason <input type="text" name="reason_code" required></label>
<button type="submit" class="danger">Permanently block</button>
</form>
{{end}}
</section>
<section class="panel">
<h2>Danger zone</h2>
<form method="post" action="/_gm/users/{{.UserID}}/soft-delete" class="form" onsubmit="return confirm('Soft-delete this account? This cascades to sessions, memberships, and owned games.');">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<button type="submit" class="danger">Soft-delete account</button>
</form>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,27 @@
{{define "content" -}}
<h1>Users</h1>
{{with .Data}}
<table class="list">
<thead><tr><th>Email</th><th>User name</th><th>Display</th><th>Tier</th><th>Status</th><th>Created</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td><a href="/_gm/users/{{.UserID}}">{{.Email}}</a></td>
<td>{{.UserName}}</td>
<td>{{.DisplayName}}</td>
<td>{{.Tier}}</td>
<td>{{if .Deleted}}<span class="bad">deleted</span>{{else if .Blocked}}<span class="bad">blocked</span>{{else}}<span class="ok">active</span>{{end}}</td>
<td>{{.CreatedAt}}</td>
</tr>
{{else}}
<tr><td colspan="6"><span class="note">no users</span></td></tr>
{{end}}
</tbody>
</table>
<nav class="pager">
{{if .HasPrev}}<a href="/_gm/users?page={{.PrevPage}}&amp;page_size={{.PageSize}}">&laquo; prev</a>{{end}}
<span>page {{.Page}} · {{.Total}} total</span>
{{if .HasNext}}<a href="/_gm/users?page={{.NextPage}}&amp;page_size={{.PageSize}}">next &raquo;</a>{{end}}
</nav>
{{end}}
{{- end}}
+61
View File
@@ -0,0 +1,61 @@
package adminconsole
// UserRow is one line in the users list table.
type UserRow struct {
UserID string
Email string
UserName string
DisplayName string
Tier string
Blocked bool
Deleted bool
CreatedAt string
}
// UsersListData is the view model for the paginated users list.
type UsersListData struct {
Items []UserRow
Page int
PageSize int
Total int
HasPrev bool
HasNext bool
PrevPage int
NextPage int
}
// SanctionView is one active sanction shown on the user detail page.
type SanctionView struct {
SanctionCode string
Scope string
ReasonCode string
AppliedAt string
ExpiresAt string
}
// UserDetailData is the view model for a single user's detail page,
// combining the account aggregate with the form option lists.
type UserDetailData struct {
UserID string
Email string
UserName string
DisplayName string
PreferredLanguage string
TimeZone string
DeclaredCountry string
Blocked bool
Deleted bool
CreatedAt string
UpdatedAt string
Tier string
IsPaid bool
EntitlementSource string
EntitlementReason string
EntitlementEnds string
Sanctions []SanctionView
// Tiers lists the selectable entitlement tiers for the form.
Tiers []string
}
+46
View File
@@ -513,6 +513,52 @@ func TestConfirmEmailCodeWrongCode(t *testing.T) {
}
}
// TestConfirmEmailCodeDevFixedCodeBypassesAttemptsCeiling proves the
// dev-mode override is a true escape hatch: a developer who already
// burned past ChallengeMaxAttempts on a long-lived dev challenge
// (typically because the throttle merged repeated send-email-code
// calls onto one challenge_id) can still recover by submitting the
// fixed code without first waiting out the challenge TTL.
func TestConfirmEmailCodeDevFixedCodeBypassesAttemptsCeiling(t *testing.T) {
db := startPostgres(t)
cfg := authConfig()
cfg.DevFixedCode = "999999"
svc := buildServiceWithConfig(t, db, cfg)
ctx := context.Background()
id, err := svc.SendEmailCode(ctx, "dev-bypass-ceiling@example.test", "en", "", "")
if err != nil {
t.Fatalf("send: %v", err)
}
// Burn through the attempts ceiling with deliberately wrong codes.
for i := range cfg.ChallengeMaxAttempts + 1 {
_, err := svc.ConfirmEmailCode(ctx, auth.ConfirmInputs{
ChallengeID: id,
Code: "111111",
ClientPublicKey: randomKey(t),
TimeZone: "UTC",
})
if err == nil {
t.Fatalf("attempt %d unexpectedly succeeded", i)
}
}
// The dev-fixed code still goes through.
session, err := svc.ConfirmEmailCode(ctx, auth.ConfirmInputs{
ChallengeID: id,
Code: "999999",
ClientPublicKey: randomKey(t),
TimeZone: "UTC",
})
if err != nil {
t.Fatalf("dev-fixed-code after attempts exhausted: %v", err)
}
if session.DeviceSessionID == uuid.Nil {
t.Fatalf("dev-fixed-code did not produce a session")
}
}
func TestConfirmEmailCodeAttemptsCeiling(t *testing.T) {
db := startPostgres(t)
svc, mailer, _, _ := buildService(t, db)
+15 -6
View File
@@ -163,6 +163,21 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi
return Session{}, err
}
// The dev-mode fixed-code override is checked first so it bypasses
// both the bcrypt verify and the per-challenge attempts ceiling.
// Without this, a developer who already burned through
// `ChallengeMaxAttempts` on an existing un-consumed challenge —
// for example after the throttle merged repeated send-email-code
// calls onto one challenge_id — could not recover with the fixed
// code either, defeating the purpose of the override. Production
// deployments leave `DevFixedCode` empty, so this branch is
// inert and the regular attempts gate still applies.
if s.devFixedCodeMatches(in.Code) {
s.deps.Logger.Warn("auth challenge accepted via dev-mode fixed code override",
zap.String("challenge_id", in.ChallengeID.String()),
zap.Int32("attempts", loaded.Attempts),
)
} else {
if int(loaded.Attempts) > s.deps.Config.ChallengeMaxAttempts {
s.deps.Logger.Info("auth challenge attempts exhausted",
zap.String("challenge_id", in.ChallengeID.String()),
@@ -170,8 +185,6 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi
)
return Session{}, ErrTooManyAttempts
}
if !s.devFixedCodeMatches(in.Code) {
if err := verifyCode(loaded.CodeHash, in.Code); err != nil {
if errors.Is(err, ErrCodeMismatch) {
s.deps.Logger.Info("auth challenge code mismatch",
@@ -182,10 +195,6 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi
}
return Session{}, err
}
} else {
s.deps.Logger.Warn("auth challenge accepted via dev-mode fixed code override",
zap.String("challenge_id", in.ChallengeID.String()),
)
}
// Re-check permanent_block after verifying the code. SendEmailCode
+108 -45
View File
@@ -55,6 +55,8 @@ const (
envAdminBootstrapUser = "BACKEND_ADMIN_BOOTSTRAP_USER"
envAdminBootstrapPassword = "BACKEND_ADMIN_BOOTSTRAP_PASSWORD"
envAdminConsoleCSRFKey = "BACKEND_ADMIN_CONSOLE_CSRF_KEY"
envGeoIPDBPath = "BACKEND_GEOIP_DB_PATH"
envOTelTracesExporter = "BACKEND_OTEL_TRACES_EXPORTER"
@@ -91,15 +93,18 @@ const (
envRuntimeContainerPIDsLimit = "BACKEND_RUNTIME_CONTAINER_PIDS_LIMIT"
envRuntimeContainerStateMount = "BACKEND_RUNTIME_CONTAINER_STATE_MOUNT"
envRuntimeStopGracePeriod = "BACKEND_RUNTIME_STOP_GRACE_PERIOD"
envRuntimeStackLabel = "BACKEND_STACK_LABEL"
envNotificationAdminEmail = "BACKEND_NOTIFICATION_ADMIN_EMAIL"
envNotificationWorkerInterval = "BACKEND_NOTIFICATION_WORKER_INTERVAL"
envNotificationMaxAttempts = "BACKEND_NOTIFICATION_MAX_ATTEMPTS"
envDevSandboxEmail = "BACKEND_DEV_SANDBOX_EMAIL"
envDevSandboxEngineImage = "BACKEND_DEV_SANDBOX_ENGINE_IMAGE"
envDevSandboxEngineVersion = "BACKEND_DEV_SANDBOX_ENGINE_VERSION"
envDevSandboxPlayerCount = "BACKEND_DEV_SANDBOX_PLAYER_COUNT"
envDiplomailMaxBodyBytes = "BACKEND_DIPLOMAIL_MAX_BODY_BYTES"
envDiplomailMaxSubjectBytes = "BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES"
envDiplomailTranslatorURL = "BACKEND_DIPLOMAIL_TRANSLATOR_URL"
envDiplomailTranslatorTimeout = "BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT"
envDiplomailTranslatorMaxAttempts = "BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS"
envDiplomailWorkerInterval = "BACKEND_DIPLOMAIL_WORKER_INTERVAL"
)
// Default values applied when an environment variable is absent.
@@ -163,8 +168,11 @@ const (
defaultNotificationWorkerInterval = 5 * time.Second
defaultNotificationMaxAttempts = 8
defaultDevSandboxEngineVersion = "0.1.0"
defaultDevSandboxPlayerCount = 20
defaultDiplomailMaxBodyBytes = 4096
defaultDiplomailMaxSubjectBytes = 256
defaultDiplomailTranslatorTimeout = 10 * time.Second
defaultDiplomailTranslatorMaxAttempts = 5
defaultDiplomailWorkerInterval = 2 * time.Second
)
// Allowed values for the closed-set string options.
@@ -194,6 +202,7 @@ type Config struct {
Docker DockerConfig
Game GameConfig
Admin AdminBootstrapConfig
AdminConsole AdminConsoleConfig
GeoIP GeoIPConfig
Telemetry TelemetryConfig
Auth AuthConfig
@@ -201,29 +210,13 @@ type Config struct {
Engine EngineConfig
Runtime RuntimeConfig
Notification NotificationConfig
DevSandbox DevSandboxConfig
Diplomail DiplomailConfig
// FreshnessWindow mirrors the gateway freshness window and is used by the
// push server to bound the cursor TTL.
FreshnessWindow time.Duration
}
// DevSandboxConfig configures the boot-time bootstrap implemented in
// `backend/internal/devsandbox`. When Email is empty the bootstrap
// is a no-op, which is the production posture. When Email is set —
// from `BACKEND_DEV_SANDBOX_EMAIL` in the `tools/local-dev` stack —
// the bootstrap idempotently provisions a real user, the configured
// number of dummy participants, a private "Dev Sandbox" game, the
// matching memberships, and drives the lifecycle to `running`. The
// engine image and engine version refer to a row that the bootstrap
// also seeds in `engine_versions`.
type DevSandboxConfig struct {
Email string
EngineImage string
EngineVersion string
PlayerCount int
}
// LoggingConfig stores the parameters used by the structured logger.
type LoggingConfig struct {
// Level is the zap level name (e.g. "debug", "info", "warn", "error").
@@ -293,6 +286,15 @@ type AdminBootstrapConfig struct {
Password string
}
// AdminConsoleConfig configures the server-rendered operator console.
// CSRFKey is the secret keying the console's stateless anti-CSRF token.
// When empty the console falls back to a per-process random key, which is
// secure but means forms do not survive a restart and do not validate across
// replicas; set a shared key when running more than one backend instance.
type AdminConsoleConfig struct {
CSRFKey string
}
// GeoIPConfig configures the GeoLite2 country database used by geo lookups.
type GeoIPConfig struct {
DBPath string
@@ -395,6 +397,50 @@ type RuntimeConfig struct {
// StopGracePeriod is the docker stop SIGTERM-to-SIGKILL grace period
// applied during stop / cancel / restart / patch.
StopGracePeriod time.Duration
// StackLabel is the optional value backend stamps as
// `galaxy.stack=<value>` on every engine container it spawns. It
// lets host-side tooling (Makefile, CI workflows) scope cleanup
// operations to a single dev stack without touching unrelated
// workloads on the same Docker daemon. When empty, the label is
// not applied.
StackLabel string
}
// DiplomailConfig bounds the diplomatic-mail subsystem. Both limits
// are enforced in the service layer, so they can be tuned at runtime
// without a schema migration. Body and subject are stored as plain
// UTF-8 text; HTML is neither parsed nor sanitised on the server.
type DiplomailConfig struct {
// MaxBodyBytes caps the length of `diplomail_messages.body` in
// bytes (not runes). A send whose body exceeds the limit is
// rejected with ErrInvalidInput.
MaxBodyBytes int
// MaxSubjectBytes caps the length of `diplomail_messages.subject`
// in bytes. Subjects are optional; the empty-string default
// passes the limit trivially.
MaxSubjectBytes int
// TranslatorURL is the base URL of the LibreTranslate-compatible
// instance the async translation worker calls. When empty, the
// worker still runs but falls through to "deliver original"
// (the noop translator returns engine=noop).
TranslatorURL string
// TranslatorTimeout bounds a single HTTP request to the
// translator. Worker retries (exponential backoff up to
// TranslatorMaxAttempts) layer on top.
TranslatorTimeout time.Duration
// TranslatorMaxAttempts is the number of times the worker tries
// to translate one (message, target_lang) pair before falling
// back to delivering the original body.
TranslatorMaxAttempts int
// WorkerInterval bounds how often the async translation worker
// scans for pending pairs. The worker handles one pair per tick.
WorkerInterval time.Duration
}
// NotificationConfig configures the notification fan-out module
@@ -494,9 +540,12 @@ func DefaultConfig() Config {
WorkerInterval: defaultNotificationWorkerInterval,
MaxAttempts: defaultNotificationMaxAttempts,
},
DevSandbox: DevSandboxConfig{
EngineVersion: defaultDevSandboxEngineVersion,
PlayerCount: defaultDevSandboxPlayerCount,
Diplomail: DiplomailConfig{
MaxBodyBytes: defaultDiplomailMaxBodyBytes,
MaxSubjectBytes: defaultDiplomailMaxSubjectBytes,
TranslatorTimeout: defaultDiplomailTranslatorTimeout,
TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts,
WorkerInterval: defaultDiplomailWorkerInterval,
},
Runtime: RuntimeConfig{
WorkerPoolSize: defaultRuntimeWorkerPoolSize,
@@ -578,6 +627,8 @@ func LoadFromEnv() (Config, error) {
cfg.Admin.User = loadString(envAdminBootstrapUser, cfg.Admin.User)
cfg.Admin.Password = loadString(envAdminBootstrapPassword, cfg.Admin.Password)
cfg.AdminConsole.CSRFKey = loadString(envAdminConsoleCSRFKey, cfg.AdminConsole.CSRFKey)
cfg.GeoIP.DBPath = loadString(envGeoIPDBPath, cfg.GeoIP.DBPath)
cfg.Telemetry.TracesExporter = strings.ToLower(loadString(envOTelTracesExporter, cfg.Telemetry.TracesExporter))
@@ -648,6 +699,7 @@ func LoadFromEnv() (Config, error) {
if cfg.Runtime.StopGracePeriod, err = loadDuration(envRuntimeStopGracePeriod, cfg.Runtime.StopGracePeriod); err != nil {
return Config{}, err
}
cfg.Runtime.StackLabel = strings.TrimSpace(loadString(envRuntimeStackLabel, cfg.Runtime.StackLabel))
cfg.Notification.AdminEmail = loadString(envNotificationAdminEmail, cfg.Notification.AdminEmail)
if cfg.Notification.WorkerInterval, err = loadDuration(envNotificationWorkerInterval, cfg.Notification.WorkerInterval); err != nil {
@@ -657,10 +709,20 @@ func LoadFromEnv() (Config, error) {
return Config{}, err
}
cfg.DevSandbox.Email = strings.TrimSpace(loadString(envDevSandboxEmail, cfg.DevSandbox.Email))
cfg.DevSandbox.EngineImage = strings.TrimSpace(loadString(envDevSandboxEngineImage, cfg.DevSandbox.EngineImage))
cfg.DevSandbox.EngineVersion = strings.TrimSpace(loadString(envDevSandboxEngineVersion, cfg.DevSandbox.EngineVersion))
if cfg.DevSandbox.PlayerCount, err = loadInt(envDevSandboxPlayerCount, cfg.DevSandbox.PlayerCount); err != nil {
if cfg.Diplomail.MaxBodyBytes, err = loadInt(envDiplomailMaxBodyBytes, cfg.Diplomail.MaxBodyBytes); err != nil {
return Config{}, err
}
if cfg.Diplomail.MaxSubjectBytes, err = loadInt(envDiplomailMaxSubjectBytes, cfg.Diplomail.MaxSubjectBytes); err != nil {
return Config{}, err
}
cfg.Diplomail.TranslatorURL = loadString(envDiplomailTranslatorURL, cfg.Diplomail.TranslatorURL)
if cfg.Diplomail.TranslatorTimeout, err = loadDuration(envDiplomailTranslatorTimeout, cfg.Diplomail.TranslatorTimeout); err != nil {
return Config{}, err
}
if cfg.Diplomail.TranslatorMaxAttempts, err = loadInt(envDiplomailTranslatorMaxAttempts, cfg.Diplomail.TranslatorMaxAttempts); err != nil {
return Config{}, err
}
if cfg.Diplomail.WorkerInterval, err = loadDuration(envDiplomailWorkerInterval, cfg.Diplomail.WorkerInterval); err != nil {
return Config{}, err
}
@@ -853,27 +915,28 @@ func (c Config) Validate() error {
if c.Notification.MaxAttempts <= 0 {
return fmt.Errorf("%s must be positive", envNotificationMaxAttempts)
}
if c.Diplomail.MaxBodyBytes <= 0 {
return fmt.Errorf("%s must be positive", envDiplomailMaxBodyBytes)
}
if c.Diplomail.MaxSubjectBytes < 0 {
return fmt.Errorf("%s must not be negative", envDiplomailMaxSubjectBytes)
}
if c.Diplomail.TranslatorTimeout <= 0 {
return fmt.Errorf("%s must be positive", envDiplomailTranslatorTimeout)
}
if c.Diplomail.TranslatorMaxAttempts <= 0 {
return fmt.Errorf("%s must be positive", envDiplomailTranslatorMaxAttempts)
}
if c.Diplomail.WorkerInterval <= 0 {
return fmt.Errorf("%s must be positive", envDiplomailWorkerInterval)
}
if email := strings.TrimSpace(c.Notification.AdminEmail); email != "" {
if _, err := netmail.ParseAddress(email); err != nil {
return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envNotificationAdminEmail, err)
}
}
if email := strings.TrimSpace(c.DevSandbox.Email); email != "" {
if _, err := netmail.ParseAddress(email); err != nil {
return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envDevSandboxEmail, err)
}
if strings.TrimSpace(c.DevSandbox.EngineImage) == "" {
return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineImage, envDevSandboxEmail)
}
if strings.TrimSpace(c.DevSandbox.EngineVersion) == "" {
return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineVersion, envDevSandboxEmail)
}
if c.DevSandbox.PlayerCount <= 0 {
return fmt.Errorf("%s must be positive when %s is set", envDevSandboxPlayerCount, envDevSandboxEmail)
}
}
return nil
}
-287
View File
@@ -1,287 +0,0 @@
// Package devsandbox provisions a ready-to-play game on backend boot
// for the `tools/local-dev` stack.
//
// Bootstrap is invoked from `backend/cmd/backend/main.go` after the
// admin bootstrap and before the HTTP listener starts. It reads
// `cfg.DevSandbox`; when `Email` is empty (the production posture)
// the function logs "skipped" and returns nil. When set, it
// idempotently:
//
// 1. registers the configured engine version and image;
// 2. find-or-creates the real dev user with the configured email;
// 3. find-or-creates `cfg.PlayerCount - 1` deterministic dummy
// users so the engine's minimum-players constraint is met;
// 4. find-or-creates a private "Dev Sandbox" game owned by the
// real user with min/max_players = cfg.PlayerCount and a
// year-out turn schedule (effectively frozen at turn 1);
// 5. inserts memberships for all participants bypassing the
// application/approval flow;
// 6. drives the lifecycle to `running` (or as far as possible if
// the runtime is busy).
//
// The function is a no-op on subsequent boots once the game is
// running; partial states from earlier crashes are recovered.
package devsandbox
import (
"context"
"errors"
"fmt"
"time"
"galaxy/backend/internal/config"
"galaxy/backend/internal/lobby"
"galaxy/backend/internal/runtime"
"github.com/google/uuid"
"go.uber.org/zap"
)
// SandboxGameName is the display name used to identify the
// auto-provisioned game on subsequent reboots. The combination of
// game_name and owner_user_id is unique enough in practice — only
// the dev sandbox bootstrap creates a game owned by the configured
// real user with this exact name.
const SandboxGameName = "Dev Sandbox"
// SandboxTurnSchedule keeps the game on turn 1 by scheduling the
// next turn a year out. The runtime scheduler still parses this and
// will tick once a year — long enough to never interfere with
// solo UI development.
const SandboxTurnSchedule = "0 0 1 1 *"
// UserEnsurer matches `auth.UserEnsurer`. We define a local
// interface to avoid importing the auth package and circular
// dependencies — the production wiring passes the same `*user.Service`
// instance used by auth.
type UserEnsurer interface {
EnsureByEmail(ctx context.Context, email, preferredLanguage, timeZone, declaredCountry string) (uuid.UUID, error)
}
// Deps aggregates the collaborators Bootstrap needs.
type Deps struct {
Users UserEnsurer
Lobby *lobby.Service
EngineVersions *runtime.EngineVersionService
}
// Bootstrap runs the seven-step provisioning flow described on the
// package doc comment. Errors are returned to the caller; the boot
// path in `cmd/backend/main.go` aborts startup if Bootstrap fails so
// a misconfigured dev environment surfaces immediately rather than
// silently leaving the lobby empty.
func Bootstrap(ctx context.Context, deps Deps, cfg config.DevSandboxConfig, logger *zap.Logger) error {
if logger == nil {
logger = zap.NewNop()
}
logger = logger.Named("dev_sandbox")
if cfg.Email == "" {
logger.Info("skipped (no email)")
return nil
}
if deps.Users == nil || deps.Lobby == nil || deps.EngineVersions == nil {
return errors.New("dev_sandbox: deps.Users, deps.Lobby and deps.EngineVersions are required")
}
if cfg.PlayerCount <= 0 {
return fmt.Errorf("dev_sandbox: PlayerCount must be positive, got %d", cfg.PlayerCount)
}
if err := ensureEngineVersion(ctx, deps.EngineVersions, cfg, logger); err != nil {
return err
}
realID, err := deps.Users.EnsureByEmail(ctx, cfg.Email, "en", "UTC", "")
if err != nil {
return fmt.Errorf("dev_sandbox: ensure real user: %w", err)
}
dummyIDs := make([]uuid.UUID, 0, cfg.PlayerCount-1)
for i := 1; i < cfg.PlayerCount; i++ {
email := fmt.Sprintf("dev-dummy-%02d@local.test", i)
id, err := deps.Users.EnsureByEmail(ctx, email, "en", "UTC", "")
if err != nil {
return fmt.Errorf("dev_sandbox: ensure dummy %d: %w", i, err)
}
dummyIDs = append(dummyIDs, id)
}
if err := purgeTerminalSandboxGames(ctx, deps.Lobby, realID, logger); err != nil {
return err
}
game, err := findOrCreateSandboxGame(ctx, deps.Lobby, realID, cfg)
if err != nil {
return err
}
game, err = ensureMembershipsAndDrive(ctx, deps.Lobby, game, realID, dummyIDs, logger)
if err != nil {
return err
}
logger.Info("bootstrap complete",
zap.String("user_id", realID.String()),
zap.String("game_id", game.GameID.String()),
zap.String("status", game.Status),
)
return nil
}
func ensureEngineVersion(ctx context.Context, svc *runtime.EngineVersionService, cfg config.DevSandboxConfig, logger *zap.Logger) error {
_, err := svc.Register(ctx, runtime.RegisterInput{
Version: cfg.EngineVersion,
ImageRef: cfg.EngineImage,
})
switch {
case err == nil:
logger.Info("engine version registered",
zap.String("version", cfg.EngineVersion),
zap.String("image", cfg.EngineImage),
)
return nil
case errors.Is(err, runtime.ErrEngineVersionTaken):
logger.Debug("engine version already registered",
zap.String("version", cfg.EngineVersion),
)
return nil
default:
return fmt.Errorf("dev_sandbox: register engine version: %w", err)
}
}
// terminalSandboxStatus reports whether a sandbox game has reached a
// state from which it can no longer be driven back to running. We
// treat such games as "absent" so the next bootstrap creates a fresh
// one rather than handing the developer a dead lobby tile.
func terminalSandboxStatus(status string) bool {
switch status {
case lobby.GameStatusCancelled, lobby.GameStatusFinished, lobby.GameStatusStartFailed:
return true
}
return false
}
// purgeTerminalSandboxGames deletes every previous "Dev Sandbox" game
// the dev user owns that has reached a terminal state
// (cancelled / finished / start_failed). The cascade declared in
// `00001_init.sql` removes the matching memberships, applications,
// invites, runtime records, and player mappings in the same write,
// so the developer's lobby never piles up dead tiles between
// `make rebuild` cycles. Non-terminal games are left untouched —
// a `running` sandbox from a previous boot is the happy path.
func purgeTerminalSandboxGames(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, logger *zap.Logger) error {
games, err := svc.ListMyGames(ctx, ownerID)
if err != nil {
return fmt.Errorf("dev_sandbox: list my games: %w", err)
}
for _, g := range games {
if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID {
continue
}
if !terminalSandboxStatus(g.Status) {
continue
}
if err := svc.DeleteGame(ctx, g.GameID); err != nil {
return fmt.Errorf("dev_sandbox: delete terminal sandbox %s: %w", g.GameID, err)
}
logger.Info("purged terminal sandbox game",
zap.String("game_id", g.GameID.String()),
zap.String("status", g.Status),
)
}
return nil
}
func findOrCreateSandboxGame(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, cfg config.DevSandboxConfig) (lobby.GameRecord, error) {
games, err := svc.ListMyGames(ctx, ownerID)
if err != nil {
return lobby.GameRecord{}, fmt.Errorf("dev_sandbox: list my games: %w", err)
}
for _, g := range games {
if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID {
continue
}
// `purgeTerminalSandboxGames` ran before us, so any sandbox
// game still in the list is either a live one we should
// reuse or a transient state we can drive forward.
return g, nil
}
rec, err := svc.CreateGame(ctx, lobby.CreateGameInput{
OwnerUserID: &ownerID,
Visibility: lobby.VisibilityPrivate,
GameName: SandboxGameName,
Description: "Auto-provisioned by backend/internal/devsandbox for solo UI development.",
MinPlayers: int32(cfg.PlayerCount),
MaxPlayers: int32(cfg.PlayerCount),
StartGapHours: 0,
StartGapPlayers: 0,
EnrollmentEndsAt: time.Now().Add(365 * 24 * time.Hour),
TurnSchedule: SandboxTurnSchedule,
TargetEngineVersion: cfg.EngineVersion,
})
if err != nil {
return lobby.GameRecord{}, fmt.Errorf("dev_sandbox: create game: %w", err)
}
return rec, nil
}
func ensureMembershipsAndDrive(ctx context.Context, svc *lobby.Service, game lobby.GameRecord, realID uuid.UUID, dummyIDs []uuid.UUID, logger *zap.Logger) (lobby.GameRecord, error) {
caller := realID
if game.Status == lobby.GameStatusDraft {
next, err := svc.OpenEnrollment(ctx, &caller, false, game.GameID)
if err != nil {
return game, fmt.Errorf("dev_sandbox: open enrollment: %w", err)
}
game = next
}
if game.Status == lobby.GameStatusEnrollmentOpen {
users := append([]uuid.UUID{realID}, dummyIDs...)
for i, uid := range users {
raceName := fmt.Sprintf("Sandbox-%02d", i+1)
if _, err := svc.InsertMembershipDirect(ctx, lobby.InsertMembershipDirectInput{
GameID: game.GameID,
UserID: uid,
RaceName: raceName,
}); err != nil {
return game, fmt.Errorf("dev_sandbox: insert membership %d: %w", i+1, err)
}
}
logger.Info("memberships ensured",
zap.Int("count", len(users)),
zap.String("game_id", game.GameID.String()),
)
next, err := svc.ReadyToStart(ctx, &caller, false, game.GameID)
if err != nil {
return game, fmt.Errorf("dev_sandbox: ready to start: %w", err)
}
game = next
}
if game.Status == lobby.GameStatusReadyToStart {
next, err := svc.Start(ctx, &caller, false, game.GameID)
if err != nil {
return game, fmt.Errorf("dev_sandbox: start: %w", err)
}
game = next
}
if game.Status == lobby.GameStatusStartFailed {
next, err := svc.RetryStart(ctx, &caller, false, game.GameID)
if err != nil {
logger.Warn("retry start failed", zap.Error(err))
return game, nil
}
game = next
if game.Status == lobby.GameStatusReadyToStart {
next, err := svc.Start(ctx, &caller, false, game.GameID)
if err != nil {
return game, fmt.Errorf("dev_sandbox: start after retry: %w", err)
}
game = next
}
}
return game, nil
}
@@ -1,106 +0,0 @@
package devsandbox
import (
"context"
"errors"
"testing"
"galaxy/backend/internal/config"
"github.com/google/uuid"
"go.uber.org/zap"
)
// TestBootstrapSkippedWhenEmailEmpty exercises the no-op branch: with
// the production posture (Email == "") Bootstrap must return without
// touching any dependency. The fact that Users/Lobby/EngineVersions
// are nil here doubles as a check that the early-return runs first.
func TestBootstrapSkippedWhenEmailEmpty(t *testing.T) {
err := Bootstrap(
context.Background(),
Deps{},
config.DevSandboxConfig{},
zap.NewNop(),
)
if err != nil {
t.Fatalf("expected nil error on empty email, got: %v", err)
}
}
// TestBootstrapRejectsZeroPlayerCount confirms the validation
// short-circuits the flow before any DB call when PlayerCount is
// non-positive but Email is set. The error path is fast and never
// dereferences the (still-nil) Users/Lobby deps.
func TestBootstrapRejectsZeroPlayerCount(t *testing.T) {
err := Bootstrap(
context.Background(),
Deps{Users: stubEnsurer{}, Lobby: nil, EngineVersions: nil},
config.DevSandboxConfig{
Email: "dev@local.test",
EngineImage: "galaxy-engine:local-dev",
EngineVersion: "0.0.0-local-dev",
PlayerCount: 0,
},
zap.NewNop(),
)
if err == nil {
t.Fatal("expected error on zero PlayerCount, got nil")
}
}
// TestBootstrapRejectsMissingDeps checks that a misconfigured wiring
// (Email set but one of the required services nil) fails fast rather
// than panicking when the bootstrap reaches its first service call.
func TestBootstrapRejectsMissingDeps(t *testing.T) {
err := Bootstrap(
context.Background(),
Deps{Users: stubEnsurer{}, Lobby: nil, EngineVersions: nil},
config.DevSandboxConfig{
Email: "dev@local.test",
EngineImage: "galaxy-engine:local-dev",
EngineVersion: "0.0.0-local-dev",
PlayerCount: 20,
},
zap.NewNop(),
)
if err == nil {
t.Fatal("expected error on missing deps, got nil")
}
if !errors.Is(err, errMissingDepsSentinel) && err.Error() == "" {
// The exact wording is not part of the contract; this branch
// only asserts the error is non-nil and human-readable.
t.Fatalf("error has empty message: %v", err)
}
}
// errMissingDepsSentinel exists so the assertion above can compile;
// the real error is constructed via errors.New inside Bootstrap and
// is intentionally not exported. The test only needs to confirm the
// returned error has a message.
var errMissingDepsSentinel = errors.New("sentinel")
// TestTerminalSandboxStatus pins the contract that decides whether a
// previously created sandbox game gets purged on the next boot.
// Terminal states are deleted (cascade-style) so the developer's
// lobby never piles up dead tiles between `make rebuild` cycles.
func TestTerminalSandboxStatus(t *testing.T) {
terminal := []string{"cancelled", "finished", "start_failed"}
live := []string{"draft", "enrollment_open", "ready_to_start", "starting", "running", "paused"}
for _, status := range terminal {
if !terminalSandboxStatus(status) {
t.Errorf("expected %q to be terminal", status)
}
}
for _, status := range live {
if terminalSandboxStatus(status) {
t.Errorf("expected %q to be non-terminal", status)
}
}
}
type stubEnsurer struct{}
func (stubEnsurer) EnsureByEmail(_ context.Context, _, _, _, _ string) (uuid.UUID, error) {
return uuid.UUID{}, nil
}
+217
View File
@@ -0,0 +1,217 @@
# diplomail
`diplomail` owns the diplomatic-mail subsystem of the Galaxy backend
service. Messages live in the lobby-side domain (their storage and
lifecycle are tied to a game), but they are surfaced inside the game UI
— the lobby exposes only an unread-count badge per game.
## Stages
The package ships in four staged increments. Stage A is the surface
described below; the remaining stages add admin / system mail,
lifecycle hooks, paid-tier broadcast, multi-game broadcast, bulk
purge, and the language-detection / translation cache.
| Stage | Scope | Status |
|-------|-------|--------|
| A | Schema, personal single-recipient send / read / delete, unread badge, push event with body-language `und` | shipped |
| B | Owner / admin sends + lifecycle hooks (paused, cancelled, kick); strict soft-access for kicked players | shipped |
| C | Paid-tier personal broadcast + admin multi-game broadcast + bulk purge + admin observability | shipped |
| D | Body-language detection (whatlanggo) + translation cache + lazy per-read translator dispatch | shipped |
| E | LibreTranslate HTTP client + async translation worker with exponential backoff + delivery gating on translation completion | shipped |
## Tables
Three Postgres tables in the `backend` schema:
- `diplomail_messages` — one row per send (personal, admin, or
system). Captures `game_name` and IP at insert time so audit
rendering survives renames and purges. The `sender_race_name`
column snapshots the sender's race in the game at send time when
the sender is a player with an active membership; the in-game UI
keys per-race thread grouping on this column.
- `diplomail_recipients` — one row per (message, recipient). Holds
per-user `read_at`, `deleted_at`, `delivered_at`, `notified_at`
state. Snapshot fields (`recipient_user_name`,
`recipient_race_name`) are captured at insert time and survive
membership revocation.
- `diplomail_translations` — cached per (message, target_lang)
rendering. One translation is reused across every recipient that
asks for that language.
## Permissions
| Action | Caller | Pre-conditions |
|--------|--------|----------------|
| Send personal | user | active membership in game; recipient is active member |
| Paid-tier broadcast | paid-tier user | active membership; recipients = every other active member |
| Send admin (single user) | game owner OR site admin | recipient is any-status member of the game |
| Send admin (broadcast) | game owner OR site admin | recipient scope ∈ `active` / `active_and_removed` / `all_members`; sender excluded |
| Multi-game admin broadcast | site admin | scope `selected` (with `game_ids`) or `all_running` |
| Bulk purge | site admin | `older_than_years >= 1`; targets games with terminal status finished more than N years ago |
| Read message | the recipient | row exists in `diplomail_recipients(message_id, user_id)`; non-active members see admin-kind only |
| Mark read | the recipient | row exists; idempotent if already marked |
| Soft delete | the recipient | `read_at IS NOT NULL` (open-then-delete, item 10) |
Stage D will add body-language detection (whatlanggo) and the
translation cache + async worker.
System mail is produced internally by lobby lifecycle hooks:
`Service.transition()` emits `game.paused` / `game.cancelled` system
mail to every active member; `Service.changeMembershipStatus` /
`Service.AdminBanMember` emit `membership.removed` /
`membership.blocked` system mail addressed to the affected user.
## Content rules
- Body is plain UTF-8 text. The server does **not** parse, sanitise,
or escape HTML — the UI renders messages via `textContent`.
- Body length is capped by `BACKEND_DIPLOMAIL_MAX_BODY_BYTES` (default
4096). Subject length is capped by
`BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (default 256). Both limits
live in the service layer so they can be tuned without a schema
migration.
- `body_lang` is filled at send time by the configured
`detector.LanguageDetector` (default: `whatlanggo`, body-only,
≥ 25 runes; shorter bodies stay `und`).
## Recipient selection
`POST /messages` and `POST /admin` (when `target="user"`) accept the
recipient identifier in one of two shapes:
- `recipient_user_id` (uuid) — explicit user lookup; the recipient
may be any active member of the game.
- `recipient_race_name` (string) — resolves to the active member
with this race name in the game. Race names are unique by lobby
invariant; lobby-removed and blocked members cannot be reached
through the race-name shortcut (they no longer appear in the
active scope). Exactly one of the two fields must be supplied;
supplying both, or neither, returns `invalid_request`.
The race-name path lets the in-game UI compose mail directly off
the engine's `report.races[]` view without an extra membership
round-trip.
## Translation
Stage D adds a lazy translation cache. When a recipient reads a
message through `GET /api/v1/user/games/{game_id}/mail/messages/{id}`,
the handler resolves the caller's `accounts.preferred_language` and
asks `Service.GetMessage(…, targetLang)` to attach a translation:
- on cache hit (row in `diplomail_translations`), the rendering is
returned directly under `translated_subject` / `translated_body`;
- on cache miss, the configured `translator.Translator` is invoked.
A non-noop result is persisted and returned to the caller; the
noop translator that ships with Stage D returns `engine == "noop"`,
which is treated as "translation unavailable" and the caller falls
back to the original body.
The inbox listing (`/inbox`) reuses cached translations but never
calls the translator on miss — bulk listings stay fast even when a
real translator (LibreTranslate, SaaS engine) introduces I/O cost.
Future work plugs a real `translator.Translator` (LibreTranslate
HTTP client is the documented next step) without touching the rest
of the system.
## Async translation (Stage E)
Stage E switches the translation pipeline from "lazy at read" to
"async at send". The send path stays synchronous from the
caller's perspective: the message and recipient rows are inserted
in one transaction. What changes is delivery semantics:
- Recipients whose `preferred_language` matches the detected
`body_lang` (or whose body language is `und`) get
`available_at = now()` straight away and the push event fires
during the request.
- Recipients whose `preferred_language` differs are inserted with
`available_at IS NULL`. They are **not** visible in inbox, unread
count, or push events until the worker translates the message.
The worker (`internal/diplomail.Worker`, started as an
`app.Component` in `cmd/backend/main`) ticks once every
`BACKEND_DIPLOMAIL_WORKER_INTERVAL` (default `2s`). Each tick:
1. Picks one distinct `(message_id, recipient_preferred_language)`
pair from `diplomail_recipients` where `available_at IS NULL`
and `next_translation_attempt_at` is unset or due.
2. Loads the source message, checks the translation cache.
3. On cache hit → marks every pending recipient of the pair
delivered and emits push.
4. On cache miss → asks the configured `Translator`:
- success → caches the translation, marks delivered, push;
- HTTP 400 (unsupported pair) → marks delivered without a
translation (fallback to original);
- other failure → bumps `translation_attempts`, schedules the
retry via `next_translation_attempt_at`, leaves pending.
5. After `BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS` (default `5`)
the worker falls back to delivering the original body so a
prolonged LibreTranslate outage does not strand messages.
Retry backoff is exponential `1s → 2s → 4s → 8s → 16s` (capped at
60s) per pair. Operators monitor the LibreTranslate dependency
through standard OpenTelemetry export — translation outcomes
surface in `diplomail.worker` logs at Info / Warn levels;
Grafana / Prometheus dashboards live outside this package.
### Multi-instance posture (known limitation)
`PickPendingTranslationPair` intentionally drops `FOR UPDATE`: the
worker is single-threaded per process, and we did not want a slow
LibreTranslate HTTP call to keep a row-lock open. The cost is a
small window where two backend instances pulling at the same
moment can both claim the same pair: the cache-write side stays
clean (`INSERT … ON CONFLICT DO NOTHING`), but each instance will
publish its own push event to every recipient of the pair, so the
duplicate push is the visible failure mode.
The current deployment runs a single backend instance and the
window does not exist. When the platform scales to multiple
instances, we will revisit the pickup query — either by holding
the lock through the HTTP call (with a short timeout to bound the
worst case) or by introducing a `claimed_at` column and a
short-lived advisory lease. The change is local to this package
and does not affect callers.
For the LibreTranslate operational recipe — installing, wiring,
manual smoke test — see
[`backend/docs/diplomail-translator-setup.md`](../../docs/diplomail-translator-setup.md).
## Push integration
Every successful send emits a `diplomail.message.received` push
intent through the existing notification pipeline. The catalog entry
limits delivery to the push channel — email is intentionally absent;
the inbox endpoint is the durable fallback for offline users. The
payload includes the recipient's freshly recomputed unread count for
the lobby badge and for the in-game header.
## Lifecycle hooks (Stage B)
The lobby module is the producer of system mail. Stage B will add a
`DiplomailPublisher` collaborator on `lobby.Service` and call it on
`paused` / `cancelled` transitions and on `BlockMembership` /
`AdminBanMember`. The publisher constructs a
`kind='admin', sender_kind='system'` message with a templated body;
the recipient receives the durable copy in their inbox even after the
membership is revoked.
If a future stage adds inactivity-based player removal at the lobby
sweeper, that path **must** call the same publisher so the kicked
player has the explanation in their inbox.
## Wiring
`cmd/backend/main.go` constructs `*diplomail.Service` with three
collaborators:
- `*Store` over the shared Postgres pool;
- `MembershipLookup` adapter that walks the lobby cache for the
active `(game_id, user_id)` row and stitches in the immutable
`accounts.user_name`;
- `NotificationPublisher` adapter that translates each
`DiplomailNotification` into a `notification.Intent` and routes it
through `*notification.Service.Submit`.
+634
View File
@@ -0,0 +1,634 @@
package diplomail
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
// SendAdminPersonal persists an admin-kind message addressed to a
// single recipient and fan-outs the push event. The HTTP layer is
// responsible for the owner-vs-admin authorisation decision; this
// function trusts the caller designation it receives.
//
// The recipient may be in any membership status, so the lookup goes
// through MembershipLookup.GetMembershipAnyStatus. This lets the
// owner / admin reach a kicked player to explain the kick or follow
// up after a removal.
func (s *Service) SendAdminPersonal(ctx context.Context, in SendAdminPersonalInput) (Message, Recipient, error) {
subject, body, err := s.prepareContent(in.Subject, in.Body)
if err != nil {
return Message{}, Recipient{}, err
}
if err := validateCaller(in.CallerKind, in.CallerUserID, in.CallerUsername); err != nil {
return Message{}, Recipient{}, err
}
recipientID, err := s.resolveActiveRecipient(ctx, in.GameID, in.RecipientUserID, in.RecipientRaceName)
if err != nil {
return Message{}, Recipient{}, err
}
recipient, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, in.GameID, recipientID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not a member of the game", ErrForbidden)
}
return Message{}, Recipient{}, fmt.Errorf("diplomail: load admin recipient: %w", err)
}
msgInsert, err := s.buildAdminMessageInsert(ctx, in.CallerKind, in.CallerUserID, in.CallerUsername,
recipient.GameID, recipient.GameName, subject, body, in.SenderIP, BroadcastScopeSingle)
if err != nil {
return Message{}, Recipient{}, err
}
rcptInsert := buildRecipientInsert(msgInsert.MessageID, recipient, msgInsert.BodyLang, s.nowUTC())
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert})
if err != nil {
return Message{}, Recipient{}, fmt.Errorf("diplomail: send admin personal: %w", err)
}
if len(recipients) != 1 {
return Message{}, Recipient{}, fmt.Errorf("diplomail: send admin personal: unexpected recipient count %d", len(recipients))
}
if recipients[0].AvailableAt != nil { s.publishMessageReceived(ctx, msg, recipients[0]) }
return msg, recipients[0], nil
}
// SendAdminBroadcast persists an admin-kind broadcast addressed to
// every member matching `RecipientScope`, then emits one push event
// per recipient. The caller's own membership row, when present, is
// excluded from the recipient list — broadcasters do not get a copy
// of their own message.
func (s *Service) SendAdminBroadcast(ctx context.Context, in SendAdminBroadcastInput) (Message, []Recipient, error) {
subject, body, err := s.prepareContent(in.Subject, in.Body)
if err != nil {
return Message{}, nil, err
}
if err := validateCaller(in.CallerKind, in.CallerUserID, in.CallerUsername); err != nil {
return Message{}, nil, err
}
scope, err := normaliseScope(in.RecipientScope)
if err != nil {
return Message{}, nil, err
}
members, err := s.deps.Memberships.ListMembers(ctx, in.GameID, scope)
if err != nil {
return Message{}, nil, fmt.Errorf("diplomail: list members for broadcast: %w", err)
}
members = filterOutCaller(members, in.CallerUserID)
if len(members) == 0 {
return Message{}, nil, fmt.Errorf("%w: no recipients for broadcast", ErrInvalidInput)
}
gameName := members[0].GameName
msgInsert, err := s.buildAdminMessageInsert(ctx, in.CallerKind, in.CallerUserID, in.CallerUsername,
in.GameID, gameName, subject, body, in.SenderIP, BroadcastScopeGameBroadcast)
if err != nil {
return Message{}, nil, err
}
rcptInserts := make([]RecipientInsert, 0, len(members))
for _, m := range members {
rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
}
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
if err != nil {
return Message{}, nil, fmt.Errorf("diplomail: send admin broadcast: %w", err)
}
for _, r := range recipients {
if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
}
return msg, recipients, nil
}
// SendPlayerBroadcast persists a paid-tier player broadcast and
// fans out the push event to every other active member of the game.
// The send is `kind="personal"`, `sender_kind="player"`,
// `broadcast_scope="game_broadcast"` — recipients reply to it as if
// it were a single-recipient personal send, and the reply targets
// only the broadcaster. The caller's entitlement tier is checked
// against `EntitlementReader`; free-tier callers are rejected with
// ErrForbidden.
func (s *Service) SendPlayerBroadcast(ctx context.Context, in SendPlayerBroadcastInput) (Message, []Recipient, error) {
subject, body, err := s.prepareContent(in.Subject, in.Body)
if err != nil {
return Message{}, nil, err
}
if s.deps.Entitlements == nil {
return Message{}, nil, fmt.Errorf("%w: entitlement reader is not wired", ErrForbidden)
}
paid, err := s.deps.Entitlements.IsPaidTier(ctx, in.SenderUserID)
if err != nil {
return Message{}, nil, fmt.Errorf("diplomail: entitlement lookup: %w", err)
}
if !paid {
return Message{}, nil, fmt.Errorf("%w: in-game broadcast requires a paid tier", ErrForbidden)
}
sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return Message{}, nil, fmt.Errorf("%w: sender is not an active member of the game", ErrForbidden)
}
return Message{}, nil, fmt.Errorf("diplomail: load sender membership: %w", err)
}
members, err := s.deps.Memberships.ListMembers(ctx, in.GameID, RecipientScopeActive)
if err != nil {
return Message{}, nil, fmt.Errorf("diplomail: list active members: %w", err)
}
callerID := in.SenderUserID
members = filterOutCaller(members, &callerID)
if len(members) == 0 {
return Message{}, nil, fmt.Errorf("%w: no other active members in this game", ErrInvalidInput)
}
username := sender.UserName
senderRace := sender.RaceName
msgInsert := MessageInsert{
MessageID: uuid.New(),
GameID: in.GameID,
GameName: sender.GameName,
Kind: KindPersonal,
SenderKind: SenderKindPlayer,
SenderUserID: &callerID,
SenderUsername: &username,
SenderRaceName: &senderRace,
SenderIP: in.SenderIP,
Subject: subject,
Body: body,
BodyLang: s.deps.Detector.Detect(body),
BroadcastScope: BroadcastScopeGameBroadcast,
}
rcptInserts := make([]RecipientInsert, 0, len(members))
for _, m := range members {
rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
}
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
if err != nil {
return Message{}, nil, fmt.Errorf("diplomail: send player broadcast: %w", err)
}
for _, r := range recipients {
if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
}
return msg, recipients, nil
}
// SendAdminMultiGameBroadcast emits one admin-kind message per game
// resolved from the input scope and fans out the push events. A
// recipient who plays in multiple addressed games receives one
// independently-deletable inbox entry per game; this avoids cross-
// game leakage of admin context and keeps the per-game unread badge
// honest.
func (s *Service) SendAdminMultiGameBroadcast(ctx context.Context, in SendMultiGameBroadcastInput) ([]Message, int, error) {
subject, body, err := s.prepareContent(in.Subject, in.Body)
if err != nil {
return nil, 0, err
}
if err := validateCaller(CallerKindAdmin, nil, in.CallerUsername); err != nil {
return nil, 0, err
}
scope, err := normaliseScope(in.RecipientScope)
if err != nil {
return nil, 0, err
}
if s.deps.Games == nil {
return nil, 0, fmt.Errorf("%w: game lookup is not wired", ErrInvalidInput)
}
games, err := s.resolveMultiGameTargets(ctx, in)
if err != nil {
return nil, 0, err
}
if len(games) == 0 {
return nil, 0, fmt.Errorf("%w: no games match the broadcast scope", ErrInvalidInput)
}
totalRecipients := 0
out := make([]Message, 0, len(games))
for _, game := range games {
members, err := s.deps.Memberships.ListMembers(ctx, game.GameID, scope)
if err != nil {
return nil, 0, fmt.Errorf("diplomail: list members for %s: %w", game.GameID, err)
}
if len(members) == 0 {
s.deps.Logger.Debug("multi-game broadcast skips empty game",
zap.String("game_id", game.GameID.String()),
zap.String("scope", scope))
continue
}
msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindAdmin, nil, in.CallerUsername,
game.GameID, game.GameName, subject, body, in.SenderIP, BroadcastScopeMultiGameBroadcast)
if err != nil {
return nil, 0, err
}
rcptInserts := make([]RecipientInsert, 0, len(members))
for _, m := range members {
rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
}
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
if err != nil {
return nil, 0, fmt.Errorf("diplomail: insert multi-game broadcast for %s: %w", game.GameID, err)
}
for _, r := range recipients {
if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
}
out = append(out, msg)
totalRecipients += len(recipients)
}
return out, totalRecipients, nil
}
func (s *Service) resolveMultiGameTargets(ctx context.Context, in SendMultiGameBroadcastInput) ([]GameSnapshot, error) {
switch in.Scope {
case MultiGameScopeAllRunning:
games, err := s.deps.Games.ListRunningGames(ctx)
if err != nil {
return nil, fmt.Errorf("diplomail: list running games: %w", err)
}
return games, nil
case MultiGameScopeSelected, "":
if len(in.GameIDs) == 0 {
return nil, fmt.Errorf("%w: selected scope requires game_ids", ErrInvalidInput)
}
out := make([]GameSnapshot, 0, len(in.GameIDs))
for _, id := range in.GameIDs {
game, err := s.deps.Games.GetGame(ctx, id)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: game %s not found", ErrInvalidInput, id)
}
return nil, fmt.Errorf("diplomail: load game %s: %w", id, err)
}
out = append(out, game)
}
return out, nil
default:
return nil, fmt.Errorf("%w: unknown multi-game scope %q", ErrInvalidInput, in.Scope)
}
}
// BulkCleanup deletes every diplomail_messages row tied to games that
// finished more than `OlderThanYears` years ago. Returns the affected
// game ids and the count of removed messages. The minimum allowed
// value is 1 year — finer-grained pruning would risk wiping live
// arbitration evidence.
func (s *Service) BulkCleanup(ctx context.Context, in BulkCleanupInput) (CleanupResult, error) {
if in.OlderThanYears < 1 {
return CleanupResult{}, fmt.Errorf("%w: older_than_years must be >= 1", ErrInvalidInput)
}
if s.deps.Games == nil {
return CleanupResult{}, fmt.Errorf("%w: game lookup is not wired", ErrInvalidInput)
}
cutoff := s.nowUTC().AddDate(-in.OlderThanYears, 0, 0)
games, err := s.deps.Games.ListFinishedGamesBefore(ctx, cutoff)
if err != nil {
return CleanupResult{}, fmt.Errorf("diplomail: list finished games: %w", err)
}
if len(games) == 0 {
return CleanupResult{}, nil
}
gameIDs := make([]uuid.UUID, 0, len(games))
for _, g := range games {
gameIDs = append(gameIDs, g.GameID)
}
deleted, err := s.deps.Store.DeleteMessagesForGames(ctx, gameIDs)
if err != nil {
return CleanupResult{}, fmt.Errorf("diplomail: bulk delete: %w", err)
}
return CleanupResult{GameIDs: gameIDs, MessagesDeleted: deleted}, nil
}
// ListMessagesForAdmin returns a paginated, optionally-filtered view
// of every persisted message. Used by the admin observability
// endpoint to inspect what has been sent and trace abuse reports.
func (s *Service) ListMessagesForAdmin(ctx context.Context, filter AdminMessageListing) (AdminMessagePage, error) {
rows, total, err := s.deps.Store.ListMessagesForAdmin(ctx, filter)
if err != nil {
return AdminMessagePage{}, err
}
page := filter.Page
if page < 1 {
page = 1
}
pageSize := filter.PageSize
if pageSize < 1 {
pageSize = 50
}
return AdminMessagePage{
Items: rows,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
// PublishLifecycle persists a system-kind message in response to a
// lobby lifecycle transition and fan-outs push events to the
// affected recipients. Game-scoped transitions (`game.paused`,
// `game.cancelled`) reach every active member; membership-scoped
// transitions (`membership.removed`, `membership.blocked`) reach the
// kicked player only. Failures inside the function are logged at
// Warn level — lifecycle hooks must not block the lobby state
// machine on a downstream mail failure.
func (s *Service) PublishLifecycle(ctx context.Context, ev LifecycleEvent) error {
switch ev.Kind {
case LifecycleKindGamePaused, LifecycleKindGameCancelled:
return s.publishGameLifecycle(ctx, ev)
case LifecycleKindMembershipRemoved, LifecycleKindMembershipBlocked:
return s.publishMembershipLifecycle(ctx, ev)
default:
return fmt.Errorf("%w: unknown lifecycle kind %q", ErrInvalidInput, ev.Kind)
}
}
func (s *Service) publishGameLifecycle(ctx context.Context, ev LifecycleEvent) error {
members, err := s.deps.Memberships.ListMembers(ctx, ev.GameID, RecipientScopeActive)
if err != nil {
return fmt.Errorf("diplomail lifecycle: list members for %s: %w", ev.GameID, err)
}
if len(members) == 0 {
s.deps.Logger.Debug("lifecycle skip: no active members",
zap.String("game_id", ev.GameID.String()),
zap.String("kind", ev.Kind))
return nil
}
gameName := members[0].GameName
subject, body := renderGameLifecycle(ev.Kind, gameName, ev.Actor, ev.Reason)
msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindSystem, nil, "",
ev.GameID, gameName, subject, body, "", BroadcastScopeGameBroadcast)
if err != nil {
return err
}
rcptInserts := make([]RecipientInsert, 0, len(members))
for _, m := range members {
rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
}
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
if err != nil {
return fmt.Errorf("diplomail lifecycle: insert %s system mail: %w", ev.Kind, err)
}
for _, r := range recipients {
if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
}
return nil
}
func (s *Service) publishMembershipLifecycle(ctx context.Context, ev LifecycleEvent) error {
if ev.TargetUser == nil {
return fmt.Errorf("%w: membership lifecycle requires TargetUser", ErrInvalidInput)
}
target, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, ev.GameID, *ev.TargetUser)
if err != nil {
return fmt.Errorf("diplomail lifecycle: load target membership: %w", err)
}
subject, body := renderMembershipLifecycle(ev.Kind, target.GameName, ev.Actor, ev.Reason)
msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindSystem, nil, "",
ev.GameID, target.GameName, subject, body, "", BroadcastScopeSingle)
if err != nil {
return err
}
rcptInsert := buildRecipientInsert(msgInsert.MessageID, target, msgInsert.BodyLang, s.nowUTC())
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert})
if err != nil {
return fmt.Errorf("diplomail lifecycle: insert %s system mail: %w", ev.Kind, err)
}
if len(recipients) == 1 && recipients[0].AvailableAt != nil {
s.publishMessageReceived(ctx, msg, recipients[0])
}
return nil
}
// prepareContent normalises subject and body the same way SendPersonal
// does. Factored out so admin and lifecycle paths share the
// length-and-utf8 validation rules.
func (s *Service) prepareContent(subject, body string) (string, string, error) {
subj := strings.TrimRight(subject, " \t")
bod := strings.TrimRight(body, " \t\n")
if err := s.validateContent(subj, bod); err != nil {
return "", "", err
}
return subj, bod, nil
}
// buildAdminMessageInsert encapsulates the message-row construction
// for every admin-kind send. The CHECK constraint maps sender
// shapes:
//
// sender_kind='player' → CallerKind owner; sender_user_id set,
// sender_race_name resolved from
// Memberships.GetActiveMembership
// sender_kind='admin' → CallerKind admin; sender_user_id nil
// sender_kind='system' → CallerKind system; sender_username nil
func (s *Service) buildAdminMessageInsert(ctx context.Context, callerKind string, callerUserID *uuid.UUID, callerUsername string,
gameID uuid.UUID, gameName, subject, body, senderIP, scope string) (MessageInsert, error) {
out := MessageInsert{
MessageID: uuid.New(),
GameID: gameID,
GameName: gameName,
Kind: KindAdmin,
SenderIP: senderIP,
Subject: subject,
Body: body,
BodyLang: s.deps.Detector.Detect(body),
BroadcastScope: scope,
}
switch callerKind {
case CallerKindOwner:
if callerUserID == nil {
return MessageInsert{}, fmt.Errorf("%w: owner send requires caller user id", ErrInvalidInput)
}
uid := *callerUserID
uname := callerUsername
out.SenderKind = SenderKindPlayer
out.SenderUserID = &uid
out.SenderUsername = &uname
// Owner race snapshot is best-effort: a private-game owner who
// has an active membership in their own game contributes a
// race name; an owner who is not a current member (or whose
// membership is removed/blocked) leaves the field nil. The
// CHECK constraint accepts both shapes for sender_kind='player'.
if ownerMember, err := s.deps.Memberships.GetActiveMembership(ctx, gameID, uid); err == nil {
race := ownerMember.RaceName
out.SenderRaceName = &race
} else if !errors.Is(err, ErrNotFound) {
return MessageInsert{}, fmt.Errorf("diplomail: load owner membership: %w", err)
}
case CallerKindAdmin:
uname := callerUsername
out.SenderKind = SenderKindAdmin
out.SenderUsername = &uname
case CallerKindSystem:
out.SenderKind = SenderKindSystem
default:
return MessageInsert{}, fmt.Errorf("%w: unknown caller kind %q", ErrInvalidInput, callerKind)
}
return out, nil
}
// buildRecipientInsert turns a MemberSnapshot into a RecipientInsert.
// The race-name snapshot is nullable so a kicked player with no race
// name on file is still addressable.
//
// `bodyLang` is the detected language of the message body. When the
// recipient's preferred_language matches body_lang (or body_lang is
// undetermined), the function fills AvailableAt with `now` so the
// recipient row is materialised already-delivered; otherwise
// AvailableAt stays nil and the translation worker takes over.
func buildRecipientInsert(messageID uuid.UUID, m MemberSnapshot, bodyLang string, now time.Time) RecipientInsert {
in := RecipientInsert{
RecipientID: uuid.New(),
MessageID: messageID,
GameID: m.GameID,
UserID: m.UserID,
RecipientUserName: m.UserName,
RecipientPreferredLanguage: normaliseLang(m.PreferredLanguage),
}
if m.RaceName != "" {
race := m.RaceName
in.RecipientRaceName = &race
}
if needsTranslation(bodyLang, in.RecipientPreferredLanguage) {
// AvailableAt left nil → worker will deliver after the
// translation cache is materialised (or after fallback).
} else {
t := now.UTC()
in.AvailableAt = &t
}
return in
}
// needsTranslation reports whether a recipient with preferredLang
// needs to wait for a translated rendering before the message is
// considered delivered. Undetermined body language and empty
// recipient preferences are short-circuited to "no translation
// needed" so we never block delivery on something the detector
// could not label.
func needsTranslation(bodyLang, preferredLang string) bool {
bodyLang = normaliseLang(bodyLang)
preferredLang = normaliseLang(preferredLang)
if bodyLang == "" || bodyLang == LangUndetermined {
return false
}
if preferredLang == "" || preferredLang == LangUndetermined {
return false
}
return bodyLang != preferredLang
}
// normaliseLang strips any region subtag and lowercases the result so
// `en-US` and `EN` both collapse to `en`. The diplomail layer uses
// ISO 639-1 codes; whatlanggo and LibreTranslate share that
// vocabulary.
func normaliseLang(tag string) string {
tag = strings.TrimSpace(tag)
if tag == "" {
return ""
}
if i := strings.IndexAny(tag, "-_"); i > 0 {
tag = tag[:i]
}
return strings.ToLower(tag)
}
func validateCaller(callerKind string, callerUserID *uuid.UUID, callerUsername string) error {
switch callerKind {
case CallerKindOwner:
if callerUserID == nil {
return fmt.Errorf("%w: owner send requires caller_user_id", ErrInvalidInput)
}
if callerUsername == "" {
return fmt.Errorf("%w: owner send requires caller_username", ErrInvalidInput)
}
case CallerKindAdmin:
if callerUsername == "" {
return fmt.Errorf("%w: admin send requires caller_username", ErrInvalidInput)
}
case CallerKindSystem:
// no extra checks
default:
return fmt.Errorf("%w: unknown caller_kind %q", ErrInvalidInput, callerKind)
}
return nil
}
func normaliseScope(scope string) (string, error) {
switch scope {
case "", RecipientScopeActive:
return RecipientScopeActive, nil
case RecipientScopeActiveAndRemoved, RecipientScopeAllMembers:
return scope, nil
default:
return "", fmt.Errorf("%w: unknown recipient scope %q", ErrInvalidInput, scope)
}
}
func filterOutCaller(members []MemberSnapshot, callerUserID *uuid.UUID) []MemberSnapshot {
if callerUserID == nil {
return members
}
out := make([]MemberSnapshot, 0, len(members))
for _, m := range members {
if m.UserID == *callerUserID {
continue
}
out = append(out, m)
}
return out
}
// renderGameLifecycle returns the (subject, body) pair persisted for
// the `game.paused` / `game.cancelled` system message. Bodies are in
// English; Stage D will translate them on demand into each
// recipient's preferred_language and cache the result.
func renderGameLifecycle(kind, gameName, actor, reason string) (string, string) {
actor = strings.TrimSpace(actor)
if actor == "" {
actor = "the system"
}
reasonTail := ""
if r := strings.TrimSpace(reason); r != "" {
reasonTail = " Reason: " + r + "."
}
switch kind {
case LifecycleKindGamePaused:
return "Game paused",
fmt.Sprintf("The game %q has been paused by %s.%s", gameName, actor, reasonTail)
case LifecycleKindGameCancelled:
return "Game cancelled",
fmt.Sprintf("The game %q has been cancelled by %s.%s", gameName, actor, reasonTail)
}
return "Game lifecycle update",
fmt.Sprintf("The game %q has changed state.%s", gameName, reasonTail)
}
// renderMembershipLifecycle returns the (subject, body) pair persisted
// for the `membership.removed` / `membership.blocked` system message.
func renderMembershipLifecycle(kind, gameName, actor, reason string) (string, string) {
actor = strings.TrimSpace(actor)
if actor == "" {
actor = "the system"
}
reasonTail := ""
if r := strings.TrimSpace(reason); r != "" {
reasonTail = " Reason: " + r + "."
}
switch kind {
case LifecycleKindMembershipRemoved:
return "Membership removed",
fmt.Sprintf("Your membership in %q has been removed by %s.%s", gameName, actor, reasonTail)
case LifecycleKindMembershipBlocked:
return "Membership blocked",
fmt.Sprintf("Your membership in %q has been blocked by %s.%s", gameName, actor, reasonTail)
}
return "Membership update",
fmt.Sprintf("Your membership in %q has changed.%s", gameName, reasonTail)
}
+181
View File
@@ -0,0 +1,181 @@
package diplomail
import (
"context"
"time"
"galaxy/backend/internal/config"
"galaxy/backend/internal/diplomail/detector"
"galaxy/backend/internal/diplomail/translator"
"github.com/google/uuid"
"go.uber.org/zap"
)
// Deps aggregates every collaborator the diplomail Service depends on.
//
// Store and Memberships are required. Logger and Now default to
// zap.NewNop / time.Now when nil. Notification falls back to a no-op
// publisher so unit tests can construct a Service with only the
// required collaborators populated. Entitlements and Games are
// optional — they are used by Stage C surfaces (paid-tier player
// broadcast, multi-game admin broadcast, bulk cleanup). Wiring may
// pass nil for tests that do not exercise those paths.
type Deps struct {
Store *Store
Memberships MembershipLookup
Notification NotificationPublisher
Entitlements EntitlementReader
Games GameLookup
Detector detector.LanguageDetector
Translator translator.Translator
Config config.DiplomailConfig
Logger *zap.Logger
Now func() time.Time
}
// EntitlementReader is the read-only surface diplomail uses to gate
// the paid-tier player broadcast. The canonical implementation in
// `cmd/backend/main` reads
// `*user.Service.GetEntitlementSnapshot(userID).IsPaid`.
type EntitlementReader interface {
IsPaidTier(ctx context.Context, userID uuid.UUID) (bool, error)
}
// GameLookup exposes the slim view of `games` the multi-game admin
// broadcast and bulk-cleanup paths consume. The canonical
// implementation walks the lobby cache plus an explicit store call
// for finished-game pruning.
type GameLookup interface {
// ListRunningGames returns every game whose `status` is one of
// the still-active values (running, paused, starting, …). The
// admin `all_running` broadcast scope iterates over the result.
ListRunningGames(ctx context.Context) ([]GameSnapshot, error)
// ListFinishedGamesBefore returns every game whose `finished_at`
// is older than `cutoff`. The bulk-purge admin endpoint reads
// this to compose the cascade-delete IN list.
ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]GameSnapshot, error)
// GetGame returns one game snapshot identified by id, or
// ErrNotFound. Used by the multi-game broadcast to verify the
// caller-supplied id list before enqueuing fan-out work.
GetGame(ctx context.Context, gameID uuid.UUID) (GameSnapshot, error)
}
// GameSnapshot is the trim view of `games` consumed by the multi-game
// admin broadcast and the cleanup paths. The struct intentionally
// avoids the full `lobby.GameRecord` so the diplomail package stays
// decoupled from the lobby domain.
type GameSnapshot struct {
GameID uuid.UUID
GameName string
Status string
FinishedAt *time.Time
}
// ActiveMembership is the slim view of a single (user, game) roster
// row the diplomail package needs at send time: it confirms the
// participant is active in the game and captures the snapshot fields
// (`game_name`, `user_name`, `race_name`, `preferred_language`) that
// we persist on each new message / recipient row.
type ActiveMembership struct {
UserID uuid.UUID
GameID uuid.UUID
GameName string
UserName string
RaceName string
PreferredLanguage string
}
// MembershipLookup is the read-only surface diplomail uses to verify
// "is this user an active member of this game" and to snapshot the
// roster metadata. The canonical implementation in `cmd/backend/main`
// adapts the `*lobby.Service` membership cache to this interface.
//
// GetActiveMembership returns ErrNotFound (the diplomail sentinel)
// when the user is not an active member of the game; the service
// boundary maps that to 403 forbidden.
//
// GetMembershipAnyStatus returns the same shape regardless of
// membership status (`active`, `removed`, `blocked`). Used by the
// inbox read path to check whether a kicked recipient still belongs
// to the game's roster; ErrNotFound is surfaced when the user has
// never been a member.
//
// ListMembers returns every roster row matching scope, in stable
// order. Scope values are `active`, `active_and_removed`, and
// `all_members` (the spec calls these out by name). Used by the
// broadcast composition step in admin / owner sends.
type MembershipLookup interface {
GetActiveMembership(ctx context.Context, gameID, userID uuid.UUID) (ActiveMembership, error)
GetMembershipAnyStatus(ctx context.Context, gameID, userID uuid.UUID) (MemberSnapshot, error)
ListMembers(ctx context.Context, gameID uuid.UUID, scope string) ([]MemberSnapshot, error)
}
// Recipient scope values accepted by ListMembers and by the
// `recipients` request field on admin / owner broadcasts.
const (
RecipientScopeActive = "active"
RecipientScopeActiveAndRemoved = "active_and_removed"
RecipientScopeAllMembers = "all_members"
)
// MemberSnapshot is the slim view of a membership row that survives
// all three status values. RaceName is the immutable string captured
// at registration time; an empty value is legal for rare cases where
// the row was inserted without one. PreferredLanguage is included so
// the broadcast and lifecycle paths can decide whether the recipient
// needs to wait for a translation before delivery.
type MemberSnapshot struct {
UserID uuid.UUID
GameID uuid.UUID
GameName string
UserName string
RaceName string
PreferredLanguage string
Status string
}
// NotificationPublisher is the outbound surface diplomail uses to
// emit the `diplomail.message.received` push event. The canonical
// implementation in `cmd/backend/main` adapts the notification.Service
// the same way it adapts `lobby.NotificationPublisher`; tests pass
// the no-op publisher below to avoid wiring the dispatcher.
type NotificationPublisher interface {
PublishDiplomailEvent(ctx context.Context, ev DiplomailNotification) error
}
// DiplomailNotification is the open shape carried by a per-recipient
// push intent. The struct lives in the diplomail package so the
// producer vocabulary stays here; the publisher adapter translates it
// into a `notification.Intent` at the wiring boundary.
type DiplomailNotification struct {
Kind string
IdempotencyKey string
Recipient uuid.UUID
Payload map[string]any
}
// NewNoopNotificationPublisher returns a publisher that logs every
// call at debug level and returns nil. Used by unit tests and as the
// fallback inside NewService when callers leave Deps.Notification nil.
func NewNoopNotificationPublisher(logger *zap.Logger) NotificationPublisher {
if logger == nil {
logger = zap.NewNop()
}
return &noopNotificationPublisher{logger: logger.Named("diplomail.notify.noop")}
}
type noopNotificationPublisher struct {
logger *zap.Logger
}
func (p *noopNotificationPublisher) PublishDiplomailEvent(_ context.Context, ev DiplomailNotification) error {
p.logger.Debug("noop notification",
zap.String("kind", ev.Kind),
zap.String("idempotency_key", ev.IdempotencyKey),
zap.String("recipient", ev.Recipient.String()),
)
return nil
}
@@ -0,0 +1,79 @@
// Package detector wraps the body-language detection used by the
// diplomail subsystem. The package exposes a narrow `LanguageDetector`
// interface so the implementation can be swapped without touching the
// callers; the default backed-by-whatlanggo detector handles 84
// natural languages and ships with the embedded statistical profiles.
//
// Detection happens only on the body. Subjects are short and
// frequently template-like ("Re: ..."), so detecting on them adds
// noise. The diplomail Service feeds the body, captures the BCP 47
// tag returned here, and stores it in `diplomail_messages.body_lang`.
package detector
import (
"strings"
"unicode/utf8"
"github.com/abadojack/whatlanggo"
)
// Undetermined is the BCP 47 placeholder stored when detection cannot
// confidently identify a language (empty body, too-short body, mixed
// scripts the detector refuses to bet on).
const Undetermined = "und"
// LanguageDetector is the read-only surface diplomail consumes when
// it needs to label a message body. Detect must never panic and
// must never return an error: detection failure simply yields
// `Undetermined`.
type LanguageDetector interface {
Detect(body string) string
}
// New returns the package-default detector backed by `whatlanggo`.
// The instance is safe for concurrent use; whatlanggo's `Detect`
// reads the embedded profiles without state mutation. Callers that
// want a fixed allow-list can build their own implementation around
// the same interface.
func New() LanguageDetector {
return &whatlangDetector{}
}
type whatlangDetector struct{}
// minRunes is the lower bound on body length below which whatlanggo
// can flip between near-synonyms; for shorter bodies we return
// `Undetermined` and let the noop translator skip the slot. The
// value matches whatlanggo's documented "stable above ~25 runes"
// guidance.
const minRunes = 25
// Detect returns the BCP 47 tag for body, or `Undetermined` when the
// body is empty / too short / whatlanggo refuses to label it. The
// trim is applied so leading whitespace does not bias the script
// detector toward Latin. We deliberately do not gate on
// `info.IsReliable()` because the gate is too conservative for the
// short sentences typical of in-game mail; a misclassification only
// hurts the translation cache key, never correctness.
func (d *whatlangDetector) Detect(body string) string {
body = strings.TrimSpace(body)
if body == "" {
return Undetermined
}
if utf8.RuneCountInString(body) < minRunes {
return Undetermined
}
info := whatlanggo.Detect(body)
tag := info.Lang.Iso6391()
if tag == "" {
return Undetermined
}
return tag
}
// NoopDetector returns the placeholder unconditionally. Used by
// tests and by Stage A code paths that predate the real detector.
type NoopDetector struct{}
// Detect always returns `Undetermined` regardless of input.
func (NoopDetector) Detect(string) string { return Undetermined }
@@ -0,0 +1,49 @@
package detector
import "testing"
func TestDetectKnownLanguages(t *testing.T) {
t.Parallel()
d := New()
cases := []struct {
name string
text string
want string
}{
{
name: "english paragraph",
text: "The trade agreement should be signed before the next turn. " +
"I expect a written response by the time the engine generates the next report.",
want: "en",
},
{
name: "russian paragraph",
text: "Привет! Я предлагаю заключить дипломатическое соглашение и провести " +
"совместную операцию по освоению гиперпространственных маршрутов. " +
"Жду твоего письменного ответа до конца следующего хода игры, " +
"чтобы мы успели согласовать детали и подписать договор вовремя.",
want: "ru",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := d.Detect(tc.text)
if got != tc.want {
t.Fatalf("Detect = %q, want %q", got, tc.want)
}
})
}
}
func TestDetectShortOrEmpty(t *testing.T) {
t.Parallel()
d := New()
short := []string{"", "hi", " "}
for _, s := range short {
if got := d.Detect(s); got != Undetermined {
t.Errorf("Detect(%q) = %q, want %q", s, got, Undetermined)
}
}
}
+135
View File
@@ -0,0 +1,135 @@
// Package diplomail owns the diplomatic-mail subsystem of the Galaxy
// backend service. Messages live in the lobby-side domain (their
// storage and lifecycle are tied to a game), but they are surfaced
// in-game: lobby exposes only an unread-count badge per game while the
// in-game mail view reads and writes through this package.
//
// Stage A implements the personal single-recipient subset:
//
// - send/read/mark-read/soft-delete handlers for a player addressing
// one other active member of the game;
// - a push event (`diplomail.message.received`) materialised through
// the existing notification pipeline so the recipient gets a live
// toast when online;
// - an unread-counts endpoint that drives the lobby badge.
//
// Later stages add admin/owner/system mail, lifecycle hooks, paid-tier
// player broadcasts, multi-game broadcasts, bulk purge, and the
// language-detection / translation cache.
package diplomail
import (
"time"
"galaxy/backend/internal/config"
"galaxy/backend/internal/diplomail/detector"
"galaxy/backend/internal/diplomail/translator"
"go.uber.org/zap"
)
// Kind values stored verbatim in `diplomail_messages.kind`. The schema
// CHECK constraint pins this to the closed set declared below.
const (
// KindPersonal is a replyable player-to-player message. The
// sender is always a `sender_kind='player'`.
KindPersonal = "personal"
// KindAdmin is a non-replyable administrative notification.
// The sender is either a human admin (`sender_kind='admin'`)
// or the system itself (`sender_kind='system'`).
KindAdmin = "admin"
)
// Sender kind values stored verbatim in `diplomail_messages.sender_kind`.
const (
// SenderKindPlayer marks the sender as an end-user account.
// `sender_user_id` and `sender_username` carry the player's id
// and immutable `accounts.user_name`.
SenderKindPlayer = "player"
// SenderKindAdmin marks the sender as a site administrator.
// `sender_username` carries `admin_accounts.username`.
SenderKindAdmin = "admin"
// SenderKindSystem marks the sender as the service itself
// (lifecycle hooks). Both id and username are NULL.
SenderKindSystem = "system"
)
// Broadcast scope values stored verbatim in
// `diplomail_messages.broadcast_scope`. Stage A only emits `single`;
// Stage B / C add `game_broadcast` and `multi_game_broadcast`.
const (
BroadcastScopeSingle = "single"
BroadcastScopeGameBroadcast = "game_broadcast"
BroadcastScopeMultiGameBroadcast = "multi_game_broadcast"
)
// LangUndetermined is the BCP 47 placeholder stored in
// `diplomail_messages.body_lang` when language detection has not yet
// been performed or could not produce a result. Stage A writes this
// value unconditionally; Stage D replaces it with the detected tag.
const LangUndetermined = "und"
// Service is the diplomatic-mail entry point. Every public method is
// goroutine-safe; concurrency safety is delegated to Postgres for
// persisted state.
type Service struct {
deps Deps
}
// NewService constructs a Service from deps. Logger and Now are
// defaulted; Store must be non-nil and Memberships must be non-nil
// because every send path queries the active membership roster.
func NewService(deps Deps) *Service {
if deps.Logger == nil {
deps.Logger = zap.NewNop()
}
deps.Logger = deps.Logger.Named("diplomail")
if deps.Now == nil {
deps.Now = time.Now
}
if deps.Notification == nil {
deps.Notification = NewNoopNotificationPublisher(deps.Logger)
}
if deps.Detector == nil {
deps.Detector = detector.NoopDetector{}
}
if deps.Translator == nil {
deps.Translator = translator.NewNoop()
}
if deps.Config.MaxBodyBytes <= 0 {
deps.Config.MaxBodyBytes = 4096
}
if deps.Config.MaxSubjectBytes < 0 {
deps.Config.MaxSubjectBytes = 256
}
return &Service{deps: deps}
}
// Config returns the service's runtime configuration. Tests and the
// HTTP layer occasionally surface the limits to clients (the OpenAPI
// schema documents them too).
func (s *Service) Config() config.DiplomailConfig {
if s == nil {
return config.DiplomailConfig{}
}
return s.deps.Config
}
// Logger returns the package-named logger. Used by the optional async
// worker and by tests asserting on log output.
func (s *Service) Logger() *zap.Logger {
if s == nil {
return zap.NewNop()
}
return s.deps.Logger
}
// nowUTC returns the configured clock normalised to UTC. Matches the
// convention used everywhere else in `backend` so persisted
// timestamps compare cleanly regardless of host timezone.
func (s *Service) nowUTC() time.Time {
return s.deps.Now().UTC()
}
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
package diplomail
import "errors"
// Sentinel errors surface common rejection reasons across the
// diplomail package. Handlers map them to HTTP envelopes through
// `respondDiplomailError` in `internal/server/handlers_user_mail.go`.
//
// Adding a new sentinel here is a deliberate API change: it appears in
// the handler error map and may surface as a new wire `code` value.
// Reuse the existing set when the behaviour overlaps.
var (
// ErrInvalidInput reports request-level validation failures
// (empty body, body or subject over the configured byte limit,
// invalid UUID, non-UTF-8 bytes). Maps to 400 invalid_request.
ErrInvalidInput = errors.New("diplomail: invalid input")
// ErrNotFound reports that the requested message does not exist
// or is not visible to the caller. Maps to 404 not_found.
ErrNotFound = errors.New("diplomail: not found")
// ErrForbidden reports that the caller is authenticated but not
// authorised for the requested action (not an active member of
// the game; not a recipient of the message). Maps to 403
// forbidden.
ErrForbidden = errors.New("diplomail: forbidden")
// ErrConflict reports that the requested action conflicts with
// the current persisted state (e.g. soft-deleting a message
// that has not been marked read yet). Maps to 409 conflict.
ErrConflict = errors.New("diplomail: conflict")
)
+441
View File
@@ -0,0 +1,441 @@
package diplomail
import (
"context"
"errors"
"fmt"
"strings"
"unicode/utf8"
"github.com/google/uuid"
"go.uber.org/zap"
)
// previewMaxRunes bounds the body excerpt embedded in the push event
// so the gRPC payload stays small. The value matches the UI's
// "two lines" tease and is intentionally not configurable — clients
// drive their own truncation off the canonical fetch.
const previewMaxRunes = 120
// SendPersonal persists a single-recipient personal message and
// fan-outs a `diplomail.message.received` push event to the
// recipient. Validation rules:
//
// - both sender and recipient must be active members of GameID;
// - the recipient must differ from the sender;
// - the body must be non-empty, valid UTF-8, and within the
// configured byte limit;
// - the subject must be valid UTF-8 and within the configured
// byte limit (zero is allowed).
//
// On any rule violation the function returns ErrInvalidInput or
// ErrForbidden; the inserted Message is never persisted in those
// cases.
func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Message, Recipient, error) {
subject := strings.TrimRight(in.Subject, " \t")
body := strings.TrimRight(in.Body, " \t\n")
if err := s.validateContent(subject, body); err != nil {
return Message{}, Recipient{}, err
}
recipientID, err := s.resolveActiveRecipient(ctx, in.GameID, in.RecipientUserID, in.RecipientRaceName)
if err != nil {
return Message{}, Recipient{}, err
}
if in.SenderUserID == recipientID {
return Message{}, Recipient{}, fmt.Errorf("%w: cannot send mail to yourself", ErrInvalidInput)
}
sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return Message{}, Recipient{}, fmt.Errorf("%w: sender is not an active member of the game", ErrForbidden)
}
return Message{}, Recipient{}, fmt.Errorf("diplomail: load sender membership: %w", err)
}
recipient, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, recipientID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not an active member of the game", ErrForbidden)
}
return Message{}, Recipient{}, fmt.Errorf("diplomail: load recipient membership: %w", err)
}
username := sender.UserName
senderRace := sender.RaceName
senderUserID := in.SenderUserID
msgInsert := MessageInsert{
MessageID: uuid.New(),
GameID: in.GameID,
GameName: sender.GameName,
Kind: KindPersonal,
SenderKind: SenderKindPlayer,
SenderUserID: &senderUserID,
SenderUsername: &username,
SenderRaceName: &senderRace,
SenderIP: in.SenderIP,
Subject: subject,
Body: body,
BodyLang: s.deps.Detector.Detect(body),
BroadcastScope: BroadcastScopeSingle,
}
raceName := recipient.RaceName
rcptInsert := buildRecipientInsert(
msgInsert.MessageID,
MemberSnapshot{
UserID: recipientID,
GameID: in.GameID,
GameName: recipient.GameName,
UserName: recipient.UserName,
RaceName: raceName,
PreferredLanguage: recipient.PreferredLanguage,
Status: "active",
},
msgInsert.BodyLang,
s.nowUTC(),
)
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert})
if err != nil {
return Message{}, Recipient{}, fmt.Errorf("diplomail: send personal: %w", err)
}
if len(recipients) != 1 {
return Message{}, Recipient{}, fmt.Errorf("diplomail: send personal: unexpected recipient count %d", len(recipients))
}
if recipients[0].AvailableAt != nil {
s.publishMessageReceived(ctx, msg, recipients[0])
}
return msg, recipients[0], nil
}
// resolveActiveRecipient turns a (user_id, race_name) pair into the
// canonical user id of an active member of gameID. Exactly one of the
// two inputs must be set; both-set or both-empty returns
// ErrInvalidInput. Race-name resolution is restricted to the active
// scope so lobby-removed and blocked members cannot be reached
// through the race-name shortcut. ErrInvalidInput is also returned
// when the race name matches zero members; ErrForbidden when the
// race name matches more than one active row (defence in depth — race
// names are unique within a game by lobby invariant).
func (s *Service) resolveActiveRecipient(ctx context.Context, gameID uuid.UUID, byUserID uuid.UUID, byRaceName string) (uuid.UUID, error) {
byRaceName = strings.TrimSpace(byRaceName)
hasUser := byUserID != uuid.Nil
hasRace := byRaceName != ""
switch {
case hasUser && hasRace:
return uuid.Nil, fmt.Errorf("%w: only one of recipient_user_id, recipient_race_name may be supplied", ErrInvalidInput)
case !hasUser && !hasRace:
return uuid.Nil, fmt.Errorf("%w: recipient_user_id or recipient_race_name must be supplied", ErrInvalidInput)
case hasUser:
return byUserID, nil
}
members, err := s.deps.Memberships.ListMembers(ctx, gameID, RecipientScopeActive)
if err != nil {
return uuid.Nil, fmt.Errorf("diplomail: list active members for race lookup: %w", err)
}
var found []MemberSnapshot
for _, m := range members {
if m.RaceName == byRaceName {
found = append(found, m)
}
}
switch len(found) {
case 0:
return uuid.Nil, fmt.Errorf("%w: no active member with race %q in this game", ErrInvalidInput, byRaceName)
case 1:
return found[0].UserID, nil
default:
return uuid.Nil, fmt.Errorf("%w: race %q matches multiple active members", ErrForbidden, byRaceName)
}
}
// GetMessage returns the InboxEntry for messageID addressed to
// userID. ErrNotFound is returned when the caller is not a recipient
// of the message — handlers translate that to 404 so the existence
// of the message is not leaked. The same sentinel is returned when
// the caller is no longer an active member of the game and the
// message is personal-kind: post-kick visibility is restricted to
// admin/system mail (item 8 of the spec).
//
// When `targetLang` is non-empty and differs from the message's
// `body_lang`, the function consults the translation cache; on a
// miss it asks the configured Translator to produce a rendering and
// persists the result. The noop translator returns the input
// unchanged with `engine == "noop"`, which is treated as
// "translation unavailable" — the entry comes back with `Translation
// == nil` and the caller renders the original body.
func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID, targetLang string) (InboxEntry, error) {
entry, err := s.deps.Store.LoadInboxEntry(ctx, messageID, userID)
if err != nil {
return InboxEntry{}, err
}
allowed, err := s.allowedKinds(ctx, entry.GameID, userID)
if err != nil {
return InboxEntry{}, err
}
if !allowed[entry.Kind] {
return InboxEntry{}, ErrNotFound
}
if tr := s.resolveTranslation(ctx, entry.Message, targetLang); tr != nil {
entry.Translation = tr
}
return entry, nil
}
// resolveTranslation returns the cached translation for
// (message, targetLang), lazily computing and persisting one on
// cache miss. Returns nil when no translation is needed (target is
// empty, matches `body_lang`, or the message body is itself
// undetermined) or when the configured translator declares the
// rendering unavailable.
func (s *Service) resolveTranslation(ctx context.Context, msg Message, targetLang string) *Translation {
if targetLang == "" || targetLang == msg.BodyLang || msg.BodyLang == LangUndetermined {
return nil
}
if existing, err := s.deps.Store.LoadTranslation(ctx, msg.MessageID, targetLang); err == nil {
t := existing
return &t
} else if !errors.Is(err, ErrNotFound) {
s.deps.Logger.Warn("load translation failed",
zap.String("message_id", msg.MessageID.String()),
zap.String("target_lang", targetLang),
zap.Error(err))
return nil
}
if s.deps.Translator == nil {
return nil
}
result, err := s.deps.Translator.Translate(ctx, msg.BodyLang, targetLang, msg.Subject, msg.Body)
if err != nil {
s.deps.Logger.Warn("translator call failed",
zap.String("message_id", msg.MessageID.String()),
zap.String("target_lang", targetLang),
zap.Error(err))
return nil
}
if result.Engine == "" || result.Engine == "noop" {
return nil
}
tr := Translation{
TranslationID: uuid.New(),
MessageID: msg.MessageID,
TargetLang: targetLang,
TranslatedSubject: result.Subject,
TranslatedBody: result.Body,
Translator: result.Engine,
}
stored, err := s.deps.Store.InsertTranslation(ctx, tr)
if err != nil {
s.deps.Logger.Warn("insert translation failed",
zap.String("message_id", msg.MessageID.String()),
zap.String("target_lang", targetLang),
zap.Error(err))
return nil
}
return &stored
}
// ListInbox returns every non-deleted message addressed to userID in
// gameID, newest first. Read state is preserved per entry; the HTTP
// layer renders both the message and the recipient row. Personal
// messages are filtered out when the caller is no longer an active
// member of the game so a kicked player keeps read access to the
// admin/system explanation of the kick but not to historical
// player-to-player threads.
//
// When `targetLang` is non-empty and differs from a row's body
// language, the function consults the translation cache (without
// re-translating on miss; the per-message read endpoint owns that
// path so the bulk listing never blocks on translator I/O).
func (s *Service) ListInbox(ctx context.Context, gameID, userID uuid.UUID, targetLang string) ([]InboxEntry, error) {
entries, err := s.deps.Store.ListInbox(ctx, gameID, userID)
if err != nil {
return nil, err
}
allowed, err := s.allowedKinds(ctx, gameID, userID)
if err != nil {
return nil, err
}
out := entries
if !(allowed[KindPersonal] && allowed[KindAdmin]) {
out = make([]InboxEntry, 0, len(entries))
for _, e := range entries {
if allowed[e.Kind] {
out = append(out, e)
}
}
}
if targetLang == "" {
return out, nil
}
for i := range out {
out[i].Translation = s.lookupCachedTranslation(ctx, out[i].Message, targetLang)
}
return out, nil
}
// lookupCachedTranslation reads an existing translation row without
// asking the Translator to compute one. The bulk inbox listing uses
// this to avoid per-row translator I/O; GetMessage uses the full
// `resolveTranslation` helper which falls through to the translator
// on cache miss.
func (s *Service) lookupCachedTranslation(ctx context.Context, msg Message, targetLang string) *Translation {
if targetLang == "" || targetLang == msg.BodyLang || msg.BodyLang == LangUndetermined {
return nil
}
existing, err := s.deps.Store.LoadTranslation(ctx, msg.MessageID, targetLang)
if err != nil {
if !errors.Is(err, ErrNotFound) {
s.deps.Logger.Debug("inbox translation lookup failed",
zap.String("message_id", msg.MessageID.String()),
zap.Error(err))
}
return nil
}
out := existing
return &out
}
// allowedKinds resolves the set of message kinds the caller may read
// in gameID. An active member can read everything; a former member
// (status removed or blocked) can read admin-kind only. A user who
// has never been a member of the game but is still listed as a
// recipient (legacy / system message) is granted the same admin-only
// view. The function never returns an empty set: even non-members
// keep their read access to admin mail.
func (s *Service) allowedKinds(ctx context.Context, gameID, userID uuid.UUID) (map[string]bool, error) {
if s.deps.Memberships == nil {
return map[string]bool{KindPersonal: true, KindAdmin: true}, nil
}
if _, err := s.deps.Memberships.GetActiveMembership(ctx, gameID, userID); err == nil {
return map[string]bool{KindPersonal: true, KindAdmin: true}, nil
} else if !errors.Is(err, ErrNotFound) {
return nil, err
}
return map[string]bool{KindAdmin: true}, nil
}
// ListSent returns the sender-side view of personal messages
// authored by senderUserID in gameID, newest first. Each entry pairs
// the message with one of its recipient rows; single sends contribute
// one entry per message, broadcasts contribute one entry per
// addressee. Admin and system rows have no `sender_user_id` and are
// therefore excluded; the user surface does not need them.
func (s *Service) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]InboxEntry, error) {
return s.deps.Store.ListSent(ctx, gameID, senderUserID)
}
// MarkRead transitions a recipient row to `read`. Idempotent: a
// second call on an already-read row is a no-op. Returns the
// resulting Recipient. ErrNotFound is surfaced when the caller is
// not a recipient of the message.
func (s *Service) MarkRead(ctx context.Context, userID, messageID uuid.UUID) (Recipient, error) {
return s.deps.Store.MarkRead(ctx, messageID, userID, s.nowUTC())
}
// DeleteMessage soft-deletes the recipient row identified by
// (messageID, userID). The row must already have `read_at` set, or
// the call returns ErrConflict (item 10 of the spec: open-then-delete).
// Returns ErrNotFound when the caller is not a recipient.
func (s *Service) DeleteMessage(ctx context.Context, userID, messageID uuid.UUID) (Recipient, error) {
return s.deps.Store.SoftDelete(ctx, messageID, userID, s.nowUTC())
}
// UnreadCountsForUser returns the lobby badge breakdown.
func (s *Service) UnreadCountsForUser(ctx context.Context, userID uuid.UUID) ([]UnreadCount, error) {
return s.deps.Store.UnreadCountsForUser(ctx, userID)
}
// validateContent enforces the body/subject byte limits and rejects
// non-UTF-8 input. Stage A applies the rules to plain text only; HTML
// is treated as plain text by the server (the UI renders via
// textContent) and gets no special handling.
func (s *Service) validateContent(subject, body string) error {
if body == "" {
return fmt.Errorf("%w: body must not be empty", ErrInvalidInput)
}
if !utf8.ValidString(body) {
return fmt.Errorf("%w: body must be valid UTF-8", ErrInvalidInput)
}
if len(body) > s.deps.Config.MaxBodyBytes {
return fmt.Errorf("%w: body exceeds %d bytes", ErrInvalidInput, s.deps.Config.MaxBodyBytes)
}
if subject != "" {
if !utf8.ValidString(subject) {
return fmt.Errorf("%w: subject must be valid UTF-8", ErrInvalidInput)
}
if len(subject) > s.deps.Config.MaxSubjectBytes {
return fmt.Errorf("%w: subject exceeds %d bytes", ErrInvalidInput, s.deps.Config.MaxSubjectBytes)
}
}
return nil
}
// publishMessageReceived emits the per-recipient push notification.
// Failures are logged at debug level: notifications are best-effort
// over the gRPC stream, and clients always have the unread-counts
// endpoint as the durable fallback.
func (s *Service) publishMessageReceived(ctx context.Context, msg Message, recipient Recipient) {
unreadGame, err := s.deps.Store.UnreadCountForUserGame(ctx, msg.GameID, recipient.UserID)
if err != nil {
s.deps.Logger.Warn("compute unread count for push payload failed",
zap.String("message_id", msg.MessageID.String()),
zap.String("recipient", recipient.UserID.String()),
zap.Error(err))
unreadGame = 0
}
unreadTotals, err := s.deps.Store.UnreadCountsForUser(ctx, recipient.UserID)
if err != nil {
s.deps.Logger.Warn("compute unread totals for push payload failed",
zap.String("recipient", recipient.UserID.String()),
zap.Error(err))
unreadTotals = nil
}
unreadTotal := 0
for _, u := range unreadTotals {
unreadTotal += u.Unread
}
payload := map[string]any{
"message_id": msg.MessageID.String(),
"game_id": msg.GameID.String(),
"kind": msg.Kind,
"sender_kind": msg.SenderKind,
"subject": msg.Subject,
"preview": preview(msg.Body, previewMaxRunes),
"preview_lang": msg.BodyLang,
"unread_total": unreadTotal,
"unread_game": unreadGame,
}
ev := DiplomailNotification{
Kind: "diplomail.message.received",
IdempotencyKey: "diplomail.message.received:" + msg.MessageID.String() + ":" + recipient.UserID.String(),
Recipient: recipient.UserID,
Payload: payload,
}
if err := s.deps.Notification.PublishDiplomailEvent(ctx, ev); err != nil {
s.deps.Logger.Warn("publish diplomail event failed",
zap.String("message_id", msg.MessageID.String()),
zap.String("recipient", recipient.UserID.String()),
zap.Error(err))
}
}
// preview truncates s to at most max runes and appends a horizontal
// ellipsis when truncation actually happened. The function operates
// on runes, not bytes, so multibyte UTF-8 sequences (Cyrillic,
// emoji) survive without corruption.
func preview(s string, max int) string {
if max <= 0 || utf8.RuneCountInString(s) <= max {
return s
}
count := 0
for i := range s {
if count == max {
return s[:i] + "…"
}
count++
}
return s
}
+822
View File
@@ -0,0 +1,822 @@
package diplomail
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"galaxy/backend/internal/postgres/jet/backend/model"
"galaxy/backend/internal/postgres/jet/backend/table"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
)
// Store is the Postgres-backed query surface for the diplomail
// package. All queries are built through go-jet against the generated
// table bindings under `backend/internal/postgres/jet/backend/table`.
type Store struct {
db *sql.DB
}
// NewStore constructs a Store wrapping db.
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
// messageColumns is the canonical projection for diplomail_messages
// reads.
func messageColumns() postgres.ColumnList {
m := table.DiplomailMessages
return postgres.ColumnList{
m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind,
m.SenderUserID, m.SenderUsername, m.SenderRaceName, m.SenderIP,
m.Subject, m.Body, m.BodyLang, m.BroadcastScope, m.CreatedAt,
}
}
// recipientColumns is the canonical projection for
// diplomail_recipients reads.
func recipientColumns() postgres.ColumnList {
r := table.DiplomailRecipients
return postgres.ColumnList{
r.RecipientID, r.MessageID, r.GameID, r.UserID,
r.RecipientUserName, r.RecipientRaceName, r.RecipientPreferredLanguage,
r.AvailableAt, r.TranslationAttempts, r.NextTranslationAttemptAt,
r.DeliveredAt, r.ReadAt, r.DeletedAt, r.NotifiedAt,
}
}
// MessageInsert carries the immutable per-message fields. The store
// fills MessageID, sets CreatedAt to `now()` via the column default,
// and leaves recipient-side state to InsertRecipient.
type MessageInsert struct {
MessageID uuid.UUID
GameID uuid.UUID
GameName string
Kind string
SenderKind string
SenderUserID *uuid.UUID
SenderUsername *string
SenderRaceName *string
SenderIP string
Subject string
Body string
BodyLang string
BroadcastScope string
}
// RecipientInsert carries the per-recipient snapshot. AvailableAt
// captures the async-delivery contract: when non-nil, the recipient
// row is materialised already-delivered (no translation needed or
// the language matches); when nil, the recipient is queued for the
// translation worker.
type RecipientInsert struct {
RecipientID uuid.UUID
MessageID uuid.UUID
GameID uuid.UUID
UserID uuid.UUID
RecipientUserName string
RecipientRaceName *string
RecipientPreferredLanguage string
AvailableAt *time.Time
}
// InsertMessageWithRecipients persists a Message together with one or
// more Recipient rows inside a single transaction. The function is
// the canonical write path for every send variant: Stage A passes a
// single-element slice; later stages reuse the same path for
// broadcasts.
func (s *Store) InsertMessageWithRecipients(ctx context.Context, msg MessageInsert, recipients []RecipientInsert) (Message, []Recipient, error) {
if len(recipients) == 0 {
return Message{}, nil, errors.New("diplomail store: at least one recipient required")
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Message{}, nil, fmt.Errorf("diplomail store: begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
m := table.DiplomailMessages
msgStmt := m.INSERT(
m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind,
m.SenderUserID, m.SenderUsername, m.SenderRaceName, m.SenderIP,
m.Subject, m.Body, m.BodyLang, m.BroadcastScope,
).VALUES(
msg.MessageID,
msg.GameID,
msg.GameName,
msg.Kind,
msg.SenderKind,
uuidPtrArg(msg.SenderUserID),
stringPtrArg(msg.SenderUsername),
stringPtrArg(msg.SenderRaceName),
msg.SenderIP,
msg.Subject,
msg.Body,
msg.BodyLang,
msg.BroadcastScope,
).RETURNING(messageColumns())
var msgRow model.DiplomailMessages
if err := msgStmt.QueryContext(ctx, tx, &msgRow); err != nil {
return Message{}, nil, fmt.Errorf("diplomail store: insert message: %w", err)
}
r := table.DiplomailRecipients
rcptStmt := r.INSERT(
r.RecipientID, r.MessageID, r.GameID, r.UserID,
r.RecipientUserName, r.RecipientRaceName,
r.RecipientPreferredLanguage, r.AvailableAt,
)
for _, in := range recipients {
rcptStmt = rcptStmt.VALUES(
in.RecipientID,
in.MessageID,
in.GameID,
in.UserID,
in.RecipientUserName,
stringPtrArg(in.RecipientRaceName),
in.RecipientPreferredLanguage,
timePtrArg(in.AvailableAt),
)
}
rcptStmt = rcptStmt.RETURNING(recipientColumns())
var rcptRows []model.DiplomailRecipients
if err := rcptStmt.QueryContext(ctx, tx, &rcptRows); err != nil {
return Message{}, nil, fmt.Errorf("diplomail store: insert recipients: %w", err)
}
if err := tx.Commit(); err != nil {
return Message{}, nil, fmt.Errorf("diplomail store: commit: %w", err)
}
return messageFromModel(msgRow), recipientsFromModel(rcptRows), nil
}
// LoadMessage returns the Message row identified by messageID. The
// function is used by readers that already verified recipient
// authorisation; callers that need both the message and the
// recipient's per-user state should use LoadInboxEntry.
func (s *Store) LoadMessage(ctx context.Context, messageID uuid.UUID) (Message, error) {
m := table.DiplomailMessages
stmt := postgres.SELECT(messageColumns()).
FROM(m).
WHERE(m.MessageID.EQ(postgres.UUID(messageID))).
LIMIT(1)
var row model.DiplomailMessages
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Message{}, ErrNotFound
}
return Message{}, fmt.Errorf("diplomail store: load message %s: %w", messageID, err)
}
return messageFromModel(row), nil
}
// LoadInboxEntry returns a Message together with the caller's
// Recipient row, both for messageID. Returns ErrNotFound when the
// caller is not a recipient of the message — this is also how the
// service layer enforces "only recipients may read".
func (s *Store) LoadInboxEntry(ctx context.Context, messageID, userID uuid.UUID) (InboxEntry, error) {
m := table.DiplomailMessages
r := table.DiplomailRecipients
cols := append(messageColumns(), recipientColumns()...)
stmt := postgres.SELECT(cols).
FROM(r.INNER_JOIN(m, m.MessageID.EQ(r.MessageID))).
WHERE(
r.MessageID.EQ(postgres.UUID(messageID)).
AND(r.UserID.EQ(postgres.UUID(userID))),
).
LIMIT(1)
var dest struct {
model.DiplomailMessages
Recipient model.DiplomailRecipients `alias:"diplomail_recipients"`
}
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return InboxEntry{}, ErrNotFound
}
return InboxEntry{}, fmt.Errorf("diplomail store: load inbox entry %s/%s: %w", messageID, userID, err)
}
return InboxEntry{
Message: messageFromModel(dest.DiplomailMessages),
Recipient: recipientFromModel(dest.Recipient),
}, nil
}
// ListInbox returns the recipient view of messages addressed to
// userID in gameID, newest first. Soft-deleted rows
// (`deleted_at IS NOT NULL`) are excluded. Rows still waiting for
// the async translation worker (`available_at IS NULL`) are also
// excluded — they will appear once delivery is complete.
func (s *Store) ListInbox(ctx context.Context, gameID, userID uuid.UUID) ([]InboxEntry, error) {
m := table.DiplomailMessages
r := table.DiplomailRecipients
cols := append(messageColumns(), recipientColumns()...)
stmt := postgres.SELECT(cols).
FROM(r.INNER_JOIN(m, m.MessageID.EQ(r.MessageID))).
WHERE(
r.UserID.EQ(postgres.UUID(userID)).
AND(r.GameID.EQ(postgres.UUID(gameID))).
AND(r.DeletedAt.IS_NULL()).
AND(r.AvailableAt.IS_NOT_NULL()),
).
ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC())
var dest []struct {
model.DiplomailMessages
Recipient model.DiplomailRecipients `alias:"diplomail_recipients"`
}
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
return nil, fmt.Errorf("diplomail store: list inbox %s/%s: %w", gameID, userID, err)
}
out := make([]InboxEntry, 0, len(dest))
for _, row := range dest {
out = append(out, InboxEntry{
Message: messageFromModel(row.DiplomailMessages),
Recipient: recipientFromModel(row.Recipient),
})
}
return out, nil
}
// ListSent returns the sender-side view of personal messages
// authored by senderUserID in gameID, newest first. Each
// `InboxEntry` carries the message together with one of its
// recipient rows — single sends produce one entry per message;
// game broadcasts produce one entry per addressee (the in-game
// mail UI collapses broadcast entries into a single stand-alone
// item by `message_id`). Admin / system rows have
// `sender_user_id IS NULL` and are excluded by the WHERE clause.
func (s *Store) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]InboxEntry, error) {
m := table.DiplomailMessages
r := table.DiplomailRecipients
cols := append(messageColumns(), recipientColumns()...)
stmt := postgres.SELECT(cols).
FROM(m.INNER_JOIN(r, r.MessageID.EQ(m.MessageID))).
WHERE(
m.GameID.EQ(postgres.UUID(gameID)).
AND(m.SenderUserID.EQ(postgres.UUID(senderUserID))),
).
ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC(), r.RecipientID.ASC())
var dest []struct {
model.DiplomailMessages
Recipient model.DiplomailRecipients `alias:"diplomail_recipients"`
}
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
return nil, fmt.Errorf("diplomail store: list sent %s/%s: %w", gameID, senderUserID, err)
}
out := make([]InboxEntry, 0, len(dest))
for _, row := range dest {
out = append(out, InboxEntry{
Message: messageFromModel(row.DiplomailMessages),
Recipient: recipientFromModel(row.Recipient),
})
}
return out, nil
}
// MarkRead sets `read_at = at` on the recipient row identified by
// (messageID, userID). Idempotent: a row that is already marked read
// is left untouched but the existing Recipient is returned.
// Returns ErrNotFound when the user is not a recipient of the message.
func (s *Store) MarkRead(ctx context.Context, messageID, userID uuid.UUID, at time.Time) (Recipient, error) {
r := table.DiplomailRecipients
stmt := r.UPDATE(r.ReadAt).
SET(postgres.TimestampzT(at.UTC())).
WHERE(
r.MessageID.EQ(postgres.UUID(messageID)).
AND(r.UserID.EQ(postgres.UUID(userID))).
AND(r.ReadAt.IS_NULL()),
).
RETURNING(recipientColumns())
var row model.DiplomailRecipients
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if !errors.Is(err, qrm.ErrNoRows) {
return Recipient{}, fmt.Errorf("diplomail store: mark read %s/%s: %w", messageID, userID, err)
}
// The row exists but read_at was already set, or the row
// does not exist at all. Fetch to disambiguate.
existing, loadErr := s.LoadRecipient(ctx, messageID, userID)
if loadErr != nil {
return Recipient{}, loadErr
}
return existing, nil
}
return recipientFromModel(row), nil
}
// SoftDelete sets `deleted_at = at` on the recipient row identified by
// (messageID, userID). The row must already have `read_at` set;
// otherwise the call returns ErrConflict so a hostile client cannot
// erase a message before opening it (item 10 of the spec).
// Returns ErrNotFound when the user is not a recipient.
func (s *Store) SoftDelete(ctx context.Context, messageID, userID uuid.UUID, at time.Time) (Recipient, error) {
r := table.DiplomailRecipients
stmt := r.UPDATE(r.DeletedAt).
SET(postgres.TimestampzT(at.UTC())).
WHERE(
r.MessageID.EQ(postgres.UUID(messageID)).
AND(r.UserID.EQ(postgres.UUID(userID))).
AND(r.ReadAt.IS_NOT_NULL()).
AND(r.DeletedAt.IS_NULL()),
).
RETURNING(recipientColumns())
var row model.DiplomailRecipients
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if !errors.Is(err, qrm.ErrNoRows) {
return Recipient{}, fmt.Errorf("diplomail store: soft delete %s/%s: %w", messageID, userID, err)
}
existing, loadErr := s.LoadRecipient(ctx, messageID, userID)
if loadErr != nil {
return Recipient{}, loadErr
}
if existing.ReadAt == nil {
return Recipient{}, fmt.Errorf("%w: message must be read before delete", ErrConflict)
}
// Already deleted: return the existing row idempotently.
return existing, nil
}
return recipientFromModel(row), nil
}
// LoadRecipient fetches the Recipient row keyed on (messageID, userID).
// Returns ErrNotFound when no such recipient exists.
func (s *Store) LoadRecipient(ctx context.Context, messageID, userID uuid.UUID) (Recipient, error) {
r := table.DiplomailRecipients
stmt := postgres.SELECT(recipientColumns()).
FROM(r).
WHERE(
r.MessageID.EQ(postgres.UUID(messageID)).
AND(r.UserID.EQ(postgres.UUID(userID))),
).
LIMIT(1)
var row model.DiplomailRecipients
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Recipient{}, ErrNotFound
}
return Recipient{}, fmt.Errorf("diplomail store: load recipient %s/%s: %w", messageID, userID, err)
}
return recipientFromModel(row), nil
}
// UnreadCountForUserGame returns the count of unread, non-deleted,
// delivered messages addressed to userID in gameID. Recipients
// still waiting for translation (`available_at IS NULL`) are
// excluded so the badge does not flicker.
func (s *Store) UnreadCountForUserGame(ctx context.Context, gameID, userID uuid.UUID) (int, error) {
r := table.DiplomailRecipients
stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).
FROM(r).
WHERE(
r.UserID.EQ(postgres.UUID(userID)).
AND(r.GameID.EQ(postgres.UUID(gameID))).
AND(r.ReadAt.IS_NULL()).
AND(r.DeletedAt.IS_NULL()).
AND(r.AvailableAt.IS_NOT_NULL()),
)
var dest struct {
Count int64 `alias:"count"`
}
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
return 0, fmt.Errorf("diplomail store: unread count %s/%s: %w", gameID, userID, err)
}
return int(dest.Count), nil
}
// PendingTranslationPair carries one unit of work picked by the
// translation worker. Multiple recipients of the same message that
// share a preferred_language collapse into one pair, because the
// translation is shared via the diplomail_translations cache.
// CurrentAttempts is the highest `translation_attempts` value across
// the matching recipient rows, so the worker can decide whether the
// next attempt is the last one before falling back.
type PendingTranslationPair struct {
MessageID uuid.UUID
TargetLang string
CurrentAttempts int32
}
// PickPendingTranslationPair returns one pair eligible for the
// translation worker, or `ok == false` when the queue is empty. The
// pair is the (message, target_lang) of any recipient where
// `available_at IS NULL` and `next_translation_attempt_at` is either
// unset or already due. The query intentionally drops the
// `FOR UPDATE` clause — the worker is single-threaded per process,
// and the optimistic UPDATE in `MarkPairDelivered` /
// `MarkPairFallback` filters by `available_at IS NULL`, so a stale
// pickup never delivers twice.
func (s *Store) PickPendingTranslationPair(ctx context.Context, now time.Time) (PendingTranslationPair, bool, error) {
r := table.DiplomailRecipients
stmt := postgres.SELECT(
r.MessageID.AS("message_id"),
r.RecipientPreferredLanguage.AS("target_lang"),
postgres.MAX(r.TranslationAttempts).AS("attempts"),
).
FROM(r).
WHERE(
r.AvailableAt.IS_NULL().
AND(r.RecipientPreferredLanguage.NOT_EQ(postgres.String(""))).
AND(r.NextTranslationAttemptAt.IS_NULL().
OR(r.NextTranslationAttemptAt.LT_EQ(postgres.TimestampzT(now.UTC())))),
).
GROUP_BY(r.MessageID, r.RecipientPreferredLanguage).
ORDER_BY(r.MessageID.ASC(), r.RecipientPreferredLanguage.ASC()).
LIMIT(1)
var dest struct {
MessageID uuid.UUID `alias:"message_id"`
TargetLang string `alias:"target_lang"`
Attempts int32 `alias:"attempts"`
}
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return PendingTranslationPair{}, false, nil
}
return PendingTranslationPair{}, false, fmt.Errorf("diplomail store: pick pending pair: %w", err)
}
if dest.MessageID == (uuid.UUID{}) {
return PendingTranslationPair{}, false, nil
}
return PendingTranslationPair{
MessageID: dest.MessageID,
TargetLang: dest.TargetLang,
CurrentAttempts: dest.Attempts,
}, true, nil
}
// MarkPairDelivered flips every still-pending recipient of (messageID,
// targetLang) to `available_at = at`, optionally persisting the
// translation row alongside in the same transaction. Returns the
// recipients that were just delivered (used by the worker to fan out
// push events).
func (s *Store) MarkPairDelivered(ctx context.Context, messageID uuid.UUID, targetLang string, translation *Translation, at time.Time) ([]Recipient, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("diplomail store: begin deliver tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if translation != nil {
t := table.DiplomailTranslations
ins := t.INSERT(
t.TranslationID, t.MessageID, t.TargetLang,
t.TranslatedSubject, t.TranslatedBody, t.Translator,
).VALUES(
translation.TranslationID, translation.MessageID, translation.TargetLang,
translation.TranslatedSubject, translation.TranslatedBody, translation.Translator,
).ON_CONFLICT(t.MessageID, t.TargetLang).DO_NOTHING()
if _, err := ins.ExecContext(ctx, tx); err != nil {
return nil, fmt.Errorf("diplomail store: upsert translation: %w", err)
}
}
r := table.DiplomailRecipients
upd := r.UPDATE(r.AvailableAt, r.NextTranslationAttemptAt).
SET(postgres.TimestampzT(at.UTC()), postgres.NULL).
WHERE(
r.MessageID.EQ(postgres.UUID(messageID)).
AND(r.RecipientPreferredLanguage.EQ(postgres.String(targetLang))).
AND(r.AvailableAt.IS_NULL()),
).
RETURNING(recipientColumns())
var rows []model.DiplomailRecipients
if err := upd.QueryContext(ctx, tx, &rows); err != nil {
return nil, fmt.Errorf("diplomail store: mark pair delivered: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("diplomail store: commit deliver: %w", err)
}
out := make([]Recipient, 0, len(rows))
for _, row := range rows {
out = append(out, recipientFromModel(row))
}
return out, nil
}
// SchedulePairRetry bumps the attempt counter and schedules the next
// translation attempt for `next`. The recipient rows stay in the
// pending queue (`available_at IS NULL`). Returns the new attempt
// counter so the worker can decide whether to fall back to the
// original on the next pickup.
func (s *Store) SchedulePairRetry(ctx context.Context, messageID uuid.UUID, targetLang string, next time.Time) (int32, error) {
r := table.DiplomailRecipients
upd := r.UPDATE(r.TranslationAttempts, r.NextTranslationAttemptAt).
SET(r.TranslationAttempts.ADD(postgres.Int(1)), postgres.TimestampzT(next.UTC())).
WHERE(
r.MessageID.EQ(postgres.UUID(messageID)).
AND(r.RecipientPreferredLanguage.EQ(postgres.String(targetLang))).
AND(r.AvailableAt.IS_NULL()),
).
RETURNING(r.TranslationAttempts)
var dest []struct {
TranslationAttempts int32 `alias:"diplomail_recipients.translation_attempts"`
}
if err := upd.QueryContext(ctx, s.db, &dest); err != nil {
return 0, fmt.Errorf("diplomail store: schedule pair retry: %w", err)
}
if len(dest) == 0 {
return 0, nil
}
max := dest[0].TranslationAttempts
for _, d := range dest[1:] {
if d.TranslationAttempts > max {
max = d.TranslationAttempts
}
}
return max, nil
}
// translationColumns is the canonical projection for
// diplomail_translations reads.
func translationColumns() postgres.ColumnList {
t := table.DiplomailTranslations
return postgres.ColumnList{
t.TranslationID, t.MessageID, t.TargetLang,
t.TranslatedSubject, t.TranslatedBody, t.Translator, t.TranslatedAt,
}
}
// LoadTranslation returns the cached translation row for
// (messageID, targetLang). Returns ErrNotFound when no cache row
// exists yet — the caller decides whether to compute and persist
// one.
func (s *Store) LoadTranslation(ctx context.Context, messageID uuid.UUID, targetLang string) (Translation, error) {
t := table.DiplomailTranslations
stmt := postgres.SELECT(translationColumns()).
FROM(t).
WHERE(t.MessageID.EQ(postgres.UUID(messageID)).
AND(t.TargetLang.EQ(postgres.String(targetLang)))).
LIMIT(1)
var row model.DiplomailTranslations
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Translation{}, ErrNotFound
}
return Translation{}, fmt.Errorf("diplomail store: load translation %s/%s: %w", messageID, targetLang, err)
}
return translationFromModel(row), nil
}
// InsertTranslation persists a new translation cache row. The unique
// constraint on (message_id, target_lang) prevents duplicate
// renderings. Callers that race on the same (message, lang) pair
// should be prepared for a UNIQUE violation; the second writer can
// fall back to LoadTranslation.
func (s *Store) InsertTranslation(ctx context.Context, in Translation) (Translation, error) {
t := table.DiplomailTranslations
stmt := t.INSERT(
t.TranslationID, t.MessageID, t.TargetLang,
t.TranslatedSubject, t.TranslatedBody, t.Translator,
).VALUES(
in.TranslationID, in.MessageID, in.TargetLang,
in.TranslatedSubject, in.TranslatedBody, in.Translator,
).RETURNING(translationColumns())
var row model.DiplomailTranslations
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
return Translation{}, fmt.Errorf("diplomail store: insert translation %s/%s: %w", in.MessageID, in.TargetLang, err)
}
return translationFromModel(row), nil
}
func translationFromModel(row model.DiplomailTranslations) Translation {
return Translation{
TranslationID: row.TranslationID,
MessageID: row.MessageID,
TargetLang: row.TargetLang,
TranslatedSubject: row.TranslatedSubject,
TranslatedBody: row.TranslatedBody,
Translator: row.Translator,
TranslatedAt: row.TranslatedAt,
}
}
// DeleteMessagesForGames removes every diplomail_messages row whose
// game_id falls in the supplied set. The cascade defined on the
// `diplomail_recipients` and `diplomail_translations` foreign keys
// removes the per-recipient state and the cached translations in
// the same transaction. Returns the count of messages removed.
//
// Used by the admin bulk-purge endpoint; callers are expected to
// have already filtered the input set to terminal-state games.
func (s *Store) DeleteMessagesForGames(ctx context.Context, gameIDs []uuid.UUID) (int, error) {
if len(gameIDs) == 0 {
return 0, nil
}
args := make([]postgres.Expression, 0, len(gameIDs))
for _, id := range gameIDs {
args = append(args, postgres.UUID(id))
}
m := table.DiplomailMessages
stmt := m.DELETE().WHERE(m.GameID.IN(args...))
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return 0, fmt.Errorf("diplomail store: bulk delete messages: %w", err)
}
affected, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("diplomail store: rows affected: %w", err)
}
return int(affected), nil
}
// ListMessagesForAdmin returns a paginated slice of messages
// matching filter. The result is ordered by created_at DESC,
// message_id DESC. Total is the count without pagination so the
// caller can render a "page X of N" envelope.
func (s *Store) ListMessagesForAdmin(ctx context.Context, filter AdminMessageListing) ([]Message, int, error) {
m := table.DiplomailMessages
page := filter.Page
if page < 1 {
page = 1
}
pageSize := filter.PageSize
if pageSize < 1 {
pageSize = 50
}
conditions := postgres.BoolExpression(nil)
addCondition := func(cond postgres.BoolExpression) {
if conditions == nil {
conditions = cond
return
}
conditions = conditions.AND(cond)
}
if filter.GameID != nil {
addCondition(m.GameID.EQ(postgres.UUID(*filter.GameID)))
}
if filter.Kind != "" {
addCondition(m.Kind.EQ(postgres.String(filter.Kind)))
}
if filter.SenderKind != "" {
addCondition(m.SenderKind.EQ(postgres.String(filter.SenderKind)))
}
countStmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).FROM(m)
if conditions != nil {
countStmt = countStmt.WHERE(conditions)
}
var countDest struct {
Count int64 `alias:"count"`
}
if err := countStmt.QueryContext(ctx, s.db, &countDest); err != nil {
return nil, 0, fmt.Errorf("diplomail store: count admin messages: %w", err)
}
listStmt := postgres.SELECT(messageColumns()).FROM(m)
if conditions != nil {
listStmt = listStmt.WHERE(conditions)
}
listStmt = listStmt.
ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC()).
LIMIT(int64(pageSize)).
OFFSET(int64((page - 1) * pageSize))
var rows []model.DiplomailMessages
if err := listStmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, 0, fmt.Errorf("diplomail store: list admin messages: %w", err)
}
out := make([]Message, 0, len(rows))
for _, row := range rows {
out = append(out, messageFromModel(row))
}
return out, int(countDest.Count), nil
}
// UnreadCountsForUser returns a per-game breakdown of unread messages
// addressed to userID, plus the matching game names so the lobby
// badge UI can render entries even after the recipient's membership
// has been revoked. The slice is ordered by game name.
func (s *Store) UnreadCountsForUser(ctx context.Context, userID uuid.UUID) ([]UnreadCount, error) {
r := table.DiplomailRecipients
m := table.DiplomailMessages
stmt := postgres.SELECT(
r.GameID.AS("game_id"),
postgres.MAX(m.GameName).AS("game_name"),
postgres.COUNT(postgres.STAR).AS("count"),
).
FROM(r.INNER_JOIN(m, m.MessageID.EQ(r.MessageID))).
WHERE(
r.UserID.EQ(postgres.UUID(userID)).
AND(r.ReadAt.IS_NULL()).
AND(r.DeletedAt.IS_NULL()).
AND(r.AvailableAt.IS_NOT_NULL()),
).
GROUP_BY(r.GameID).
ORDER_BY(postgres.MAX(m.GameName).ASC())
var dest []struct {
GameID uuid.UUID `alias:"game_id"`
GameName string `alias:"game_name"`
Count int64 `alias:"count"`
}
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
return nil, fmt.Errorf("diplomail store: unread counts %s: %w", userID, err)
}
out := make([]UnreadCount, 0, len(dest))
for _, row := range dest {
out = append(out, UnreadCount{
GameID: row.GameID,
GameName: row.GameName,
Unread: int(row.Count),
})
}
return out, nil
}
// messageFromModel converts a jet-generated row to the domain type.
func messageFromModel(row model.DiplomailMessages) Message {
out := Message{
MessageID: row.MessageID,
GameID: row.GameID,
GameName: row.GameName,
Kind: row.Kind,
SenderKind: row.SenderKind,
SenderIP: row.SenderIP,
Subject: row.Subject,
Body: row.Body,
BodyLang: row.BodyLang,
BroadcastScope: row.BroadcastScope,
CreatedAt: row.CreatedAt,
}
if row.SenderUserID != nil {
id := *row.SenderUserID
out.SenderUserID = &id
}
if row.SenderUsername != nil {
name := *row.SenderUsername
out.SenderUsername = &name
}
if row.SenderRaceName != nil {
name := *row.SenderRaceName
out.SenderRaceName = &name
}
return out
}
// recipientFromModel converts a jet-generated row to the domain type.
func recipientFromModel(row model.DiplomailRecipients) Recipient {
out := Recipient{
RecipientID: row.RecipientID,
MessageID: row.MessageID,
GameID: row.GameID,
UserID: row.UserID,
RecipientUserName: row.RecipientUserName,
RecipientPreferredLanguage: row.RecipientPreferredLanguage,
AvailableAt: row.AvailableAt,
TranslationAttempts: row.TranslationAttempts,
NextTranslationAttemptAt: row.NextTranslationAttemptAt,
DeliveredAt: row.DeliveredAt,
ReadAt: row.ReadAt,
DeletedAt: row.DeletedAt,
NotifiedAt: row.NotifiedAt,
}
if row.RecipientRaceName != nil {
name := *row.RecipientRaceName
out.RecipientRaceName = &name
}
return out
}
// recipientsFromModel converts a slice in place. Used by
// InsertMessageWithRecipients.
func recipientsFromModel(rows []model.DiplomailRecipients) []Recipient {
out := make([]Recipient, 0, len(rows))
for _, row := range rows {
out = append(out, recipientFromModel(row))
}
return out
}
// uuidPtrArg returns the jet argument expression for a nullable UUID.
// Pre-NULL handling here avoids a custom NULL literal at every call
// site.
func uuidPtrArg(v *uuid.UUID) postgres.Expression {
if v == nil {
return postgres.NULL
}
return postgres.UUID(*v)
}
// stringPtrArg returns the jet argument expression for a nullable
// text column.
func stringPtrArg(v *string) postgres.Expression {
if v == nil {
return postgres.NULL
}
return postgres.String(*v)
}
// timePtrArg returns the jet argument expression for a nullable
// timestamptz column.
func timePtrArg(v *time.Time) postgres.Expression {
if v == nil {
return postgres.NULL
}
return postgres.TimestampzT(v.UTC())
}
@@ -0,0 +1,154 @@
package translator
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// LibreTranslateEngine is the engine identifier persisted in
// `diplomail_translations.translator` for cache rows produced by the
// LibreTranslate client.
const LibreTranslateEngine = "libretranslate"
// LibreTranslateConfig configures the HTTP client. URL is the base
// of the deployed instance (without `/translate`). Timeout bounds a
// single HTTP request; the worker layers retry / backoff on top.
type LibreTranslateConfig struct {
URL string
Timeout time.Duration
}
// ErrUnsupportedLanguagePair classifies a LibreTranslate 400 response
// that indicates the engine cannot translate between the requested
// source / target codes. The worker treats this as terminal: no
// further retries, deliver the original.
var ErrUnsupportedLanguagePair = errors.New("translator: language pair not supported by libretranslate")
// NewLibreTranslate constructs a Translator that posts to
// `<URL>/translate`. Returns an error when URL is empty so wiring
// catches "translator misconfigured" at startup rather than at
// first-translation-attempt.
func NewLibreTranslate(cfg LibreTranslateConfig) (Translator, error) {
url := strings.TrimRight(strings.TrimSpace(cfg.URL), "/")
if url == "" {
return nil, errors.New("translator: libretranslate URL must be set")
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
return &libreTranslate{
endpoint: url + "/translate",
client: &http.Client{Timeout: timeout},
}, nil
}
type libreTranslate struct {
endpoint string
client *http.Client
}
// requestBody is the LibreTranslate POST /translate input shape.
// `q` is sent as a two-element array so the engine returns one
// translation per element in the same call (subject + body).
type requestBody struct {
Q []string `json:"q"`
Source string `json:"source"`
Target string `json:"target"`
Format string `json:"format"`
}
// responseBody is the LibreTranslate output shape when `q` is an
// array. The single-string-q variant is a different shape; we never
// emit a single-q request so the client always sees the array form.
type responseBody struct {
TranslatedText []string `json:"translatedText"`
Error string `json:"error,omitempty"`
}
// Translate posts subject + body to LibreTranslate, normalising the
// language codes and classifying the response. The 400 / unsupported-
// pair path is signalled by `ErrUnsupportedLanguagePair`. All other
// HTTP errors (timeout, 5xx, network failure) come back as wrapped
// errors so the worker can backoff and retry.
func (l *libreTranslate) Translate(ctx context.Context, srcLang, dstLang, subject, body string) (Result, error) {
src := normaliseLanguageCode(srcLang)
dst := normaliseLanguageCode(dstLang)
if src == "" || dst == "" {
return Result{}, fmt.Errorf("translator: missing source or target language (src=%q dst=%q)", srcLang, dstLang)
}
if src == dst {
return Result{Subject: subject, Body: body, Engine: NoopEngine}, nil
}
reqBody, err := json.Marshal(requestBody{
Q: []string{subject, body},
Source: src,
Target: dst,
Format: "text",
})
if err != nil {
return Result{}, fmt.Errorf("translator: marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, l.endpoint, bytes.NewReader(reqBody))
if err != nil {
return Result{}, fmt.Errorf("translator: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := l.client.Do(req)
if err != nil {
return Result{}, fmt.Errorf("translator: do request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return Result{}, fmt.Errorf("translator: read response: %w", err)
}
if resp.StatusCode == http.StatusBadRequest {
return Result{}, fmt.Errorf("%w: %s", ErrUnsupportedLanguagePair, strings.TrimSpace(string(raw)))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return Result{}, fmt.Errorf("translator: libretranslate http %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var out responseBody
if err := json.Unmarshal(raw, &out); err != nil {
return Result{}, fmt.Errorf("translator: unmarshal response: %w", err)
}
if out.Error != "" {
return Result{}, fmt.Errorf("translator: libretranslate error: %s", out.Error)
}
if len(out.TranslatedText) != 2 {
return Result{}, fmt.Errorf("translator: libretranslate returned %d strings, want 2", len(out.TranslatedText))
}
return Result{
Subject: out.TranslatedText[0],
Body: out.TranslatedText[1],
Engine: LibreTranslateEngine,
}, nil
}
// normaliseLanguageCode collapses a BCP 47 tag to the ISO 639-1 base
// that LibreTranslate expects (`en-US` → `en`, `EN` → `en`). The
// helper is mirrored on the diplomail service side; both sides need
// to use the same normalisation so cache keys line up.
func normaliseLanguageCode(tag string) string {
tag = strings.TrimSpace(tag)
if tag == "" {
return ""
}
if i := strings.IndexAny(tag, "-_"); i > 0 {
tag = tag[:i]
}
return strings.ToLower(tag)
}
@@ -0,0 +1,173 @@
package translator
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestLibreTranslateHappyPath(t *testing.T) {
t.Parallel()
var (
requestSource string
requestTarget string
requestQ []string
requestFormat string
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var in requestBody
if err := json.Unmarshal(body, &in); err != nil {
t.Errorf("unmarshal: %v", err)
}
requestSource = in.Source
requestTarget = in.Target
requestQ = in.Q
requestFormat = in.Format
_ = json.NewEncoder(w).Encode(responseBody{
TranslatedText: []string{"[ru] " + in.Q[0], "[ru] " + in.Q[1]},
})
}))
t.Cleanup(server.Close)
tr, err := NewLibreTranslate(LibreTranslateConfig{URL: server.URL, Timeout: 2 * time.Second})
if err != nil {
t.Fatalf("new: %v", err)
}
res, err := tr.Translate(context.Background(), "en", "ru", "Hello", "World")
if err != nil {
t.Fatalf("translate: %v", err)
}
if res.Engine != LibreTranslateEngine {
t.Fatalf("engine = %q, want %q", res.Engine, LibreTranslateEngine)
}
if res.Subject != "[ru] Hello" || res.Body != "[ru] World" {
t.Fatalf("result = %+v", res)
}
if requestSource != "en" || requestTarget != "ru" || requestFormat != "text" {
t.Fatalf("request fields: src=%q dst=%q fmt=%q", requestSource, requestTarget, requestFormat)
}
if len(requestQ) != 2 || requestQ[0] != "Hello" || requestQ[1] != "World" {
t.Fatalf("request q = %v", requestQ)
}
}
func TestLibreTranslateNormalisesLanguageCodes(t *testing.T) {
t.Parallel()
var src, dst string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var in requestBody
_ = json.Unmarshal(body, &in)
src, dst = in.Source, in.Target
_ = json.NewEncoder(w).Encode(responseBody{TranslatedText: []string{"a", "b"}})
}))
t.Cleanup(server.Close)
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
if _, err := tr.Translate(context.Background(), "EN-US", "ru-RU", "x", "y"); err != nil {
t.Fatalf("translate: %v", err)
}
if src != "en" || dst != "ru" {
t.Fatalf("normalised codes src=%q dst=%q, want en/ru", src, dst)
}
}
func TestLibreTranslateUnsupportedPair(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"language not supported"}`))
}))
t.Cleanup(server.Close)
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
_, err := tr.Translate(context.Background(), "en", "xx", "subject", "body")
if !errors.Is(err, ErrUnsupportedLanguagePair) {
t.Fatalf("err = %v, want ErrUnsupportedLanguagePair", err)
}
}
func TestLibreTranslateServerError(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("kaboom"))
}))
t.Cleanup(server.Close)
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
_, err := tr.Translate(context.Background(), "en", "ru", "subject", "body")
if err == nil {
t.Fatalf("expected error, got nil")
}
if errors.Is(err, ErrUnsupportedLanguagePair) {
t.Fatalf("err mis-classified as unsupported pair: %v", err)
}
if !strings.Contains(err.Error(), "500") {
t.Fatalf("err = %v, want mention of 500", err)
}
}
func TestLibreTranslateSameSourceAndTargetIsNoop(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("translator should not call the server for identical src/dst: %s", r.URL.Path)
}))
t.Cleanup(server.Close)
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
res, err := tr.Translate(context.Background(), "en", "EN", "x", "y")
if err != nil {
t.Fatalf("translate: %v", err)
}
if res.Engine != NoopEngine {
t.Fatalf("engine = %q, want %q", res.Engine, NoopEngine)
}
}
func TestLibreTranslateRequiresURL(t *testing.T) {
t.Parallel()
_, err := NewLibreTranslate(LibreTranslateConfig{URL: ""})
if err == nil {
t.Fatalf("expected error for empty URL")
}
}
// TestLibreTranslateRejectsMalformedArray defends against a server
// that returns a partial / unexpected `translatedText` payload. The
// client must surface an error (not panic, not return a half-empty
// Result) so the worker can decide between retry and fallback.
func TestLibreTranslateRejectsMalformedArray(t *testing.T) {
t.Parallel()
cases := []struct {
name string
body string
}{
{"single string", `{"translatedText": "only one"}`},
{"array of one", `{"translatedText": ["only one"]}`},
{"empty array", `{"translatedText": []}`},
{"missing field", `{"foo":"bar"}`},
}
for _, tc := range cases {
body := tc.body
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(body))
}))
t.Cleanup(server.Close)
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
res, err := tr.Translate(context.Background(), "en", "ru", "subject", "body")
if err == nil {
t.Fatalf("expected error for malformed body %q, got %+v", body, res)
}
})
}
}
@@ -0,0 +1,59 @@
// Package translator wraps the per-language rendering for the
// diplomail subsystem. The package exposes a narrow `Translator`
// interface so the actual translation backend (LibreTranslate, an
// in-process model, a SaaS engine, …) can be swapped without
// touching the rest of the codebase.
//
// Stage D ships a `NoopTranslator` that returns the input unchanged.
// The diplomail Service treats a `Name == NoopEngine` result as
// "translation unavailable" and refrains from writing a cache row;
// the inbox handler then returns the original body with a
// `translated == false` payload. The contract lets the rest of the
// system ship without a translation backend; future stages can wire
// a real `Translator` without code changes elsewhere.
package translator
import "context"
// NoopEngine is the engine identifier returned by `NoopTranslator`.
// The diplomail Service checks for this value to decide whether to
// persist a `diplomail_translations` row.
const NoopEngine = "noop"
// Result carries one translated rendering plus the engine identifier
// that produced it. The engine name is persisted as
// `diplomail_translations.translator` so an operator can see which
// backend produced each row.
type Result struct {
Subject string
Body string
Engine string
}
// Translator is the read-only surface diplomail consumes when it
// needs to render a message for a recipient whose
// `preferred_language` differs from `body_lang`. Implementations
// must be safe for concurrent use; `Translate` may be invoked from
// the async worker on many messages at once.
type Translator interface {
// Translate renders `subject` and `body` from `srcLang` into
// `dstLang`. A nil error with `Result.Engine == NoopEngine`
// signals that no real rendering happened.
Translate(ctx context.Context, srcLang, dstLang, subject, body string) (Result, error)
}
// NewNoop returns a Translator that always returns the input
// unchanged with engine name `NoopEngine`.
func NewNoop() Translator {
return noop{}
}
type noop struct{}
func (noop) Translate(_ context.Context, _, _, subject, body string) (Result, error) {
return Result{
Subject: subject,
Body: body,
Engine: NoopEngine,
}, nil
}
+267
View File
@@ -0,0 +1,267 @@
package diplomail
import (
"time"
"github.com/google/uuid"
)
// Message mirrors a row in `backend.diplomail_messages` enriched with
// the per-message metadata captured at insert time.
//
// SenderUserID and SenderUsername are nullable in the DB so that the
// CHECK constraint can cover the three legal sender shapes:
//
// - player: SenderUserID set, SenderUsername set
// - admin: SenderUserID nil, SenderUsername set
// - system: SenderUserID nil, SenderUsername nil
type Message struct {
MessageID uuid.UUID
GameID uuid.UUID
GameName string
Kind string
SenderKind string
SenderUserID *uuid.UUID
SenderUsername *string
// SenderRaceName carries the snapshot of the sender's race in the
// game at send time. Non-nil for sender_kind='player' rows, nil
// for admin and system. The in-game mail UI groups personal
// threads by this name (Phase 28).
SenderRaceName *string
SenderIP string
Subject string
Body string
BodyLang string
BroadcastScope string
CreatedAt time.Time
}
// Recipient mirrors a row in `backend.diplomail_recipients`. The
// per-recipient state (read/deleted/delivered/notified) lives here.
// RecipientUserName, RecipientRaceName, and
// RecipientPreferredLanguage are snapshots taken at insert time so
// the inbox listing, admin search, and translation worker render
// correctly even after the source rows are renamed or revoked.
//
// AvailableAt encodes the async-translation contract introduced in
// Stage E:
//
// - non-nil → message is visible to the recipient (in inbox /
// unread counts / push events) starting from this timestamp;
// - nil → recipient is waiting for the translation worker to fan
// out the translated rendering. The translation_attempts counter
// tracks the number of failed LibreTranslate calls; the worker
// gives up after `MaxTranslationAttempts` and falls back to the
// original body, flipping AvailableAt to now().
type Recipient struct {
RecipientID uuid.UUID
MessageID uuid.UUID
GameID uuid.UUID
UserID uuid.UUID
RecipientUserName string
RecipientRaceName *string
RecipientPreferredLanguage string
AvailableAt *time.Time
TranslationAttempts int32
NextTranslationAttemptAt *time.Time
DeliveredAt *time.Time
ReadAt *time.Time
DeletedAt *time.Time
NotifiedAt *time.Time
}
// InboxEntry is the read-side projection composed of a Message and the
// caller's own Recipient row. The HTTP layer renders one of these per
// item in the inbox listing. Translation, when non-nil, carries the
// per-recipient rendering returned from
// `Service.GetMessage(ctx, …, targetLang)` and surfaced under the
// `body_translated` payload field; Stage D ships a noop translator,
// so this field stays nil until a real backend is wired.
type InboxEntry struct {
Message
Recipient Recipient
Translation *Translation
}
// Translation mirrors a row in `backend.diplomail_translations`. The
// engine identifier is preserved so an operator can see which
// backend produced the cached rendering.
type Translation struct {
TranslationID uuid.UUID
MessageID uuid.UUID
TargetLang string
TranslatedSubject string
TranslatedBody string
Translator string
TranslatedAt time.Time
}
// SendPersonalInput is the request payload for SendPersonal: the
// caller sending a single-recipient personal message. Exactly one of
// RecipientUserID and RecipientRaceName must be non-zero; the
// service resolves a non-empty RecipientRaceName to the active
// member with that race in the game. Other validation (active
// membership, body length, etc.) is performed inside the service.
type SendPersonalInput struct {
GameID uuid.UUID
SenderUserID uuid.UUID
RecipientUserID uuid.UUID
RecipientRaceName string
Subject string
Body string
SenderIP string
}
// CallerKind enumerates the privileged sender roles for admin-kind
// messages. Owners (`CallerKindOwner`) are players who own a private
// game; admins (`CallerKindAdmin`) hit the dedicated admin route;
// `CallerKindSystem` is reserved for internal lifecycle hooks.
const (
CallerKindOwner = "owner"
CallerKindAdmin = "admin"
CallerKindSystem = "system"
)
// SendAdminPersonalInput is the request payload for an owner /
// admin / system sending an admin-kind message to a single
// recipient. Exactly one of RecipientUserID and RecipientRaceName
// must be non-zero; the service resolves a non-empty
// RecipientRaceName to the active member with that race in the
// game. Authorization (owner-vs-admin distinction) is enforced by
// the HTTP layer; the service trusts the caller designation.
type SendAdminPersonalInput struct {
GameID uuid.UUID
CallerKind string
CallerUserID *uuid.UUID
CallerUsername string
RecipientUserID uuid.UUID
RecipientRaceName string
Subject string
Body string
SenderIP string
}
// SendAdminBroadcastInput is the request payload for an owner /
// admin / system broadcasting an admin-kind message inside a single
// game. RecipientScope selects the address book; the sender's own
// recipient row is never created (a broadcast author does not get a
// copy of their own message).
type SendAdminBroadcastInput struct {
GameID uuid.UUID
CallerKind string
CallerUserID *uuid.UUID
CallerUsername string
RecipientScope string
Subject string
Body string
SenderIP string
}
// LifecycleEventKind enumerates the producer-side intents the lobby
// emits when a game-state or membership-state transition lands.
const (
LifecycleKindGamePaused = "game.paused"
LifecycleKindGameCancelled = "game.cancelled"
LifecycleKindMembershipRemoved = "membership.removed"
LifecycleKindMembershipBlocked = "membership.blocked"
)
// SendPlayerBroadcastInput is the request payload for the paid-tier
// player broadcast. The sender is a player; recipients are the
// active members of the game minus the sender. The resulting message
// is `kind="personal"`, `sender_kind="player"`,
// `broadcast_scope="game_broadcast"` — recipients may reply as if it
// were a personal send, but the reply goes back to the broadcaster
// only.
type SendPlayerBroadcastInput struct {
GameID uuid.UUID
SenderUserID uuid.UUID
Subject string
Body string
SenderIP string
}
// MultiGameBroadcastScope enumerates the admin multi-game broadcast
// modes. `selected` requires `GameIDs`; `all_running` enumerates
// every game whose status is non-terminal through GameLookup.
const (
MultiGameScopeSelected = "selected"
MultiGameScopeAllRunning = "all_running"
)
// SendMultiGameBroadcastInput is the request payload for the admin
// multi-game broadcast. The service materialises one message row per
// addressed game (so a recipient who plays in two games receives two
// independently-deletable inbox entries), then fan-outs the push
// events.
type SendMultiGameBroadcastInput struct {
CallerUsername string
Scope string
GameIDs []uuid.UUID
RecipientScope string
Subject string
Body string
SenderIP string
}
// BulkCleanupInput selects messages eligible for purge. OlderThanYears
// must be >= 1; the service translates the value into a cutoff
// expressed in years and walks `GameLookup.ListFinishedGamesBefore`.
type BulkCleanupInput struct {
OlderThanYears int
}
// CleanupResult summarises a bulk-cleanup run for the admin response
// envelope.
type CleanupResult struct {
GameIDs []uuid.UUID
MessagesDeleted int
}
// AdminMessageListing is the filter passed to ListMessagesForAdmin.
// Pagination uses (Page, PageSize) consistent with the rest of the
// admin surface. Filters are AND-combined; the empty filter returns
// every persisted row.
type AdminMessageListing struct {
Page int
PageSize int
GameID *uuid.UUID
Kind string
SenderKind string
}
// AdminMessagePage is the canonical pagination envelope.
type AdminMessagePage struct {
Items []Message
Total int
Page int
PageSize int
}
// LifecycleEvent is the payload lobby hands to PublishLifecycle when
// a transition needs to be reflected as durable system mail. The
// recipient set is derived by the service:
//
// - For game.* events the message fans out to every active member
// of the game except the actor (the actor sees the action in
// their own UI through other channels).
// - For membership.* events the message addresses exactly
// `TargetUser` (the kicked player), regardless of their current
// membership status — this is how a kicked player retains read
// access to the explanation of the kick.
type LifecycleEvent struct {
GameID uuid.UUID
Kind string
Actor string
Reason string
TargetUser *uuid.UUID
}
// UnreadCount carries a per-game unread-count row returned by
// UnreadCountsForUser. The lobby badge UI consumes the slice plus the
// derived total.
type UnreadCount struct {
GameID uuid.UUID
GameName string
Unread int
}
+209
View File
@@ -0,0 +1,209 @@
package diplomail
import (
"context"
"errors"
"time"
"galaxy/backend/internal/diplomail/translator"
"github.com/google/uuid"
"go.uber.org/zap"
)
// translationBackoff returns the sleep applied before retry attempt
// `attempt`. attempt is 1-indexed (the value the row carries AFTER
// the failure is recorded). The schedule mirrors the spec —
// 1s → 2s → 4s → 8s → 16s — so 5 failed attempts span ~31 seconds
// before the worker falls back to delivering the original.
func translationBackoff(attempt int32) time.Duration {
if attempt <= 0 {
return 0
}
out := time.Second
for i := int32(1); i < attempt; i++ {
out *= 2
}
const cap = 60 * time.Second
if out > cap {
return cap
}
return out
}
// Worker drives the async translation pipeline. Each tick picks a
// single (message_id, target_lang) pair from
// `diplomail_recipients` where `available_at IS NULL`, asks the
// configured Translator to render the body, and either delivers the
// pending recipients (success) or schedules a retry (transient
// failure) or delivers them with a fallback to the original body
// (terminal failure / max attempts).
//
// The worker is single-threaded by design: one HTTP call to
// LibreTranslate at a time. This protects the upstream from spikes
// and keeps the implementation reviewable.
//
// Implements `internal/app.Component` so it plugs into the same
// lifecycle as the mail and notification workers.
type Worker struct {
svc *Service
}
// NewWorker constructs a Worker bound to svc. Returning a non-nil
// Worker even when the translator is the noop fallback is
// intentional — the pickup query still works and falls through to
// fallback delivery, which is the desired behaviour for setups
// without LibreTranslate.
func NewWorker(svc *Service) *Worker { return &Worker{svc: svc} }
// Run drives the worker loop until ctx is cancelled.
func (w *Worker) Run(ctx context.Context) error {
if w == nil || w.svc == nil {
return nil
}
logger := w.svc.deps.Logger.Named("worker")
interval := w.svc.deps.Config.WorkerInterval
if interval <= 0 {
interval = 2 * time.Second
}
if err := w.tick(ctx); err != nil && !errors.Is(err, context.Canceled) {
logger.Warn("diplomail worker initial tick failed", zap.Error(err))
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
if err := w.tick(ctx); err != nil && !errors.Is(err, context.Canceled) {
logger.Warn("diplomail worker tick failed", zap.Error(err))
}
}
}
}
// Shutdown is a no-op: every translation outcome is committed inside
// tick before returning, so cancelling the parent ctx is enough.
func (w *Worker) Shutdown(_ context.Context) error { return nil }
// Tick exposes the per-tick work for tests so they can drive the
// worker without depending on the ticker.
func (w *Worker) Tick(ctx context.Context) error { return w.tick(ctx) }
// tick picks one pair from the queue and applies the result. The
// per-tick budget is one pair on purpose: the worker is single
// threaded and we do not want a fast LibreTranslate instance to
// starve the rest of the backend's I/O behind a long-running batch.
func (w *Worker) tick(ctx context.Context) error {
if ctx.Err() != nil {
return ctx.Err()
}
pair, ok, err := w.svc.deps.Store.PickPendingTranslationPair(ctx, w.svc.nowUTC())
if err != nil {
return err
}
if !ok {
return nil
}
return w.processPair(ctx, pair)
}
// processPair runs the full pipeline for one (message, target_lang).
// Steps:
//
// 1. Load the source message.
// 2. Check the translation cache. If a row already exists (another
// worker pre-populated it, or two pairs converged on the same
// target), reuse it and deliver.
// 3. Otherwise call the configured Translator.
// 4. Apply the outcome: success → cache + deliver; unsupported
// pair → deliver fallback (no cache row); other failure →
// schedule retry or deliver fallback after MaxAttempts.
// 5. Fan out push events for every recipient whose `available_at`
// just transitioned.
func (w *Worker) processPair(ctx context.Context, pair PendingTranslationPair) error {
logger := w.svc.deps.Logger.Named("worker").With(
zap.String("message_id", pair.MessageID.String()),
zap.String("target_lang", pair.TargetLang),
)
msg, err := w.svc.deps.Store.LoadMessage(ctx, pair.MessageID)
if err != nil {
return err
}
if cached, err := w.svc.deps.Store.LoadTranslation(ctx, pair.MessageID, pair.TargetLang); err == nil {
t := cached
return w.deliverPair(ctx, msg, pair.TargetLang, &t, logger)
} else if !errors.Is(err, ErrNotFound) {
return err
}
result, callErr := w.svc.deps.Translator.Translate(ctx, msg.BodyLang, pair.TargetLang, msg.Subject, msg.Body)
if callErr == nil && result.Engine != "" && result.Engine != translator.NoopEngine {
tr := Translation{
TranslationID: uuid.New(),
MessageID: msg.MessageID,
TargetLang: pair.TargetLang,
TranslatedSubject: result.Subject,
TranslatedBody: result.Body,
Translator: result.Engine,
}
return w.deliverPair(ctx, msg, pair.TargetLang, &tr, logger)
}
if callErr == nil {
// Noop translator (or engine returned empty). Treat as
// "translation unavailable" — deliver fallback so users
// see the original.
logger.Debug("translator returned noop, delivering fallback")
return w.deliverPair(ctx, msg, pair.TargetLang, nil, logger)
}
if errors.Is(callErr, translator.ErrUnsupportedLanguagePair) {
logger.Info("language pair unsupported, delivering fallback", zap.Error(callErr))
return w.deliverPair(ctx, msg, pair.TargetLang, nil, logger)
}
// Transient failure — bump the attempts counter and schedule a
// retry. The next attempt timestamp is computed from the
// post-increment counter so the spec's 1s→2s→4s→8s→16s schedule
// applies between retries of the same pair.
maxAttempts := w.svc.deps.Config.TranslatorMaxAttempts
if maxAttempts <= 0 {
maxAttempts = 5
}
nextAttempt := pair.CurrentAttempts + 1
if int(nextAttempt) >= maxAttempts {
logger.Warn("translator max attempts reached, delivering fallback",
zap.Int32("attempts", nextAttempt), zap.Error(callErr))
return w.deliverPair(ctx, msg, pair.TargetLang, nil, logger)
}
next := w.svc.nowUTC().Add(translationBackoff(nextAttempt + 1))
if _, err := w.svc.deps.Store.SchedulePairRetry(ctx, pair.MessageID, pair.TargetLang, next); err != nil {
return err
}
logger.Info("translator attempt failed, scheduled retry",
zap.Int32("attempts", nextAttempt),
zap.Time("next_attempt_at", next),
zap.Error(callErr))
return nil
}
// deliverPair flips every still-pending recipient of (messageID,
// targetLang) to delivered, optionally inserting the translation row
// in the same transaction, and emits push events to the recipients
// who were just unblocked.
func (w *Worker) deliverPair(ctx context.Context, msg Message, targetLang string, translation *Translation, logger *zap.Logger) error {
recipients, err := w.svc.deps.Store.MarkPairDelivered(ctx, msg.MessageID, targetLang, translation, w.svc.nowUTC())
if err != nil {
return err
}
if len(recipients) == 0 {
logger.Debug("deliver yielded no recipients (already delivered)")
return nil
}
for _, r := range recipients {
w.svc.publishMessageReceived(ctx, msg, r)
}
return nil
}
+3 -10
View File
@@ -23,7 +23,6 @@ const (
pathAdminStatus = "/api/v1/admin/status"
pathAdminTurn = "/api/v1/admin/turn"
pathAdminRaceBanish = "/api/v1/admin/race/banish"
pathPlayerCommand = "/api/v1/command"
pathPlayerOrder = "/api/v1/order"
pathPlayerReport = "/api/v1/report"
pathPlayerBattle = "/api/v1/battle"
@@ -183,16 +182,10 @@ func (c *Client) BanishRace(ctx context.Context, baseURL, raceName string) error
}
}
// ExecuteCommands calls `PUT /api/v1/command` with payload forwarded
// PutOrders calls `PUT /api/v1/order` with the payload forwarded
// verbatim. The engine response body is returned verbatim; on 4xx the
// body is returned alongside ErrEngineValidation so callers can
// forward the per-command error.
func (c *Client) ExecuteCommands(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
return c.forwardPlayerWrite(ctx, baseURL, pathPlayerCommand, payload, "engine command")
}
// PutOrders calls `PUT /api/v1/order` with the same forwarding
// semantics as ExecuteCommands.
// body is returned alongside ErrEngineValidation so callers can forward
// the per-command error.
func (c *Client) PutOrders(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
return c.forwardPlayerWrite(ctx, baseURL, pathPlayerOrder, payload, "engine order")
}
+8 -22
View File
@@ -26,6 +26,7 @@ func newTestClient(t *testing.T, srv *httptest.Server) *Client {
func TestClientInitSuccess(t *testing.T) {
wantID := uuid.New()
var gotReq rest.InitRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathAdminInit {
t.Fatalf("unexpected path: %s", r.URL.Path)
@@ -33,13 +34,16 @@ func TestClientInitSuccess(t *testing.T) {
if r.Method != http.MethodPost {
t.Fatalf("unexpected method: %s", r.Method)
}
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatalf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: wantID, Turn: 1, Players: []rest.PlayerState{{ID: uuid.New(), RaceName: "alpha"}}})
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
got, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{Races: []rest.InitRace{{RaceName: "alpha"}}})
got, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{GameID: wantID, Races: []rest.InitRace{{RaceName: "alpha"}}})
if err != nil {
t.Fatalf("Init returned error: %v", err)
}
@@ -49,6 +53,9 @@ func TestClientInitSuccess(t *testing.T) {
if got.Turn != 1 {
t.Fatalf("Turn = %d, want 1", got.Turn)
}
if gotReq.GameID != wantID {
t.Fatalf("request gameId = %s, want %s", gotReq.GameID, wantID)
}
}
func TestClientInitValidationError(t *testing.T) {
@@ -149,27 +156,6 @@ func TestClientBanishRace(t *testing.T) {
}
}
func TestClientCommandsForwardsBody(t *testing.T) {
want := json.RawMessage(`{"actor":"alpha","cmd":[{"@type":"raceQuit"}]}`)
gotResp := json.RawMessage(`{"applied":true}`)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathPlayerCommand || r.Method != http.MethodPut {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
_, _ = w.Write(gotResp)
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
resp, err := cli.ExecuteCommands(context.Background(), srv.URL, want)
if err != nil {
t.Fatalf("ExecuteCommands: %v", err)
}
if string(resp) != string(gotResp) {
t.Fatalf("response = %s, want %s", string(resp), string(gotResp))
}
}
func TestClientReportsForwardsQuery(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathPlayerReport {
+18
View File
@@ -117,6 +117,24 @@ func (c *Cache) GetGame(gameID uuid.UUID) (GameRecord, bool) {
return g, ok
}
// ListGames returns a snapshot copy of every cached game. Terminal-
// state games (finished, cancelled) are evicted from the cache on
// `PutGame`, so the result reflects the live roster of running /
// paused / draft / starting / etc. games. The slice is freshly
// allocated and safe for the caller to mutate.
func (c *Cache) ListGames() []GameRecord {
if c == nil {
return nil
}
c.mu.RLock()
defer c.mu.RUnlock()
out := make([]GameRecord, 0, len(c.games))
for _, g := range c.games {
out = append(out, g)
}
return out
}
// PutGame stores game in the cache when its status is cacheable;
// terminal statuses (finished, cancelled) cause the entry to be evicted.
func (c *Cache) PutGame(game GameRecord) {
+66 -5
View File
@@ -9,14 +9,21 @@ import (
// EntitlementProvider is the read-only view the lobby needs over the
// user-domain entitlement snapshot. The canonical implementation is
// `*user.Service` exposing `GetEntitlement(ctx, userID)`; tests substitute
// a fake.
// `*user.Service` exposing `GetEntitlementSnapshot(ctx, userID)`; tests
// substitute a fake.
//
// `MaxRegisteredRaceNames` is the only field consumed by when
// the caller attempts to register a `pending_registration` row the lobby
// counts already-`registered` rows for that user against this limit.
// `GetMaxRegisteredRaceNames` is consumed at race-name registration time
// — when the caller attempts to register a `pending_registration` row the
// lobby counts already-`registered` rows for that user against this limit.
//
// `IsPaid` is consumed by the user-facing private-game creation gate at
// the HTTP handler level (`POST /api/v1/user/lobby/games`): free-tier
// callers are rejected with `403 forbidden` before the lobby Service is
// invoked. Admin-driven public-game creation
// (`POST /api/v1/admin/games`) bypasses the gate.
type EntitlementProvider interface {
GetMaxRegisteredRaceNames(ctx context.Context, userID uuid.UUID) (int32, error)
IsPaid(ctx context.Context, userID uuid.UUID) (bool, error)
}
// RuntimeGateway is the outbound surface the lobby uses to ask the runtime
@@ -51,6 +58,37 @@ type NotificationPublisher interface {
PublishLobbyEvent(ctx context.Context, intent LobbyNotification) error
}
// DiplomailPublisher is the outbound surface the lobby uses to drop a
// durable system mail entry whenever a game-state or
// membership-state transition needs to land in the affected players'
// inboxes. The real implementation in `cmd/backend/main` adapts the
// `*diplomail.Service.PublishLifecycle` call; tests and partial
// wiring fall back to `NewNoopDiplomailPublisher`.
type DiplomailPublisher interface {
PublishLifecycle(ctx context.Context, event LifecycleEvent) error
}
// LifecycleEvent is the open shape carried by a system-mail intent.
// `Kind` is one of the lobby-internal constants
// (`LifecycleKindGamePaused`, etc.). `TargetUser` is populated only
// for membership-scoped events; the publisher derives the game-scoped
// recipient set itself.
type LifecycleEvent struct {
GameID uuid.UUID
Kind string
Actor string
Reason string
TargetUser *uuid.UUID
}
// Lifecycle-event kinds the lobby emits.
const (
LifecycleKindGamePaused = "game.paused"
LifecycleKindGameCancelled = "game.cancelled"
LifecycleKindMembershipRemoved = "membership.removed"
LifecycleKindMembershipBlocked = "membership.blocked"
)
// LobbyNotification is the open shape carried by a notification intent.
// The implementation emits a small set of `Kind` values matching the catalog in
// `backend/README.md` §10. The `Payload` map is the kind-specific data
@@ -123,3 +161,26 @@ func (p *noopNotificationPublisher) PublishLobbyEvent(_ context.Context, intent
)
return nil
}
// NewNoopDiplomailPublisher returns a DiplomailPublisher that logs
// every call at debug level and returns nil. Used by tests and by
// the lobby Service factory when the Deps.Diplomail field is left
// nil.
func NewNoopDiplomailPublisher(logger *zap.Logger) DiplomailPublisher {
if logger == nil {
logger = zap.NewNop()
}
return &noopDiplomailPublisher{logger: logger.Named("lobby.diplomail.noop")}
}
type noopDiplomailPublisher struct {
logger *zap.Logger
}
func (p *noopDiplomailPublisher) PublishLifecycle(_ context.Context, event LifecycleEvent) error {
p.logger.Debug("noop diplomail lifecycle",
zap.String("kind", event.Kind),
zap.String("game_id", event.GameID.String()),
)
return nil
}
+74 -5
View File
@@ -10,6 +10,7 @@ import (
"galaxy/cronutil"
"github.com/google/uuid"
"go.uber.org/zap"
)
// CreateGameInput is the parameter struct for Service.CreateGame.
@@ -233,16 +234,50 @@ func (s *Service) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameReco
return s.deps.Store.ListMyGames(ctx, userID)
}
// ListFinishedGamesBefore returns every game whose status is
// `finished` or `cancelled` and whose `finished_at` is strictly older
// than cutoff. The result walks the store through the admin-paged
// query with a 200-row batch size; the caller is expected to invoke
// this from rare admin workflows (diplomail bulk cleanup) rather
// than hot-path reads.
func (s *Service) ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]GameRecord, error) {
const pageSize = 200
page := 1
var out []GameRecord
for {
batch, _, err := s.deps.Store.ListAdminGames(ctx, page, pageSize)
if err != nil {
return nil, fmt.Errorf("lobby: list finished games before %s: %w", cutoff, err)
}
if len(batch) == 0 {
break
}
for _, g := range batch {
if g.Status != GameStatusFinished && g.Status != GameStatusCancelled {
continue
}
if g.FinishedAt == nil || !g.FinishedAt.Before(cutoff) {
continue
}
out = append(out, g)
}
if len(batch) < pageSize {
break
}
page++
}
return out, nil
}
// DeleteGame removes the game and every referencing row (memberships,
// applications, invites, runtime_records, player_mappings) via the
// `ON DELETE CASCADE` constraints declared in `00001_init.sql`.
// Idempotent: returns nil when no game matches.
//
// Phase 14 introduces this method for the dev-sandbox bootstrap so a
// terminal "Dev Sandbox" tile from a previous local-dev session can
// be scrubbed before a fresh game spawns. Production callers must
// stay on the regular cancel / finish lifecycle — `DeleteGame` is
// destructive and bypasses the cascade-notification machinery.
// `DeleteGame` is destructive — a hard delete that bypasses the
// cascade-notification machinery — so production callers stay on the
// regular cancel / finish lifecycle. It is exercised by the lobby
// integration tests.
func (s *Service) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
if err := s.deps.Store.DeleteGame(ctx, gameID); err != nil {
return err
@@ -441,9 +476,43 @@ func (s *Service) transition(ctx context.Context, callerUserID *uuid.UUID, calle
return updated, fmt.Errorf("post-commit %s: %w", rule.Reason, err)
}
}
s.emitGameLifecycleMail(ctx, updated, callerIsAdmin, rule)
return updated, nil
}
// emitGameLifecycleMail asks the diplomail publisher to drop a
// system-mail entry whenever a state change is user-visible. Only
// the `paused` and `cancelled` transitions emit mail today (the spec
// names them explicitly); `running`/`finished`/etc. are signalled by
// other channels and do not need a durable inbox entry.
func (s *Service) emitGameLifecycleMail(ctx context.Context, game GameRecord, callerIsAdmin bool, rule transitionRule) {
var kind string
switch rule.To {
case GameStatusPaused:
kind = LifecycleKindGamePaused
case GameStatusCancelled:
kind = LifecycleKindGameCancelled
default:
return
}
actor := "the game owner"
if callerIsAdmin {
actor = "an administrator"
}
ev := LifecycleEvent{
GameID: game.GameID,
Kind: kind,
Actor: actor,
Reason: rule.Reason,
}
if err := s.deps.Diplomail.PublishLifecycle(ctx, ev); err != nil {
s.deps.Logger.Warn("publish lifecycle mail failed",
zap.String("game_id", game.GameID.String()),
zap.String("kind", kind),
zap.Error(err))
}
}
// checkOwner enforces ownership semantics:
//
// - callerIsAdmin == true → always allowed (admin force-start, etc.).
+17
View File
@@ -20,6 +20,7 @@
package lobby
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
@@ -28,6 +29,7 @@ import (
"galaxy/backend/internal/config"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"go.uber.org/zap"
)
@@ -124,6 +126,7 @@ type Deps struct {
Cache *Cache
Runtime RuntimeGateway
Notification NotificationPublisher
Diplomail DiplomailPublisher
Entitlement EntitlementProvider
Policy *Policy
Config config.LobbyConfig
@@ -156,6 +159,9 @@ func NewService(deps Deps) (*Service, error) {
if deps.Notification == nil {
deps.Notification = NewNoopNotificationPublisher(deps.Logger)
}
if deps.Diplomail == nil {
deps.Diplomail = NewNoopDiplomailPublisher(deps.Logger)
}
if deps.Policy == nil {
policy, err := NewPolicy()
if err != nil {
@@ -203,6 +209,17 @@ func (s *Service) Config() config.LobbyConfig {
return s.deps.Config
}
// IsPaid reports whether userID currently sits on a paid tier. Thin
// pass-through over EntitlementProvider used by the HTTP handler that
// fronts user-driven private-game creation; admin-driven public-game
// creation does not consult this gate.
func (s *Service) IsPaid(ctx context.Context, userID uuid.UUID) (bool, error) {
if s == nil || s.deps.Entitlement == nil {
return false, fmt.Errorf("lobby: entitlement provider not configured")
}
return s.deps.Entitlement.IsPaid(ctx, userID)
}
// generateInviteCode produces an `inviteCodeBytes`-byte hex code used
// for code-based invites. The function uses `crypto/rand`; a failure to
// read entropy is propagated to the caller.
+6 -2
View File
@@ -103,6 +103,10 @@ func (s stubEntitlement) GetMaxRegisteredRaceNames(_ context.Context, _ uuid.UUI
return s.max, nil
}
func (s stubEntitlement) IsPaid(_ context.Context, _ uuid.UUID) (bool, error) {
return true, nil
}
func newServiceForTest(t *testing.T, db *sql.DB, now func() time.Time, max int32) *lobby.Service {
t.Helper()
store := lobby.NewStore(db)
@@ -244,8 +248,8 @@ func TestEndToEndPrivateGameFlow(t *testing.T) {
}
}
// TestDeleteGameCascadesEverything pins the contract the dev-sandbox
// bootstrap relies on: removing a game wipes every referencing row
// TestDeleteGameCascadesEverything pins the DeleteGame contract:
// removing a game wipes every referencing row
// (memberships, applications, invites, runtime_records,
// player_mappings) in a single SQL statement. Before this is wired
// the developer's lobby pile up cancelled tiles between
+5 -6
View File
@@ -20,9 +20,9 @@ type InsertMembershipDirectInput struct {
// writes as ApproveApplication: the per-game race-name reservation
// row plus the membership row, and refreshes the in-memory caches.
//
// The method is intended for boot-time provisioning by
// `backend/internal/devsandbox` and similar trusted callers. It is
// not exposed through any HTTP handler. The caller must guarantee
// The method is intended for trusted boot-time provisioning and
// integration tests; it is not exposed through any HTTP handler. The
// caller must guarantee
// game.Status == GameStatusEnrollmentOpen — the function returns
// ErrConflict otherwise — and that the race-name policy and
// canonical-key invariants are honoured (the implementation reuses
@@ -30,9 +30,8 @@ type InsertMembershipDirectInput struct {
// or unsuitable name still fails).
//
// Idempotency: if a membership for (GameID, UserID) already exists
// the function returns the existing row without modifying state.
// This makes the helper safe to call on every backend boot from
// devsandbox.Bootstrap.
// the function returns the existing row without modifying state, so
// the helper is safe to call repeatedly.
func (s *Service) InsertMembershipDirect(ctx context.Context, in InsertMembershipDirectInput) (Membership, error) {
displayName, err := ValidateDisplayName(in.RaceName)
if err != nil {
+36
View File
@@ -76,6 +76,7 @@ func (s *Service) AdminBanMember(ctx context.Context, gameID, userID uuid.UUID,
zap.String("membership_id", updated.MembershipID.String()),
zap.Error(pubErr))
}
s.emitMembershipLifecycleMail(ctx, updated, MembershipStatusBlocked, true, reason)
_ = game
return updated, nil
}
@@ -142,9 +143,44 @@ func (s *Service) changeMembershipStatus(
zap.String("kind", notificationKind),
zap.Error(pubErr))
}
s.emitMembershipLifecycleMail(ctx, updated, newStatus, callerIsAdmin, "")
return updated, nil
}
// emitMembershipLifecycleMail asks the diplomail publisher to drop a
// durable explanation into the kicked player's inbox. The mail
// survives the membership row going to `removed` / `blocked` so the
// player keeps read access to it (soft-access rule, item 8).
func (s *Service) emitMembershipLifecycleMail(ctx context.Context, membership Membership, newStatus string, callerIsAdmin bool, reason string) {
var kind string
switch newStatus {
case MembershipStatusRemoved:
kind = LifecycleKindMembershipRemoved
case MembershipStatusBlocked:
kind = LifecycleKindMembershipBlocked
default:
return
}
actor := "the game owner"
if callerIsAdmin {
actor = "an administrator"
}
target := membership.UserID
ev := LifecycleEvent{
GameID: membership.GameID,
Kind: kind,
Actor: actor,
Reason: reason,
TargetUser: &target,
}
if err := s.deps.Diplomail.PublishLifecycle(ctx, ev); err != nil {
s.deps.Logger.Warn("publish membership lifecycle mail failed",
zap.String("membership_id", membership.MembershipID.String()),
zap.String("kind", kind),
zap.Error(err))
}
}
func (s *Service) canManageMembership(game GameRecord, membership Membership, callerUserID *uuid.UUID, allowSelf bool) bool {
if game.Visibility == VisibilityPublic {
// Public-game membership management is admin-only.
+2 -3
View File
@@ -236,9 +236,8 @@ func (s *Store) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameRecord
// referencing table (memberships / applications / invites /
// runtime_records / player_mappings — all declared with ON DELETE
// CASCADE in `00001_init.sql`). Idempotent: returns nil when no row
// matches. Used by the dev-sandbox bootstrap to scrub terminal
// games on every backend boot so the developer's lobby never piles
// up cancelled tiles.
// matches. A hard delete for trusted callers and integration tests;
// production lifecycle uses cancel / finish.
func (s *Store) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
g := table.Games
stmt := g.DELETE().WHERE(g.GameID.EQ(postgres.UUID(gameID)))
+5
View File
@@ -19,6 +19,7 @@ const (
KindRuntimeStartConfigInvalid = "runtime.start_config_invalid"
KindGameTurnReady = "game.turn.ready"
KindGamePaused = "game.paused"
KindDiplomailReceived = "diplomail.message.received"
)
// CatalogEntry describes the per-kind delivery policy: which channels
@@ -103,6 +104,9 @@ var catalog = map[string]CatalogEntry{
KindGamePaused: {
Channels: []string{ChannelPush},
},
KindDiplomailReceived: {
Channels: []string{ChannelPush},
},
}
// LookupCatalog returns the per-kind policy and a boolean reporting
@@ -133,5 +137,6 @@ func SupportedKinds() []string {
KindRuntimeStartConfigInvalid,
KindGameTurnReady,
KindGamePaused,
KindDiplomailReceived,
}
}
@@ -41,6 +41,7 @@ func TestCatalogChannels(t *testing.T) {
KindRuntimeStartConfigInvalid: {ChannelEmail},
KindGameTurnReady: {ChannelPush},
KindGamePaused: {ChannelPush},
KindDiplomailReceived: {ChannelPush},
}
for kind, want := range expect {
entry, ok := LookupCatalog(kind)
@@ -25,9 +25,15 @@ import (
// payload is `{game_id, turn, reason}` consumed by the same in-game
// shell layout, so there is no value in dragging a FB schema in for
// one consumer.
//
// `diplomail.message.received` (Stage A) carries the message metadata
// plus an unread-count snapshot. Stage A intentionally ships the
// payload as JSON so the diplomail UI can iterate on the contract
// without a FB schema dance; a later stage can promote it.
var jsonFriendlyKinds = map[string]bool{
KindGameTurnReady: true,
KindGamePaused: true,
KindDiplomailReceived: true,
}
// TestBuildClientPushEventCoversCatalog asserts that every catalog kind
@@ -88,6 +94,17 @@ func TestBuildClientPushEventCoversCatalog(t *testing.T) {
"turn": int32(7),
"reason": "generation_failed",
}},
{"diplomail message received", KindDiplomailReceived, map[string]any{
"message_id": gameID.String(),
"game_id": gameID.String(),
"kind": "personal",
"sender_kind": "player",
"subject": "Trade deal",
"preview": "Care to talk gas mining?",
"preview_lang": "en",
"unread_total": 3,
"unread_game": 1,
}},
}
seenKinds := map[string]bool{}
+139
View File
@@ -0,0 +1,139 @@
// Package opsstatus reads point-in-time operational signals from Postgres for
// the admin console dashboard: database reachability, per-status counts of game
// runtimes, mail deliveries, and notification routes, plus the malformed
// notification-intent count.
//
// It is a read-only projection built entirely through the go-jet query builder
// against the generated table bindings; it owns no business logic and mutates
// nothing. Richer, historical metrics are out of scope — those belong to the
// Prometheus exporters wired on `backend` and `gateway`.
package opsstatus
import (
"context"
"database/sql"
"fmt"
"time"
"galaxy/backend/internal/postgres/jet/backend/table"
"github.com/go-jet/jet/v2/postgres"
)
// defaultCollectTimeout bounds a single Collect call so a slow or wedged
// database cannot hang the dashboard request.
const defaultCollectTimeout = 3 * time.Second
// StatusCount pairs a status value with the number of rows currently in it.
type StatusCount struct {
Status string
Count int64
}
// Snapshot is a point-in-time view of the operational signals rendered on the
// dashboard. Errors collects per-query failures so a single failing probe
// degrades to a visible note rather than failing the whole page.
type Snapshot struct {
PostgresHealthy bool
Runtimes []StatusCount
MailDeliveries []StatusCount
NotificationRoutes []StatusCount
NotificationMalformed int64
Errors []string
}
// Reader collects an operational Snapshot. The admin console depends on this
// interface so the dashboard can be tested without a database.
type Reader interface {
Collect(ctx context.Context) Snapshot
}
// Store is the Postgres-backed Reader.
type Store struct {
db *sql.DB
timeout time.Duration
}
// NewStore constructs a Store reading from db.
func NewStore(db *sql.DB) *Store {
return &Store{db: db, timeout: defaultCollectTimeout}
}
// Collect gathers the dashboard signals within a bounded timeout. It never
// returns an error: a failed probe is recorded in Snapshot.Errors and the
// remaining probes still run, except that a failed Postgres ping short-circuits
// the rest (the dependent queries would only fail the same way).
func (s *Store) Collect(ctx context.Context) Snapshot {
ctx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel()
var snap Snapshot
if err := s.db.PingContext(ctx); err != nil {
snap.Errors = append(snap.Errors, fmt.Sprintf("postgres ping: %v", err))
return snap
}
snap.PostgresHealthy = true
if counts, err := s.statusCounts(ctx, table.RuntimeRecords.Status, table.RuntimeRecords); err != nil {
snap.Errors = append(snap.Errors, fmt.Sprintf("runtime status counts: %v", err))
} else {
snap.Runtimes = counts
}
if counts, err := s.statusCounts(ctx, table.MailDeliveries.Status, table.MailDeliveries); err != nil {
snap.Errors = append(snap.Errors, fmt.Sprintf("mail delivery counts: %v", err))
} else {
snap.MailDeliveries = counts
}
if counts, err := s.statusCounts(ctx, table.NotificationRoutes.Status, table.NotificationRoutes); err != nil {
snap.Errors = append(snap.Errors, fmt.Sprintf("notification route counts: %v", err))
} else {
snap.NotificationRoutes = counts
}
if n, err := s.countAll(ctx, table.NotificationMalformedIntents); err != nil {
snap.Errors = append(snap.Errors, fmt.Sprintf("malformed notification count: %v", err))
} else {
snap.NotificationMalformed = n
}
return snap
}
// statusCounts runs `SELECT status, COUNT(*) FROM <from> GROUP BY status`
// through jet and returns the rows ordered by status.
func (s *Store) statusCounts(ctx context.Context, status postgres.ColumnString, from postgres.ReadableTable) ([]StatusCount, error) {
stmt := postgres.SELECT(
status.AS("status_count.status"),
postgres.COUNT(postgres.STAR).AS("status_count.count"),
).FROM(from).GROUP_BY(status).ORDER_BY(status.ASC())
var rows []struct {
Status string `alias:"status_count.status"`
Count int64 `alias:"status_count.count"`
}
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, err
}
out := make([]StatusCount, len(rows))
for i, row := range rows {
out[i] = StatusCount{Status: row.Status, Count: row.Count}
}
return out, nil
}
// countAll runs `SELECT COUNT(*) FROM <from>` through jet.
func (s *Store) countAll(ctx context.Context, from postgres.ReadableTable) (int64, error) {
stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).FROM(from)
var row struct {
Count int64 `alias:"count"`
}
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
return 0, err
}
return row.Count, nil
}
@@ -0,0 +1,155 @@
package opsstatus_test
import (
"context"
"database/sql"
"net/url"
"testing"
"time"
"galaxy/backend/internal/mail"
"galaxy/backend/internal/opsstatus"
backendpg "galaxy/backend/internal/postgres"
pgshared "galaxy/postgres"
"github.com/google/uuid"
testcontainers "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
pgImage = "postgres:16-alpine"
pgUser = "galaxy"
pgPassword = "galaxy"
pgDatabase = "galaxy_backend"
pgSchema = "backend"
pgStartup = 90 * time.Second
pgOpTO = 10 * time.Second
)
// startPostgres mirrors the per-package scaffolding used by the other store
// tests: spin up Postgres, apply migrations, return *sql.DB.
func startPostgres(t *testing.T) *sql.DB {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
pgContainer, err := tcpostgres.Run(ctx, pgImage,
tcpostgres.WithDatabase(pgDatabase),
tcpostgres.WithUsername(pgUser),
tcpostgres.WithPassword(pgPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(pgStartup),
),
)
if err != nil {
t.Skipf("postgres testcontainer unavailable, skipping: %v", err)
}
t.Cleanup(func() {
if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil {
t.Errorf("terminate postgres container: %v", termErr)
}
})
baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("connection string: %v", err)
}
scopedDSN, err := dsnWithSearchPath(baseDSN, pgSchema)
if err != nil {
t.Fatalf("scope dsn: %v", err)
}
cfg := pgshared.DefaultConfig()
cfg.PrimaryDSN = scopedDSN
cfg.OperationTimeout = pgOpTO
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
if err != nil {
t.Fatalf("open primary: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil {
t.Fatalf("ping: %v", err)
}
if err := backendpg.ApplyMigrations(ctx, db); err != nil {
t.Fatalf("apply migrations: %v", err)
}
return db
}
func dsnWithSearchPath(baseDSN, schema string) (string, error) {
parsed, err := url.Parse(baseDSN)
if err != nil {
return "", err
}
values := parsed.Query()
values.Set("search_path", schema)
if values.Get("sslmode") == "" {
values.Set("sslmode", "disable")
}
parsed.RawQuery = values.Encode()
return parsed.String(), nil
}
func TestStoreCollect(t *testing.T) {
db := startPostgres(t)
store := opsstatus.NewStore(db)
ctx := context.Background()
// Empty schema: queries must execute cleanly with zero counts.
empty := store.Collect(ctx)
if !empty.PostgresHealthy {
t.Fatal("PostgresHealthy must be true against a reachable database")
}
if len(empty.Errors) != 0 {
t.Fatalf("unexpected collection errors: %v", empty.Errors)
}
if got := totalCount(empty.MailDeliveries); got != 0 {
t.Fatalf("mail deliveries total = %d, want 0", got)
}
if len(empty.Runtimes) != 0 || len(empty.NotificationRoutes) != 0 {
t.Fatalf("expected empty status slices, got runtimes=%v routes=%v", empty.Runtimes, empty.NotificationRoutes)
}
if empty.NotificationMalformed != 0 {
t.Fatalf("malformed notifications = %d, want 0", empty.NotificationMalformed)
}
// Enqueue one mail delivery and confirm the GROUP BY count reflects it.
mailStore := mail.NewStore(db)
inserted, err := mailStore.InsertEnqueue(ctx, mail.EnqueueArgs{
DeliveryID: uuid.New(),
TemplateID: mail.TemplateLoginCode,
IdempotencyKey: uuid.NewString(),
Recipients: []string{"ops@example.test"},
ContentType: "text/plain",
Subject: "hello",
Body: []byte("hi"),
})
if err != nil {
t.Fatalf("insert mail delivery: %v", err)
}
if !inserted {
t.Fatal("expected the delivery to be inserted")
}
after := store.Collect(ctx)
if len(after.Errors) != 0 {
t.Fatalf("unexpected collection errors after insert: %v", after.Errors)
}
if got := totalCount(after.MailDeliveries); got != 1 {
t.Fatalf("mail deliveries total after insert = %d, want 1 (statuses: %v)", got, after.MailDeliveries)
}
}
func totalCount(counts []opsstatus.StatusCount) int64 {
var total int64
for _, c := range counts {
total += c.Count
}
return total
}
@@ -0,0 +1,30 @@
//
// 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 DiplomailMessages struct {
MessageID uuid.UUID `sql:"primary_key"`
GameID uuid.UUID
GameName string
Kind string
SenderKind string
SenderUserID *uuid.UUID
SenderUsername *string
SenderRaceName *string
SenderIP string
Subject string
Body string
BodyLang string
BroadcastScope string
CreatedAt time.Time
}
@@ -0,0 +1,30 @@
//
// 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 DiplomailRecipients struct {
RecipientID uuid.UUID `sql:"primary_key"`
MessageID uuid.UUID
GameID uuid.UUID
UserID uuid.UUID
RecipientUserName string
RecipientRaceName *string
RecipientPreferredLanguage string
AvailableAt *time.Time
TranslationAttempts int32
NextTranslationAttemptAt *time.Time
DeliveredAt *time.Time
ReadAt *time.Time
DeletedAt *time.Time
NotifiedAt *time.Time
}
@@ -0,0 +1,23 @@
//
// 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 DiplomailTranslations struct {
TranslationID uuid.UUID `sql:"primary_key"`
MessageID uuid.UUID
TargetLang string
TranslatedSubject string
TranslatedBody string
Translator string
TranslatedAt time.Time
}
@@ -0,0 +1,117 @@
//
// 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 DiplomailMessages = newDiplomailMessagesTable("backend", "diplomail_messages", "")
type diplomailMessagesTable struct {
postgres.Table
// Columns
MessageID postgres.ColumnString
GameID postgres.ColumnString
GameName postgres.ColumnString
Kind postgres.ColumnString
SenderKind postgres.ColumnString
SenderUserID postgres.ColumnString
SenderUsername postgres.ColumnString
SenderRaceName postgres.ColumnString
SenderIP postgres.ColumnString
Subject postgres.ColumnString
Body postgres.ColumnString
BodyLang postgres.ColumnString
BroadcastScope postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type DiplomailMessagesTable struct {
diplomailMessagesTable
EXCLUDED diplomailMessagesTable
}
// AS creates new DiplomailMessagesTable with assigned alias
func (a DiplomailMessagesTable) AS(alias string) *DiplomailMessagesTable {
return newDiplomailMessagesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new DiplomailMessagesTable with assigned schema name
func (a DiplomailMessagesTable) FromSchema(schemaName string) *DiplomailMessagesTable {
return newDiplomailMessagesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new DiplomailMessagesTable with assigned table prefix
func (a DiplomailMessagesTable) WithPrefix(prefix string) *DiplomailMessagesTable {
return newDiplomailMessagesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new DiplomailMessagesTable with assigned table suffix
func (a DiplomailMessagesTable) WithSuffix(suffix string) *DiplomailMessagesTable {
return newDiplomailMessagesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newDiplomailMessagesTable(schemaName, tableName, alias string) *DiplomailMessagesTable {
return &DiplomailMessagesTable{
diplomailMessagesTable: newDiplomailMessagesTableImpl(schemaName, tableName, alias),
EXCLUDED: newDiplomailMessagesTableImpl("", "excluded", ""),
}
}
func newDiplomailMessagesTableImpl(schemaName, tableName, alias string) diplomailMessagesTable {
var (
MessageIDColumn = postgres.StringColumn("message_id")
GameIDColumn = postgres.StringColumn("game_id")
GameNameColumn = postgres.StringColumn("game_name")
KindColumn = postgres.StringColumn("kind")
SenderKindColumn = postgres.StringColumn("sender_kind")
SenderUserIDColumn = postgres.StringColumn("sender_user_id")
SenderUsernameColumn = postgres.StringColumn("sender_username")
SenderRaceNameColumn = postgres.StringColumn("sender_race_name")
SenderIPColumn = postgres.StringColumn("sender_ip")
SubjectColumn = postgres.StringColumn("subject")
BodyColumn = postgres.StringColumn("body")
BodyLangColumn = postgres.StringColumn("body_lang")
BroadcastScopeColumn = postgres.StringColumn("broadcast_scope")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderRaceNameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderRaceNameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{SenderIPColumn, SubjectColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
)
return diplomailMessagesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
MessageID: MessageIDColumn,
GameID: GameIDColumn,
GameName: GameNameColumn,
Kind: KindColumn,
SenderKind: SenderKindColumn,
SenderUserID: SenderUserIDColumn,
SenderUsername: SenderUsernameColumn,
SenderRaceName: SenderRaceNameColumn,
SenderIP: SenderIPColumn,
Subject: SubjectColumn,
Body: BodyColumn,
BodyLang: BodyLangColumn,
BroadcastScope: BroadcastScopeColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,117 @@
//
// 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 DiplomailRecipients = newDiplomailRecipientsTable("backend", "diplomail_recipients", "")
type diplomailRecipientsTable struct {
postgres.Table
// Columns
RecipientID postgres.ColumnString
MessageID postgres.ColumnString
GameID postgres.ColumnString
UserID postgres.ColumnString
RecipientUserName postgres.ColumnString
RecipientRaceName postgres.ColumnString
RecipientPreferredLanguage postgres.ColumnString
AvailableAt postgres.ColumnTimestampz
TranslationAttempts postgres.ColumnInteger
NextTranslationAttemptAt postgres.ColumnTimestampz
DeliveredAt postgres.ColumnTimestampz
ReadAt postgres.ColumnTimestampz
DeletedAt postgres.ColumnTimestampz
NotifiedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type DiplomailRecipientsTable struct {
diplomailRecipientsTable
EXCLUDED diplomailRecipientsTable
}
// AS creates new DiplomailRecipientsTable with assigned alias
func (a DiplomailRecipientsTable) AS(alias string) *DiplomailRecipientsTable {
return newDiplomailRecipientsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new DiplomailRecipientsTable with assigned schema name
func (a DiplomailRecipientsTable) FromSchema(schemaName string) *DiplomailRecipientsTable {
return newDiplomailRecipientsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new DiplomailRecipientsTable with assigned table prefix
func (a DiplomailRecipientsTable) WithPrefix(prefix string) *DiplomailRecipientsTable {
return newDiplomailRecipientsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new DiplomailRecipientsTable with assigned table suffix
func (a DiplomailRecipientsTable) WithSuffix(suffix string) *DiplomailRecipientsTable {
return newDiplomailRecipientsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newDiplomailRecipientsTable(schemaName, tableName, alias string) *DiplomailRecipientsTable {
return &DiplomailRecipientsTable{
diplomailRecipientsTable: newDiplomailRecipientsTableImpl(schemaName, tableName, alias),
EXCLUDED: newDiplomailRecipientsTableImpl("", "excluded", ""),
}
}
func newDiplomailRecipientsTableImpl(schemaName, tableName, alias string) diplomailRecipientsTable {
var (
RecipientIDColumn = postgres.StringColumn("recipient_id")
MessageIDColumn = postgres.StringColumn("message_id")
GameIDColumn = postgres.StringColumn("game_id")
UserIDColumn = postgres.StringColumn("user_id")
RecipientUserNameColumn = postgres.StringColumn("recipient_user_name")
RecipientRaceNameColumn = postgres.StringColumn("recipient_race_name")
RecipientPreferredLanguageColumn = postgres.StringColumn("recipient_preferred_language")
AvailableAtColumn = postgres.TimestampzColumn("available_at")
TranslationAttemptsColumn = postgres.IntegerColumn("translation_attempts")
NextTranslationAttemptAtColumn = postgres.TimestampzColumn("next_translation_attempt_at")
DeliveredAtColumn = postgres.TimestampzColumn("delivered_at")
ReadAtColumn = postgres.TimestampzColumn("read_at")
DeletedAtColumn = postgres.TimestampzColumn("deleted_at")
NotifiedAtColumn = postgres.TimestampzColumn("notified_at")
allColumns = postgres.ColumnList{RecipientIDColumn, MessageIDColumn, GameIDColumn, UserIDColumn, RecipientUserNameColumn, RecipientRaceNameColumn, RecipientPreferredLanguageColumn, AvailableAtColumn, TranslationAttemptsColumn, NextTranslationAttemptAtColumn, DeliveredAtColumn, ReadAtColumn, DeletedAtColumn, NotifiedAtColumn}
mutableColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, UserIDColumn, RecipientUserNameColumn, RecipientRaceNameColumn, RecipientPreferredLanguageColumn, AvailableAtColumn, TranslationAttemptsColumn, NextTranslationAttemptAtColumn, DeliveredAtColumn, ReadAtColumn, DeletedAtColumn, NotifiedAtColumn}
defaultColumns = postgres.ColumnList{RecipientPreferredLanguageColumn, TranslationAttemptsColumn}
)
return diplomailRecipientsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
RecipientID: RecipientIDColumn,
MessageID: MessageIDColumn,
GameID: GameIDColumn,
UserID: UserIDColumn,
RecipientUserName: RecipientUserNameColumn,
RecipientRaceName: RecipientRaceNameColumn,
RecipientPreferredLanguage: RecipientPreferredLanguageColumn,
AvailableAt: AvailableAtColumn,
TranslationAttempts: TranslationAttemptsColumn,
NextTranslationAttemptAt: NextTranslationAttemptAtColumn,
DeliveredAt: DeliveredAtColumn,
ReadAt: ReadAtColumn,
DeletedAt: DeletedAtColumn,
NotifiedAt: NotifiedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,96 @@
//
// 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 DiplomailTranslations = newDiplomailTranslationsTable("backend", "diplomail_translations", "")
type diplomailTranslationsTable struct {
postgres.Table
// Columns
TranslationID postgres.ColumnString
MessageID postgres.ColumnString
TargetLang postgres.ColumnString
TranslatedSubject postgres.ColumnString
TranslatedBody postgres.ColumnString
Translator postgres.ColumnString
TranslatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type DiplomailTranslationsTable struct {
diplomailTranslationsTable
EXCLUDED diplomailTranslationsTable
}
// AS creates new DiplomailTranslationsTable with assigned alias
func (a DiplomailTranslationsTable) AS(alias string) *DiplomailTranslationsTable {
return newDiplomailTranslationsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new DiplomailTranslationsTable with assigned schema name
func (a DiplomailTranslationsTable) FromSchema(schemaName string) *DiplomailTranslationsTable {
return newDiplomailTranslationsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new DiplomailTranslationsTable with assigned table prefix
func (a DiplomailTranslationsTable) WithPrefix(prefix string) *DiplomailTranslationsTable {
return newDiplomailTranslationsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new DiplomailTranslationsTable with assigned table suffix
func (a DiplomailTranslationsTable) WithSuffix(suffix string) *DiplomailTranslationsTable {
return newDiplomailTranslationsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newDiplomailTranslationsTable(schemaName, tableName, alias string) *DiplomailTranslationsTable {
return &DiplomailTranslationsTable{
diplomailTranslationsTable: newDiplomailTranslationsTableImpl(schemaName, tableName, alias),
EXCLUDED: newDiplomailTranslationsTableImpl("", "excluded", ""),
}
}
func newDiplomailTranslationsTableImpl(schemaName, tableName, alias string) diplomailTranslationsTable {
var (
TranslationIDColumn = postgres.StringColumn("translation_id")
MessageIDColumn = postgres.StringColumn("message_id")
TargetLangColumn = postgres.StringColumn("target_lang")
TranslatedSubjectColumn = postgres.StringColumn("translated_subject")
TranslatedBodyColumn = postgres.StringColumn("translated_body")
TranslatorColumn = postgres.StringColumn("translator")
TranslatedAtColumn = postgres.TimestampzColumn("translated_at")
allColumns = postgres.ColumnList{TranslationIDColumn, MessageIDColumn, TargetLangColumn, TranslatedSubjectColumn, TranslatedBodyColumn, TranslatorColumn, TranslatedAtColumn}
mutableColumns = postgres.ColumnList{MessageIDColumn, TargetLangColumn, TranslatedSubjectColumn, TranslatedBodyColumn, TranslatorColumn, TranslatedAtColumn}
defaultColumns = postgres.ColumnList{TranslatedSubjectColumn, TranslatedAtColumn}
)
return diplomailTranslationsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
TranslationID: TranslationIDColumn,
MessageID: MessageIDColumn,
TargetLang: TargetLangColumn,
TranslatedSubject: TranslatedSubjectColumn,
TranslatedBody: TranslatedBodyColumn,
Translator: TranslatorColumn,
TranslatedAt: TranslatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -16,6 +16,9 @@ func UseSchema(schema string) {
AuthChallenges = AuthChallenges.FromSchema(schema)
BlockedEmails = BlockedEmails.FromSchema(schema)
DeviceSessions = DeviceSessions.FromSchema(schema)
DiplomailMessages = DiplomailMessages.FromSchema(schema)
DiplomailRecipients = DiplomailRecipients.FromSchema(schema)
DiplomailTranslations = DiplomailTranslations.FromSchema(schema)
EngineVersions = EngineVersions.FromSchema(schema)
EntitlementRecords = EntitlementRecords.FromSchema(schema)
EntitlementSnapshots = EntitlementSnapshots.FromSchema(schema)
@@ -606,7 +606,8 @@ CREATE TABLE notifications (
'lobby.race_name.expired',
'runtime.image_pull_failed', 'runtime.container_start_failed',
'runtime.start_config_invalid',
'game.turn.ready', 'game.paused'
'game.turn.ready', 'game.paused',
'diplomail.message.received'
))
);
@@ -662,6 +663,126 @@ CREATE TABLE notification_malformed_intents (
CREATE INDEX notification_malformed_intents_listing_idx
ON notification_malformed_intents (received_at DESC);
-- =====================================================================
-- Diplomail domain
-- =====================================================================
-- diplomail_messages is the canonical record of every diplomatic-mail
-- send: one row per personal message, owner/admin send, broadcast, or
-- system notification. game_name is captured at insert time so the
-- bulk-purge / rename paths still render correctly. sender_username
-- carries either accounts.user_name (sender_kind='player') or
-- admin_accounts.username (sender_kind='admin'); system senders leave
-- it NULL. body and subject are plain UTF-8; length limits are enforced
-- in the service layer and may be tuned without a migration.
CREATE TABLE diplomail_messages (
message_id uuid PRIMARY KEY,
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
game_name text NOT NULL,
kind text NOT NULL,
sender_kind text NOT NULL,
sender_user_id uuid,
sender_username text,
-- sender_race_name is the immutable snapshot of the sender's race
-- in this game, captured at insert time when sender_kind='player'.
-- Admin and system messages carry NULL. The Phase 28 mail UI keys
-- per-race threading on this column.
sender_race_name text,
sender_ip text NOT NULL DEFAULT '',
subject text NOT NULL DEFAULT '',
body text NOT NULL,
body_lang text NOT NULL DEFAULT 'und',
broadcast_scope text NOT NULL DEFAULT 'single',
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT diplomail_messages_kind_chk
CHECK (kind IN ('personal', 'admin')),
CONSTRAINT diplomail_messages_sender_kind_chk
CHECK (sender_kind IN ('player', 'admin', 'system')),
CONSTRAINT diplomail_messages_sender_identity_chk CHECK (
(sender_kind = 'player' AND sender_user_id IS NOT NULL AND sender_username IS NOT NULL) OR
(sender_kind = 'admin' AND sender_user_id IS NULL AND sender_username IS NOT NULL) OR
(sender_kind = 'system' AND sender_user_id IS NULL AND sender_username IS NULL)
),
-- sender_race_name is only meaningful for player senders. Admin
-- and system rows never carry a race; player rows carry one when
-- the sender has an active membership at send time (a non-playing
-- private-game owner may legitimately have none).
CONSTRAINT diplomail_messages_sender_race_chk CHECK (
sender_kind = 'player' OR sender_race_name IS NULL
),
CONSTRAINT diplomail_messages_kind_sender_chk CHECK (
(kind = 'personal' AND sender_kind = 'player') OR
(kind = 'admin' AND sender_kind IN ('player', 'admin', 'system'))
),
CONSTRAINT diplomail_messages_broadcast_scope_chk
CHECK (broadcast_scope IN ('single', 'game_broadcast', 'multi_game_broadcast'))
);
CREATE INDEX diplomail_messages_game_idx
ON diplomail_messages (game_id, created_at DESC);
CREATE INDEX diplomail_messages_sender_user_idx
ON diplomail_messages (sender_user_id, created_at DESC)
WHERE sender_user_id IS NOT NULL;
-- diplomail_recipients carries one row per (message, recipient). The
-- per-user read/delete/deliver/notified state lives here. recipient
-- snapshots (user_name, race_name) are captured at insert time so the
-- inbox listing and admin search render without joining accounts /
-- memberships and survive race-name renames, membership revocation,
-- and account soft-delete. recipient_race_name is nullable for the
-- rare admin notifications addressed to a player who no longer has an
-- active membership in the game.
CREATE TABLE diplomail_recipients (
recipient_id uuid PRIMARY KEY,
message_id uuid NOT NULL REFERENCES diplomail_messages (message_id) ON DELETE CASCADE,
game_id uuid NOT NULL,
user_id uuid NOT NULL,
recipient_user_name text NOT NULL,
recipient_race_name text,
recipient_preferred_language text NOT NULL DEFAULT '',
available_at timestamptz,
translation_attempts integer NOT NULL DEFAULT 0,
next_translation_attempt_at timestamptz,
delivered_at timestamptz,
read_at timestamptz,
deleted_at timestamptz,
notified_at timestamptz,
CONSTRAINT diplomail_recipients_unique UNIQUE (message_id, user_id)
);
CREATE INDEX diplomail_recipients_inbox_idx
ON diplomail_recipients (user_id, game_id, deleted_at, read_at);
CREATE INDEX diplomail_recipients_unread_idx
ON diplomail_recipients (user_id, game_id)
WHERE read_at IS NULL AND deleted_at IS NULL AND available_at IS NOT NULL;
-- Index drives the translation worker's pending-pair pickup. The
-- partial filter keeps the scan tight: terminal-state recipients
-- (with a non-NULL available_at) never appear in this btree. The
-- composite ordering puts the next-attempt clock first so the
-- backoff filter (`next_translation_attempt_at <= now()`) seeks
-- before the secondary cluster on (message_id, lang).
CREATE INDEX diplomail_recipients_pending_translation_idx
ON diplomail_recipients (next_translation_attempt_at, message_id, recipient_preferred_language)
WHERE available_at IS NULL;
-- diplomail_translations caches one rendered translation per
-- (message, target_lang) so a broadcast addressed to many recipients
-- with the same preferred_language is translated once. translator
-- identifies the backend that produced the row.
CREATE TABLE diplomail_translations (
translation_id uuid PRIMARY KEY,
message_id uuid NOT NULL REFERENCES diplomail_messages (message_id) ON DELETE CASCADE,
target_lang text NOT NULL,
translated_subject text NOT NULL DEFAULT '',
translated_body text NOT NULL,
translator text NOT NULL,
translated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT diplomail_translations_unique UNIQUE (message_id, target_lang)
);
-- =====================================================================
-- Geo domain
-- =====================================================================
+40 -20
View File
@@ -1,26 +1,46 @@
# Backend migrations
Goose migrations embedded into the backend binary by `embed.go`. Applied
at startup before any listener opens (see `internal/postgres`).
Goose (`pressly/goose/v3`) migrations embedded into the backend binary
by `embed.go`. Applied at startup before any listener opens see
`internal/postgres`.
## Pre-production single-file rule
## Authoring conventions
**While the platform is not yet in production, every schema change goes
into the existing `00001_init.sql` file** rather than a new
`00002_*`-prefixed file. The intent is to keep the schema in one
canonical place so reviewers and developers do not have to reconstruct
the latest shape from a chain of incremental migrations.
- Each schema change is a new file with a monotonically increasing
numeric prefix and a snake-case slug:
`0000N_short_description.sql`. Reuse of a prefix is forbidden once
the file is merged.
- `00001_init.sql` is the historical baseline. Treat it as immutable
history; do not edit it to land new schema. Squashing the chain back
into a fresh `00001` is reserved for the explicit pre-production
cut-over.
- Every file MUST contain both an `-- +goose Up` and `-- +goose Down`
section, even if Down is a single `DROP …` for the same artefacts.
Down migrations are exercised by the schema test and serve as the
documented rollback path.
- Destructive changes (dropping columns/tables, renaming with data
loss) MUST be split into at least two migrations so the chain stays
rollable forward and backward without coordinated code+schema
windows:
1. add the new shape, dual-write the data, leave the old shape in
place;
2. once all readers have switched, drop the old shape in a follow-up
migration.
- Migrations are applied automatically on backend startup, so a fresh
push to `development` plus the `dev-deploy.yaml` workflow brings the
long-lived dev database up to head without manual intervention.
`make -C tools/dev-deploy clean-data` is only needed when a developer
deliberately wants a fresh database.
- The integration harness (`backend/internal/postgres/migrations_test.go`)
spins up a disposable Postgres per run and asserts the final table
set. When a migration adds or removes tables, update the expected
list in the same patch.
Operationally this means that pulling a branch with schema changes
requires a fresh database — the only consumer today is local development
and integration tests, both of which spin up disposable Postgres
instances.
## Pre-production squash
> **Remove this rule before the first production deployment.** From
> that point on every schema change must be a new migration file with a
> monotonically increasing prefix, and `00001_init.sql` becomes
> immutable history.
If you need to make a change, edit `00001_init.sql` directly. Down
migrations should still be kept in sync (they live at the bottom of the
file — currently a single `DROP SCHEMA backend CASCADE`).
The chain may be squashed back into one clean `00001_init.sql` before
the first production deployment. That is a deliberate, one-time
operation; until then, additive numbered files are the rule. After the
squash this file gets a short note that `00001_init.sql` represents
the production baseline and the policy above continues to apply for
every later migration.
@@ -68,6 +68,10 @@ var expectedBackendTables = []string{
"notification_malformed_intents",
"notification_routes",
"notifications",
// Diplomail domain.
"diplomail_messages",
"diplomail_recipients",
"diplomail_translations",
// Geo domain.
"user_country_counters",
}
+69
View File
@@ -10,7 +10,10 @@ package postgres
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"strings"
"time"
"galaxy/backend/internal/config"
@@ -67,13 +70,29 @@ func Open(ctx context.Context, cfg config.PostgresConfig, runtime *telemetry.Run
// backend table lives here.
const schemaName = "backend"
// migrationRetryAttempts and migrationRetryBackoff bound the transient-error
// retry around ApplyMigrations. A freshly started Postgres — notably a test
// container — can reset a pooled connection moments after it reports ready,
// which surfaces as `driver: bad connection` mid-migration; a handful of quick
// retries rides over that without masking real failures.
const (
migrationRetryAttempts = 5
migrationRetryBackoff = 250 * time.Millisecond
)
// ApplyMigrations runs every pending Up migration embedded in the backend
// binary against db. The schema is created upfront so goose's bookkeeping
// table (`goose_db_version`, scoped to the DSN `search_path = backend`)
// has somewhere to land before the first migration runs; migration
// `00001_init.sql` re-asserts the schema with `IF NOT EXISTS`, so the
// double-create is idempotent.
//
// The apply is retried on transient connection errors (see retryOnTransient).
// Both steps are idempotent — `CREATE SCHEMA IF NOT EXISTS` and goose's
// version tracking — so a retry after a dropped connection re-runs cleanly and
// resumes from the last committed migration.
func ApplyMigrations(ctx context.Context, db *sql.DB) error {
return retryOnTransient(ctx, migrationRetryAttempts, migrationRetryBackoff, func() error {
if _, err := db.ExecContext(ctx, "CREATE SCHEMA IF NOT EXISTS "+schemaName); err != nil {
return fmt.Errorf("ensure backend schema: %w", err)
}
@@ -81,4 +100,54 @@ func ApplyMigrations(ctx context.Context, db *sql.DB) error {
return fmt.Errorf("apply backend migrations: %w", err)
}
return nil
})
}
// retryOnTransient runs op up to attempts times, retrying only when op fails
// with a transient connection error (see isTransientConnError) — a dropped,
// reset, or refused connection, as opposed to a deterministic SQL error. It
// waits backoff between attempts and stops early if ctx is cancelled. A
// non-transient error, or the error from the final attempt, is returned as-is.
func retryOnTransient(ctx context.Context, attempts int, backoff time.Duration, op func() error) error {
var err error
for attempt := 1; attempt <= attempts; attempt++ {
if err = op(); err == nil {
return nil
}
if attempt == attempts || !isTransientConnError(err) {
return err
}
select {
case <-ctx.Done():
return errors.Join(err, ctx.Err())
case <-time.After(backoff):
}
}
return err
}
// isTransientConnError reports whether err is a transient connection-level
// failure worth retrying. It matches database/sql's driver.ErrBadConn and the
// connection-failure messages Postgres drivers surface, while leaving
// deterministic SQL errors (syntax, constraint violations) to fail fast.
func isTransientConnError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, driver.ErrBadConn) {
return true
}
msg := strings.ToLower(err.Error())
for _, s := range []string{
"bad connection",
"connection refused",
"connection reset",
"broken pipe",
"server closed the connection",
} {
if strings.Contains(msg, s) {
return true
}
}
return false
}
+103
View File
@@ -0,0 +1,103 @@
package postgres
import (
"context"
"database/sql/driver"
"errors"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIsTransientConnError(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"driver.ErrBadConn", driver.ErrBadConn, true},
{"wrapped ErrBadConn", fmt.Errorf("run migrations: %w", driver.ErrBadConn), true},
// The exact shape observed flaking CI: goose surfaces the driver
// error as a plain string, so errors.Is can't see ErrBadConn.
{"bad connection string", errors.New(`apply backend migrations: run migrations: ERROR 00001_init.sql: CREATE TABLE race_names: driver: bad connection`), true},
{"connection refused", errors.New("dial tcp 127.0.0.1:5432: connect: connection refused"), true},
{"connection reset", errors.New("read tcp: connection reset by peer"), true},
{"broken pipe", errors.New("write tcp: broken pipe"), true},
{"server closed", errors.New("pq: server closed the connection unexpectedly"), true},
{"syntax error is not transient", errors.New(`pq: syntax error at or near "TABL"`), false},
{"constraint violation is not transient", errors.New("pq: duplicate key value violates unique constraint"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, isTransientConnError(tt.err))
})
}
}
func TestRetryOnTransientSucceedsAfterTransientFailures(t *testing.T) {
t.Parallel()
calls := 0
err := retryOnTransient(context.Background(), 5, time.Millisecond, func() error {
calls++
if calls < 3 {
return fmt.Errorf("attempt %d: %w", calls, driver.ErrBadConn)
}
return nil
})
require.NoError(t, err)
assert.Equal(t, 3, calls, "should retry until the transient error clears")
}
func TestRetryOnTransientStopsOnNonTransient(t *testing.T) {
t.Parallel()
sentinel := errors.New(`pq: syntax error at or near "TABL"`)
calls := 0
err := retryOnTransient(context.Background(), 5, time.Millisecond, func() error {
calls++
return sentinel
})
require.ErrorIs(t, err, sentinel)
assert.Equal(t, 1, calls, "a deterministic SQL error must not be retried")
}
func TestRetryOnTransientExhaustsAttempts(t *testing.T) {
t.Parallel()
calls := 0
err := retryOnTransient(context.Background(), 3, time.Millisecond, func() error {
calls++
return driver.ErrBadConn
})
require.ErrorIs(t, err, driver.ErrBadConn)
assert.Equal(t, 3, calls, "must stop after the attempt budget is spent")
}
func TestRetryOnTransientRespectsContextCancellation(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
cancel()
calls := 0
err := retryOnTransient(ctx, 5, time.Hour, func() error {
calls++
return driver.ErrBadConn
})
require.ErrorIs(t, err, context.Canceled)
require.ErrorIs(t, err, driver.ErrBadConn, "the underlying transient error is preserved")
assert.Equal(t, 1, calls, "cancellation during backoff stops further attempts")
}
+1 -1
View File
@@ -52,7 +52,7 @@ var (
ErrTurnAlreadyClosed = errors.New("runtime: turn already closed")
// ErrGamePaused reports that the game is not in a state that
// accepts user-games commands or orders: the runtime row
// accepts user-games orders: the runtime row
// carries `paused = true`, or the runtime status lands on any
// terminal value (`engine_unreachable`, `generation_failed`,
// `stopped`, `finished`, `removed`), or the game has not yet
+32 -14
View File
@@ -258,10 +258,10 @@ func (s *Service) ResolvePlayerMapping(ctx context.Context, gameID, userID uuid.
}
// CheckOrdersAccept verifies that the runtime is in a state that
// accepts user-games commands and orders. It is called by the user
// game-proxy handlers (`Commands`, `Orders`) before forwarding to
// engine, so the backend's turn-cutoff and pause guards run before
// network traffic leaves the host. The decision itself lives in the
// accepts user-games orders. It is called by the user game-proxy
// handler (`Orders`) before forwarding to engine, so the backend's
// turn-cutoff and pause guards run before network traffic leaves the
// host. The decision itself lives in the
// pure helper `OrdersAcceptStatus` so it can be unit-tested without
// constructing a full Service.
//
@@ -276,7 +276,7 @@ func (s *Service) CheckOrdersAccept(ctx context.Context, gameID uuid.UUID) error
}
// OrdersAcceptStatus inspects a runtime record and returns the
// matching sentinel for the user-games order/command pre-check:
// matching sentinel for the user-games order pre-check:
//
// - `runtime_status = generation_in_progress` → `ErrTurnAlreadyClosed`.
// The cron-driven `Scheduler.tick` has flipped the row before
@@ -537,10 +537,7 @@ func (s *Service) runStart(ctx context.Context, op OperationLog) error {
Env: map[string]string{
"GAME_STATE_PATH": statePath,
},
Labels: map[string]string{
"galaxy.game_id": gameID.String(),
"galaxy.engine_version": version.Version,
},
Labels: s.engineLabels(gameID.String(), version.Version),
BindMounts: []dockerclient.BindMount{
{
HostPath: hostStatePath,
@@ -610,7 +607,7 @@ func (s *Service) runStart(ctx context.Context, op OperationLog) error {
return err
}
initResp, err := s.deps.Engine.Init(ctx, runResult.EngineEndpoint, rest.InitRequest{Races: races})
initResp, err := s.deps.Engine.Init(ctx, runResult.EngineEndpoint, rest.InitRequest{GameID: gameID, Races: races})
if err != nil {
s.deps.Logger.Warn("engine init failed",
zap.String("game_id", gameID.String()),
@@ -735,10 +732,7 @@ func (s *Service) runPatch(ctx context.Context, op OperationLog, target EngineVe
Env: map[string]string{
"GAME_STATE_PATH": statePath,
},
Labels: map[string]string{
"galaxy.game_id": op.GameID.String(),
"galaxy.engine_version": target.Version,
},
Labels: s.engineLabels(op.GameID.String(), target.Version),
BindMounts: []dockerclient.BindMount{
{HostPath: hostStatePath, MountPath: s.deps.Config.ContainerStateMount},
},
@@ -938,6 +932,30 @@ func (s *Service) upsertRuntimeRecord(ctx context.Context, in runtimeRecordInser
// containers attach to. Wired from cfg.Docker.Network through Deps.
func (s *Service) dockerNetwork() string { return s.deps.DockerNetwork }
// engineLabels returns the label set stamped on every engine container
// spawned for gameID running engineVersion. The runtime adapter merges
// `dockerclient.ManagedLabel` separately; this helper covers the
// game-scoped labels plus an optional `galaxy.stack=<value>` from the
// runtime config so host-side tooling can scope cleanup by dev stack
// without touching unrelated workloads.
func (s *Service) engineLabels(gameID, engineVersion string) map[string]string {
return engineLabels(gameID, engineVersion, s.deps.Config.StackLabel)
}
// engineLabels is the side-effect-free part of `(*Service).engineLabels`,
// exposed at package scope so unit tests can exercise the labelling
// rules without building a full Service.
func engineLabels(gameID, engineVersion, stackLabel string) map[string]string {
labels := map[string]string{
"galaxy.game_id": gameID,
"galaxy.engine_version": engineVersion,
}
if stackLabel != "" {
labels["galaxy.stack"] = stackLabel
}
return labels
}
// waitForEngineHealthz polls the engine `/healthz` endpoint until it
// responds 2xx or until the timeout elapses. The Docker daemon
// reports a container as `running` as soon as the entrypoint starts,
@@ -203,6 +203,13 @@ func TestServiceStartGameEndToEnd(t *testing.T) {
case "/healthz":
w.WriteHeader(http.StatusOK)
case "/api/v1/admin/init":
var got rest.InitRequest
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode init request: %v", err)
}
if got.GameID != gameID {
t.Errorf("init request gameId = %s, want %s", got.GameID, gameID)
}
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 0, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 3, Population: 10}}})
case "/api/v1/admin/status":
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 1, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 5, Population: 12}}})
@@ -0,0 +1,51 @@
package runtime
import "testing"
func TestEngineLabels(t *testing.T) {
t.Parallel()
cases := []struct {
name string
gameID string
version string
stackLabel string
want map[string]string
}{
{
name: "stack label omitted when empty",
gameID: "11111111-1111-1111-1111-111111111111",
version: "0.1.0",
stackLabel: "",
want: map[string]string{
"galaxy.game_id": "11111111-1111-1111-1111-111111111111",
"galaxy.engine_version": "0.1.0",
},
},
{
name: "stack label included when set",
gameID: "22222222-2222-2222-2222-222222222222",
version: "0.2.3",
stackLabel: "dev-deploy",
want: map[string]string{
"galaxy.game_id": "22222222-2222-2222-2222-222222222222",
"galaxy.engine_version": "0.2.3",
"galaxy.stack": "dev-deploy",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := engineLabels(tc.gameID, tc.version, tc.stackLabel)
if len(got) != len(tc.want) {
t.Fatalf("len(labels) = %d, want %d (got %v)", len(got), len(tc.want), got)
}
for k, v := range tc.want {
if got[k] != v {
t.Errorf("labels[%q] = %q, want %q", k, got[k], v)
}
}
})
}
}
+30
View File
@@ -46,6 +46,7 @@ var pathParamStubs = map[string]string{
"user_id": "00000000-0000-0000-0000-000000000007",
"device_session_id": "00000000-0000-0000-0000-000000000008",
"battle_id": "00000000-0000-0000-0000-000000000009",
"message_id": "00000000-0000-0000-0000-00000000000a",
"id": "1.2.3",
"username": "alice",
"turn": "42",
@@ -149,6 +150,35 @@ var requestBodyStubs = map[string]map[string]any{
"user_id": pathParamStubs["user_id"],
"reason": "ToS violation",
},
"userMailSendPersonal": {
"recipient_user_id": pathParamStubs["user_id"],
"subject": "Contract test subject",
"body": "Contract test body",
},
"userMailSendAdmin": {
"target": "user",
"recipient_user_id": pathParamStubs["user_id"],
"subject": "Contract test admin subject",
"body": "Contract test admin body",
},
"adminDiplomailSend": {
"target": "user",
"recipient_user_id": pathParamStubs["user_id"],
"subject": "Contract test admin subject",
"body": "Contract test admin body",
},
"userMailSendBroadcast": {
"subject": "Contract test paid broadcast",
"body": "Contract test paid broadcast body",
},
"adminDiplomailBroadcast": {
"scope": "all_running",
"subject": "Contract test multi-game broadcast",
"body": "Contract test multi-game broadcast body",
},
"adminDiplomailCleanup": {
"older_than_years": 1,
},
}
// TestOpenAPIContract is the top-level OpenAPI contract test. It
@@ -0,0 +1,235 @@
package server
import (
"bytes"
"net/http"
"net/url"
"strings"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/opsstatus"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/server/middleware/basicauth"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// AdminConsoleHandlers renders the server-side operator console mounted under
// the `/_gm` route group. It wraps the framework-agnostic
// adminconsole.Renderer and CSRF signer with the gin glue: the per-page
// handlers, the embedded static-asset handler, and the CSRF guard middleware
// applied to state-changing requests. Authentication is provided by the shared
// admin Basic Auth middleware mounted on the group, so this type assumes the
// caller has already been verified.
type AdminConsoleHandlers struct {
renderer *adminconsole.Renderer
csrf *adminconsole.CSRF
assets http.Handler
monitor opsstatus.Reader
ready func() bool
users UserAdmin
games GameAdmin
runtime RuntimeAdmin
engineVersions EngineVersionAdmin
operators OperatorAdmin
mail MailAdmin
notifications NotificationAdmin
diplomail DiplomailAdmin
logger *zap.Logger
}
// AdminConsoleDeps bundles the collaborators for the operator console. Every
// field is optional: a nil Renderer or CSRF falls back to the embedded default
// templates and a per-process random key; a nil Monitor renders the dashboard
// without the monitoring panels; a nil Ready reports backend readiness as not
// ready; a nil Logger falls back to zap.NewNop.
type AdminConsoleDeps struct {
Renderer *adminconsole.Renderer
CSRF *adminconsole.CSRF
Monitor opsstatus.Reader
Ready func() bool
Users UserAdmin
Games GameAdmin
Runtime RuntimeAdmin
EngineVersions EngineVersionAdmin
Operators OperatorAdmin
Mail MailAdmin
Notifications NotificationAdmin
Diplomail DiplomailAdmin
Logger *zap.Logger
}
// NewAdminConsoleHandlers constructs the console handler set from deps. It
// panics only on conditions that are unrecoverable at startup (template parse
// failure or crypto/rand failure), both of which indicate a broken build or
// host rather than a runtime input.
func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers {
logger := deps.Logger
if logger == nil {
logger = zap.NewNop()
}
renderer := deps.Renderer
if renderer == nil {
renderer = adminconsole.MustNewRenderer()
}
csrf := deps.CSRF
if csrf == nil {
generated, err := adminconsole.NewRandomCSRF()
if err != nil {
panic(err)
}
csrf = generated
}
assetsFS, err := adminconsole.Assets()
if err != nil {
panic(err)
}
return &AdminConsoleHandlers{
renderer: renderer,
csrf: csrf,
assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))),
monitor: deps.Monitor,
ready: deps.Ready,
users: deps.Users,
games: deps.Games,
runtime: deps.Runtime,
engineVersions: deps.EngineVersions,
operators: deps.Operators,
mail: deps.Mail,
notifications: deps.Notifications,
diplomail: deps.Diplomail,
logger: logger.Named("http.admin.console"),
}
}
// Dashboard renders the console landing page (GET /_gm and GET /_gm/),
// including the monitoring panels when an ops-status reader is wired.
func (h *AdminConsoleHandlers) Dashboard() gin.HandlerFunc {
return func(c *gin.Context) {
data := adminconsole.DashboardData{}
if h.ready != nil {
data.BackendReady = h.ready()
}
if h.monitor != nil {
data.MonitorAvailable = true
snapshot := h.monitor.Collect(c.Request.Context())
data.PostgresHealthy = snapshot.PostgresHealthy
data.Runtimes = toViewCounts(snapshot.Runtimes)
data.MailDeliveries = toViewCounts(snapshot.MailDeliveries)
data.NotificationRoutes = toViewCounts(snapshot.NotificationRoutes)
data.NotificationMalformed = snapshot.NotificationMalformed
data.Errors = snapshot.Errors
}
h.render(c, http.StatusOK, "dashboard", "dashboard", "Dashboard", data)
}
}
// toViewCounts maps ops-status counts to the console's view-layer counts.
func toViewCounts(in []opsstatus.StatusCount) []adminconsole.StatusCount {
if len(in) == 0 {
return nil
}
out := make([]adminconsole.StatusCount, len(in))
for i, sc := range in {
out[i] = adminconsole.StatusCount{Status: sc.Status, Count: sc.Count}
}
return out
}
// Asset serves the embedded console static assets under `/_gm/assets/`.
func (h *AdminConsoleHandlers) Asset() gin.HandlerFunc {
return gin.WrapH(h.assets)
}
// RequireCSRF returns middleware guarding state-changing requests against
// cross-site request forgery. Safe methods pass through untouched. For unsafe
// methods it requires both a same-origin Origin/Referer header (when the
// browser sends one) and a valid per-operator token in the `_csrf` form field;
// either check failing yields 403.
func (h *AdminConsoleHandlers) RequireCSRF() gin.HandlerFunc {
return func(c *gin.Context) {
if isSafeHTTPMethod(c.Request.Method) {
c.Next()
return
}
if !sameOriginRequest(c.Request) {
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "cross-origin request rejected")
return
}
username, _ := basicauth.UsernameFromContext(c.Request.Context())
if !h.csrf.Verify(username, c.PostForm("_csrf")) {
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "invalid or missing CSRF token")
return
}
c.Next()
}
}
// render composes the data common to every console page (operator name, CSRF
// token, active navigation entry) and writes the named page. It renders into an
// intermediate buffer so a template failure surfaces as a clean 500 without
// emitting a partial document.
func (h *AdminConsoleHandlers) render(c *gin.Context, status int, page, activeNav, title string, data any) {
username, _ := basicauth.UsernameFromContext(c.Request.Context())
var buf bytes.Buffer
err := h.renderer.Render(&buf, page, adminconsole.PageData{
Title: title,
Username: username,
CSRFToken: h.csrf.Token(username),
ActiveNav: activeNav,
Data: data,
})
if err != nil {
h.logger.Error("render admin console page", zap.String("page", page), zap.Error(err))
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "failed to render page")
return
}
c.Data(status, "text/html; charset=utf-8", buf.Bytes())
}
// renderMessage renders the generic message page (not-found, validation, or
// operation-failure notices). class selects the CSS styling and backHref, when
// non-empty, adds a back link.
func (h *AdminConsoleHandlers) renderMessage(c *gin.Context, status int, activeNav, title, message, class, backHref string) {
h.render(c, status, "message", activeNav, title, adminconsole.MessageData{
Message: message,
Class: class,
BackHref: backHref,
})
}
// isSafeHTTPMethod reports whether method is a read-only HTTP method that the
// CSRF guard may let through without a token.
func isSafeHTTPMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
default:
return false
}
}
// sameOriginRequest reports whether the request's Origin (or, failing that,
// Referer) names the same host as the request itself. A request that carries
// neither header is treated as same-origin, leaving the CSRF token as the sole
// guard; a malformed or cross-host value is rejected. This relies on the
// gateway reverse proxy preserving the inbound Host header.
func sameOriginRequest(r *http.Request) bool {
source := r.Header.Get("Origin")
if source == "" {
source = r.Header.Get("Referer")
}
if source == "" {
return true
}
parsed, err := url.Parse(source)
if err != nil || parsed.Host == "" {
return false
}
return strings.EqualFold(parsed.Host, r.Host)
}
@@ -0,0 +1,423 @@
package server
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/lobby"
"galaxy/backend/internal/runtime"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// GameAdmin is the subset of the lobby service the console uses for games.
type GameAdmin interface {
ListAdminGames(ctx context.Context, page, pageSize int) (lobby.GamePage, error)
GetGame(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
CreateGame(ctx context.Context, input lobby.CreateGameInput) (lobby.GameRecord, error)
AdminForceStart(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
AdminForceStop(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
AdminBanMember(ctx context.Context, gameID, userID uuid.UUID, reason string) (lobby.Membership, error)
}
// RuntimeAdmin is the subset of the runtime service the console uses.
type RuntimeAdmin interface {
GetRuntime(ctx context.Context, gameID uuid.UUID) (runtime.RuntimeRecord, error)
AdminRestart(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error)
AdminPatch(ctx context.Context, gameID uuid.UUID, targetVersion string) (runtime.OperationLog, error)
AdminForceNextTurn(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error)
}
// EngineVersionAdmin is the subset of the engine-version service the console uses.
type EngineVersionAdmin interface {
List(ctx context.Context) ([]runtime.EngineVersion, error)
Register(ctx context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error)
Disable(ctx context.Context, version string) (runtime.EngineVersion, error)
}
// GamesList renders GET /_gm/games.
func (h *AdminConsoleHandlers) GamesList() gin.HandlerFunc {
return func(c *gin.Context) {
if h.games == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
return
}
page := parsePositiveQueryInt(c.Query("page"), 1)
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
result, err := h.games.ListAdminGames(c.Request.Context(), page, pageSize)
if err != nil {
h.logger.Error("admin console: list games", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load games.", "bad", "/_gm/")
return
}
h.render(c, http.StatusOK, "games", "games", "Games", toGamesListData(result))
}
}
// GameCreate handles POST /_gm/games — create a public game.
func (h *AdminConsoleHandlers) GameCreate() gin.HandlerFunc {
return func(c *gin.Context) {
if h.games == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
return
}
enrollmentEndsAt, err := parseConsoleDateTime(c.PostForm("enrollment_ends_at"))
if err != nil {
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "Enrollment end must be a valid date/time.", "bad", "/_gm/games")
return
}
game, err := h.games.CreateGame(c.Request.Context(), lobby.CreateGameInput{
OwnerUserID: nil,
Visibility: lobby.VisibilityPublic,
GameName: strings.TrimSpace(c.PostForm("game_name")),
Description: strings.TrimSpace(c.PostForm("description")),
MinPlayers: formInt32(c, "min_players"),
MaxPlayers: formInt32(c, "max_players"),
StartGapHours: formInt32(c, "start_gap_hours"),
StartGapPlayers: formInt32(c, "start_gap_players"),
EnrollmentEndsAt: enrollmentEndsAt,
TurnSchedule: strings.TrimSpace(c.PostForm("turn_schedule")),
TargetEngineVersion: strings.TrimSpace(c.PostForm("target_engine_version")),
})
if err != nil {
if errors.Is(err, lobby.ErrInvalidInput) {
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "The game could not be created: check the fields.", "bad", "/_gm/games")
return
}
h.logger.Error("admin console: create game", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "games", "Create failed", "Failed to create the game.", "bad", "/_gm/games")
return
}
c.Redirect(http.StatusSeeOther, "/_gm/games/"+game.GameID.String())
}
}
// GameDetail renders GET /_gm/games/:game_id with the runtime snapshot.
func (h *AdminConsoleHandlers) GameDetail() gin.HandlerFunc {
return func(c *gin.Context) {
if h.games == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
return
}
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
game, err := h.games.GetGame(c.Request.Context(), gameID)
if err != nil {
if errors.Is(err, lobby.ErrNotFound) {
h.renderMessage(c, http.StatusNotFound, "games", "Game not found", "No such game.", "bad", "/_gm/games")
return
}
h.logger.Error("admin console: get game", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load the game.", "bad", "/_gm/games")
return
}
var runtimeRecord *runtime.RuntimeRecord
if h.runtime != nil {
if record, rtErr := h.runtime.GetRuntime(c.Request.Context(), gameID); rtErr == nil {
runtimeRecord = &record
}
}
h.render(c, http.StatusOK, "game_detail", "games", game.GameName, toGameDetailData(game, runtimeRecord))
}
}
// GameForceStart handles POST /_gm/games/:game_id/force-start.
func (h *AdminConsoleHandlers) GameForceStart() gin.HandlerFunc {
return h.gameAction("force-start", func(ctx context.Context, gameID uuid.UUID) error {
_, err := h.games.AdminForceStart(ctx, gameID)
return err
})
}
// GameForceStop handles POST /_gm/games/:game_id/force-stop.
func (h *AdminConsoleHandlers) GameForceStop() gin.HandlerFunc {
return h.gameAction("force-stop", func(ctx context.Context, gameID uuid.UUID) error {
_, err := h.games.AdminForceStop(ctx, gameID)
return err
})
}
// GameBanMember handles POST /_gm/games/:game_id/ban-member.
func (h *AdminConsoleHandlers) GameBanMember() gin.HandlerFunc {
return func(c *gin.Context) {
if h.games == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
return
}
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
back := "/_gm/games/" + gameID.String()
userID, err := uuid.Parse(strings.TrimSpace(c.PostForm("user_id")))
if err != nil {
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "User ID must be a valid UUID.", "bad", back)
return
}
if _, err := h.games.AdminBanMember(c.Request.Context(), gameID, userID, strings.TrimSpace(c.PostForm("reason"))); err != nil {
h.logger.Error("admin console: ban member", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "games", "Ban failed", "Failed to ban the member.", "bad", back)
return
}
c.Redirect(http.StatusSeeOther, back)
}
}
// RuntimeRestart handles POST /_gm/games/:game_id/runtime/restart.
func (h *AdminConsoleHandlers) RuntimeRestart() gin.HandlerFunc {
return h.runtimeAction("restart", func(ctx context.Context, gameID uuid.UUID) error {
_, err := h.runtime.AdminRestart(ctx, gameID)
return err
})
}
// RuntimeForceNextTurn handles POST /_gm/games/:game_id/runtime/force-next-turn.
func (h *AdminConsoleHandlers) RuntimeForceNextTurn() gin.HandlerFunc {
return h.runtimeAction("force-next-turn", func(ctx context.Context, gameID uuid.UUID) error {
_, err := h.runtime.AdminForceNextTurn(ctx, gameID)
return err
})
}
// RuntimePatch handles POST /_gm/games/:game_id/runtime/patch.
func (h *AdminConsoleHandlers) RuntimePatch() gin.HandlerFunc {
return func(c *gin.Context) {
if h.runtime == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games")
return
}
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
back := "/_gm/games/" + gameID.String()
target := strings.TrimSpace(c.PostForm("target_version"))
if target == "" {
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "A target version is required.", "bad", back)
return
}
if _, err := h.runtime.AdminPatch(c.Request.Context(), gameID, target); err != nil {
h.logger.Error("admin console: runtime patch", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "games", "Patch failed", "Failed to patch the runtime.", "bad", back)
return
}
c.Redirect(http.StatusSeeOther, back)
}
}
// gameAction is the shared shape for game-state POST actions that take only the
// game id and redirect back to the detail page.
func (h *AdminConsoleHandlers) gameAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc {
return func(c *gin.Context) {
if h.games == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
return
}
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
back := "/_gm/games/" + gameID.String()
if err := run(c.Request.Context(), gameID); err != nil {
h.logger.Error("admin console: game "+label, zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The "+label+" action failed.", "bad", back)
return
}
c.Redirect(http.StatusSeeOther, back)
}
}
// runtimeAction is the shared shape for runtime POST actions that take only the
// game id and redirect back to the detail page.
func (h *AdminConsoleHandlers) runtimeAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc {
return func(c *gin.Context) {
if h.runtime == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games")
return
}
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
back := "/_gm/games/" + gameID.String()
if err := run(c.Request.Context(), gameID); err != nil {
h.logger.Error("admin console: runtime "+label, zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The runtime "+label+" action failed.", "bad", back)
return
}
c.Redirect(http.StatusSeeOther, back)
}
}
// EngineVersionsList renders GET /_gm/engine-versions.
func (h *AdminConsoleHandlers) EngineVersionsList() gin.HandlerFunc {
return func(c *gin.Context) {
if h.engineVersions == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
return
}
items, err := h.engineVersions.List(c.Request.Context())
if err != nil {
h.logger.Error("admin console: list engine versions", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Engine versions", "Failed to load engine versions.", "bad", "/_gm/")
return
}
h.render(c, http.StatusOK, "engine_versions", "games", "Engine versions", toEngineVersionsData(items))
}
}
// EngineVersionRegister handles POST /_gm/engine-versions.
func (h *AdminConsoleHandlers) EngineVersionRegister() gin.HandlerFunc {
return func(c *gin.Context) {
if h.engineVersions == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
return
}
enabled := c.PostForm("enabled") == "true"
_, err := h.engineVersions.Register(c.Request.Context(), runtime.RegisterInput{
Version: strings.TrimSpace(c.PostForm("version")),
ImageRef: strings.TrimSpace(c.PostForm("image_ref")),
Enabled: &enabled,
})
if err != nil {
if errors.Is(err, runtime.ErrInvalidInput) || errors.Is(err, runtime.ErrEngineVersionTaken) {
h.renderMessage(c, http.StatusBadRequest, "engine-versions", "Invalid input", "The version could not be registered (invalid semver, missing image, or duplicate).", "bad", "/_gm/engine-versions")
return
}
h.logger.Error("admin console: register engine version", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Register failed", "Failed to register the engine version.", "bad", "/_gm/engine-versions")
return
}
c.Redirect(http.StatusSeeOther, "/_gm/engine-versions")
}
}
// EngineVersionDisable handles POST /_gm/engine-versions/:version/disable.
func (h *AdminConsoleHandlers) EngineVersionDisable() gin.HandlerFunc {
return func(c *gin.Context) {
if h.engineVersions == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
return
}
version := strings.TrimSpace(c.Param("version"))
if _, err := h.engineVersions.Disable(c.Request.Context(), version); err != nil {
h.logger.Error("admin console: disable engine version", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Disable failed", "Failed to disable the engine version.", "bad", "/_gm/engine-versions")
return
}
c.Redirect(http.StatusSeeOther, "/_gm/engine-versions")
}
}
// formInt32 reads a non-negative int32 form field, defaulting to 0.
func formInt32(c *gin.Context, name string) int32 {
parsed, err := strconv.Atoi(strings.TrimSpace(c.PostForm(name)))
if err != nil || parsed < 0 {
return 0
}
return int32(parsed)
}
// parseConsoleDateTime parses the value of an <input type="datetime-local">
// (or an RFC 3339 timestamp) as UTC.
func parseConsoleDateTime(raw string) (time.Time, error) {
raw = strings.TrimSpace(raw)
for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", time.RFC3339} {
if t, err := time.ParseInLocation(layout, raw, time.UTC); err == nil {
return t.UTC(), nil
}
}
return time.Time{}, errors.New("invalid date/time")
}
// toGamesListData maps a game page into the games list view model.
func toGamesListData(page lobby.GamePage) adminconsole.GamesListData {
data := adminconsole.GamesListData{
Items: make([]adminconsole.GameRow, 0, len(page.Items)),
Page: page.Page,
PageSize: page.PageSize,
Total: page.Total,
PrevPage: page.Page - 1,
NextPage: page.Page + 1,
HasPrev: page.Page > 1,
HasNext: page.Page*page.PageSize < page.Total,
}
for _, game := range page.Items {
data.Items = append(data.Items, adminconsole.GameRow{
GameID: game.GameID.String(),
GameName: game.GameName,
Visibility: game.Visibility,
Status: game.Status,
Owner: ownerLabel(game.OwnerUserID),
Players: strconv.Itoa(int(game.MinPlayers)) + "" + strconv.Itoa(int(game.MaxPlayers)),
TurnSchedule: game.TurnSchedule,
CreatedAt: fmtConsoleTime(game.CreatedAt),
})
}
return data
}
// toGameDetailData maps a game record and optional runtime record into the
// detail view model.
func toGameDetailData(game lobby.GameRecord, rec *runtime.RuntimeRecord) adminconsole.GameDetailData {
data := adminconsole.GameDetailData{
GameID: game.GameID.String(),
GameName: game.GameName,
Description: game.Description,
Visibility: game.Visibility,
Status: game.Status,
Owner: ownerLabel(game.OwnerUserID),
MinPlayers: game.MinPlayers,
MaxPlayers: game.MaxPlayers,
StartGapHours: game.StartGapHours,
StartGapPlayers: game.StartGapPlayers,
TurnSchedule: game.TurnSchedule,
TargetEngineVersion: game.TargetEngineVersion,
EnrollmentEndsAt: fmtConsoleTime(game.EnrollmentEndsAt),
CreatedAt: fmtConsoleTime(game.CreatedAt),
StartedAt: fmtConsoleTimePtr(game.StartedAt),
FinishedAt: fmtConsoleTimePtr(game.FinishedAt),
}
if rec != nil {
data.HasRuntime = true
data.RuntimeStatus = rec.Status
data.CurrentEngineVersion = rec.CurrentEngineVersion
data.EngineHealth = rec.EngineHealth
data.CurrentTurn = rec.CurrentTurn
data.NextGenerationAt = fmtConsoleTimePtr(rec.NextGenerationAt)
data.Paused = rec.Paused
}
return data
}
// toEngineVersionsData maps engine versions into the registry view model.
func toEngineVersionsData(items []runtime.EngineVersion) adminconsole.EngineVersionsData {
data := adminconsole.EngineVersionsData{Items: make([]adminconsole.EngineVersionRow, 0, len(items))}
for _, v := range items {
data.Items = append(data.Items, adminconsole.EngineVersionRow{
Version: v.Version,
ImageRef: v.ImageRef,
Enabled: v.Enabled,
CreatedAt: fmtConsoleTime(v.CreatedAt),
})
}
return data
}
// ownerLabel renders an optional owner id; public games have no owner.
func ownerLabel(ownerID *uuid.UUID) string {
if ownerID == nil {
return "—"
}
return ownerID.String()
}
@@ -0,0 +1,353 @@
package server
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/lobby"
"galaxy/backend/internal/runtime"
"galaxy/backend/internal/server/middleware/basicauth"
"github.com/google/uuid"
"go.uber.org/zap"
)
type fakeGameAdmin struct {
page lobby.GamePage
game lobby.GameRecord
getErr error
created lobby.CreateGameInput
createCalls int
forceStartCalls int
forceStopCalls int
banCalls int
lastBanUser uuid.UUID
lastBanReason string
}
func (f *fakeGameAdmin) ListAdminGames(context.Context, int, int) (lobby.GamePage, error) {
return f.page, nil
}
func (f *fakeGameAdmin) GetGame(context.Context, uuid.UUID) (lobby.GameRecord, error) {
return f.game, f.getErr
}
func (f *fakeGameAdmin) CreateGame(_ context.Context, in lobby.CreateGameInput) (lobby.GameRecord, error) {
f.createCalls++
f.created = in
return f.game, nil
}
func (f *fakeGameAdmin) AdminForceStart(context.Context, uuid.UUID) (lobby.GameRecord, error) {
f.forceStartCalls++
return f.game, nil
}
func (f *fakeGameAdmin) AdminForceStop(context.Context, uuid.UUID) (lobby.GameRecord, error) {
f.forceStopCalls++
return f.game, nil
}
func (f *fakeGameAdmin) AdminBanMember(_ context.Context, _, userID uuid.UUID, reason string) (lobby.Membership, error) {
f.banCalls++
f.lastBanUser = userID
f.lastBanReason = reason
return lobby.Membership{}, nil
}
type fakeRuntimeAdmin struct {
record runtime.RuntimeRecord
getErr error
restartCalls int
forceNextCalls int
patchCalls int
lastPatchVersion string
}
func (f *fakeRuntimeAdmin) GetRuntime(context.Context, uuid.UUID) (runtime.RuntimeRecord, error) {
return f.record, f.getErr
}
func (f *fakeRuntimeAdmin) AdminRestart(context.Context, uuid.UUID) (runtime.OperationLog, error) {
f.restartCalls++
return runtime.OperationLog{}, nil
}
func (f *fakeRuntimeAdmin) AdminPatch(_ context.Context, _ uuid.UUID, target string) (runtime.OperationLog, error) {
f.patchCalls++
f.lastPatchVersion = target
return runtime.OperationLog{}, nil
}
func (f *fakeRuntimeAdmin) AdminForceNextTurn(context.Context, uuid.UUID) (runtime.OperationLog, error) {
f.forceNextCalls++
return runtime.OperationLog{}, nil
}
type fakeEngineVersionAdmin struct {
list []runtime.EngineVersion
registered runtime.RegisterInput
registerCalls int
disableCalls int
lastDisabled string
}
func (f *fakeEngineVersionAdmin) List(context.Context) ([]runtime.EngineVersion, error) {
return f.list, nil
}
func (f *fakeEngineVersionAdmin) Register(_ context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error) {
f.registerCalls++
f.registered = in
return runtime.EngineVersion{}, nil
}
func (f *fakeEngineVersionAdmin) Disable(_ context.Context, version string) (runtime.EngineVersion, error) {
f.disableCalls++
f.lastDisabled = version
return runtime.EngineVersion{}, nil
}
func newGamesConsoleRouter(t *testing.T, games GameAdmin, rt RuntimeAdmin, ev EngineVersionAdmin) (http.Handler, *adminconsole.CSRF) {
t.Helper()
csrf := adminconsole.NewCSRF([]byte("test-key"))
handler, err := NewRouter(RouterDependencies{
Logger: zap.NewNop(),
AdminVerifier: basicauth.NewStaticVerifier("secret"),
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{
CSRF: csrf, Games: games, Runtime: rt, EngineVersions: ev,
}),
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
return handler, csrf
}
func consoleGet(t *testing.T, router http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.SetBasicAuth("ops", "secret")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func consolePost(t *testing.T, router http.Handler, path, form string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan"+path, strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", "https://galaxy.lan")
req.SetBasicAuth("ops", "secret")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func TestConsoleGamesList(t *testing.T) {
games := &fakeGameAdmin{page: lobby.GamePage{
Items: []lobby.GameRecord{{GameID: uuid.New(), GameName: "Nova", Visibility: "public", Status: "enrollment_open"}},
Page: 1, PageSize: 50, Total: 1,
}}
router, _ := newGamesConsoleRouter(t, games, nil, nil)
rec := consoleGet(t, router, "/_gm/games")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
for _, want := range []string{"Nova", "public", "enrollment_open", "Create public game"} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("games list missing %q", want)
}
}
}
func TestConsoleGameDetailWithRuntime(t *testing.T) {
id := uuid.New()
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova", Status: "running"}}
rt := &fakeRuntimeAdmin{record: runtime.RuntimeRecord{GameID: id, Status: "running", CurrentEngineVersion: "0.1.0", CurrentTurn: 7}}
router, csrf := newGamesConsoleRouter(t, games, rt, nil)
rec := consoleGet(t, router, "/_gm/games/"+id.String())
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
for _, want := range []string{"Nova", "Force start", "Force stop", "0.1.0", "Patch", "Ban member", csrf.Token("ops")} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("game detail missing %q", want)
}
}
}
func TestConsoleGameDetailNoRuntime(t *testing.T) {
id := uuid.New()
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova"}}
rt := &fakeRuntimeAdmin{getErr: errors.New("not found")}
router, _ := newGamesConsoleRouter(t, games, rt, nil)
rec := consoleGet(t, router, "/_gm/games/"+id.String())
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "No runtime record") {
t.Error("expected a no-runtime note")
}
}
func TestConsoleGameCreate(t *testing.T) {
id := uuid.New()
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
form := "_csrf=" + csrf.Token("ops") +
"&game_name=Nova&description=d&min_players=2&max_players=8&start_gap_hours=0&start_gap_players=0" +
"&enrollment_ends_at=2030-01-02T15:04&turn_schedule=@every+24h&target_engine_version=0.1.0"
rec := consolePost(t, router, "/_gm/games", form)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Location"); got != "/_gm/games/"+id.String() {
t.Errorf("redirect = %q, want detail page", got)
}
if games.createCalls != 1 {
t.Fatalf("CreateGame called %d times, want 1", games.createCalls)
}
if games.created.Visibility != lobby.VisibilityPublic {
t.Errorf("visibility = %q, want public", games.created.Visibility)
}
if games.created.GameName != "Nova" {
t.Errorf("game name = %q", games.created.GameName)
}
if games.created.EnrollmentEndsAt.Year() != 2030 {
t.Errorf("enrollment year = %d, want 2030", games.created.EnrollmentEndsAt.Year())
}
if games.created.OwnerUserID != nil {
t.Error("public game must have a nil owner")
}
}
func TestConsoleGameForceStart(t *testing.T) {
id := uuid.New()
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "_csrf="+csrf.Token("ops"))
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
if games.forceStartCalls != 1 {
t.Errorf("AdminForceStart called %d times, want 1", games.forceStartCalls)
}
}
func TestConsoleGameForceStartRejectsBadCSRF(t *testing.T) {
id := uuid.New()
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
router, _ := newGamesConsoleRouter(t, games, nil, nil)
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "")
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if games.forceStartCalls != 0 {
t.Error("force-start must not run without a CSRF token")
}
}
func TestConsoleGameBanMember(t *testing.T) {
gameID := uuid.New()
target := uuid.New()
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}}
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
form := "_csrf=" + csrf.Token("ops") + "&user_id=" + target.String() + "&reason=cheating"
rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", form)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
}
if games.banCalls != 1 || games.lastBanUser != target || games.lastBanReason != "cheating" {
t.Errorf("ban recorded %d user=%s reason=%q", games.banCalls, games.lastBanUser, games.lastBanReason)
}
}
func TestConsoleGameBanMemberRejectsBadUUID(t *testing.T) {
gameID := uuid.New()
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}}
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", "_csrf="+csrf.Token("ops")+"&user_id=not-a-uuid")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if games.banCalls != 0 {
t.Error("ban must not run with an invalid user id")
}
}
func TestConsoleRuntimePatch(t *testing.T) {
id := uuid.New()
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
rt := &fakeRuntimeAdmin{}
router, csrf := newGamesConsoleRouter(t, games, rt, nil)
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops")+"&target_version=0.1.1")
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
if rt.patchCalls != 1 || rt.lastPatchVersion != "0.1.1" {
t.Errorf("patch recorded %d version=%q", rt.patchCalls, rt.lastPatchVersion)
}
}
func TestConsoleRuntimePatchMissingVersion(t *testing.T) {
id := uuid.New()
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
rt := &fakeRuntimeAdmin{}
router, csrf := newGamesConsoleRouter(t, games, rt, nil)
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops"))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if rt.patchCalls != 0 {
t.Error("patch must not run without a target version")
}
}
func TestConsoleEngineVersions(t *testing.T) {
ev := &fakeEngineVersionAdmin{list: []runtime.EngineVersion{{Version: "0.1.0", ImageRef: "img:0.1.0", Enabled: true}}}
router, csrf := newGamesConsoleRouter(t, nil, nil, ev)
rec := consoleGet(t, router, "/_gm/engine-versions")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
for _, want := range []string{"0.1.0", "img:0.1.0", "Register version", "Disable"} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("engine versions page missing %q", want)
}
}
rec = consolePost(t, router, "/_gm/engine-versions", "_csrf="+csrf.Token("ops")+"&version=0.2.0&image_ref=img:0.2.0&enabled=true")
if rec.Code != http.StatusSeeOther {
t.Fatalf("register status = %d, want 303", rec.Code)
}
if ev.registerCalls != 1 || ev.registered.Version != "0.2.0" || ev.registered.Enabled == nil || !*ev.registered.Enabled {
t.Errorf("register recorded %d version=%q enabled=%v", ev.registerCalls, ev.registered.Version, ev.registered.Enabled)
}
rec = consolePost(t, router, "/_gm/engine-versions/0.1.0/disable", "_csrf="+csrf.Token("ops"))
if rec.Code != http.StatusSeeOther {
t.Fatalf("disable status = %d, want 303", rec.Code)
}
if ev.disableCalls != 1 || ev.lastDisabled != "0.1.0" {
t.Errorf("disable recorded %d version=%q", ev.disableCalls, ev.lastDisabled)
}
}
func TestConsoleGamesUnavailable(t *testing.T) {
router, _ := newGamesConsoleRouter(t, nil, nil, nil)
rec := consoleGet(t, router, "/_gm/games")
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
}
@@ -0,0 +1,327 @@
package server
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/diplomail"
"galaxy/backend/internal/mail"
"galaxy/backend/internal/notification"
"galaxy/backend/internal/server/clientip"
"galaxy/backend/internal/server/middleware/basicauth"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// MailAdmin is the subset of the mail service the console uses.
type MailAdmin interface {
AdminListDeliveries(ctx context.Context, page, pageSize int) (mail.AdminListDeliveriesPage, error)
AdminGetDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error)
AdminListAttempts(ctx context.Context, deliveryID uuid.UUID) ([]mail.Attempt, error)
AdminResendDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error)
AdminListDeadLetters(ctx context.Context, page, pageSize int) (mail.AdminListDeadLettersPage, error)
}
// NotificationAdmin is the subset of the notification service the console uses.
type NotificationAdmin interface {
AdminListNotifications(ctx context.Context, page, pageSize int) (notification.AdminListNotificationsPage, error)
AdminListDeadLetters(ctx context.Context, page, pageSize int) (notification.AdminListDeadLettersPage, error)
AdminListMalformed(ctx context.Context, page, pageSize int) (notification.AdminListMalformedPage, error)
}
// DiplomailAdmin is the subset of the diplomail service the console uses.
type DiplomailAdmin interface {
SendAdminMultiGameBroadcast(ctx context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error)
}
const consoleSnapshotPageSize = 50
// MailPage renders GET /_gm/mail — paginated deliveries plus a dead-letter snapshot.
func (h *AdminConsoleHandlers) MailPage() gin.HandlerFunc {
return func(c *gin.Context) {
if h.mail == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
return
}
page := parsePositiveQueryInt(c.Query("page"), 1)
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
ctx := c.Request.Context()
deliveries, err := h.mail.AdminListDeliveries(ctx, page, pageSize)
if err != nil {
h.logger.Error("admin console: list deliveries", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load deliveries.", "bad", "/_gm/")
return
}
dead, err := h.mail.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize)
if err != nil {
h.logger.Error("admin console: list mail dead-letters", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load dead-letters.", "bad", "/_gm/")
return
}
h.render(c, http.StatusOK, "mail", "mail", "Mail", toMailData(deliveries, dead))
}
}
// MailDeliveryDetail renders GET /_gm/mail/deliveries/:delivery_id.
func (h *AdminConsoleHandlers) MailDeliveryDetail() gin.HandlerFunc {
return func(c *gin.Context) {
if h.mail == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
return
}
deliveryID, ok := parseConsoleDeliveryID(c, h)
if !ok {
return
}
ctx := c.Request.Context()
delivery, err := h.mail.AdminGetDelivery(ctx, deliveryID)
if err != nil {
if errors.Is(err, mail.ErrDeliveryNotFound) {
h.renderMessage(c, http.StatusNotFound, "mail", "Delivery not found", "No such delivery.", "bad", "/_gm/mail")
return
}
h.logger.Error("admin console: get delivery", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load the delivery.", "bad", "/_gm/mail")
return
}
attempts, err := h.mail.AdminListAttempts(ctx, deliveryID)
if err != nil {
h.logger.Error("admin console: list attempts", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load attempts.", "bad", "/_gm/mail")
return
}
h.render(c, http.StatusOK, "mail_delivery", "mail", "Delivery", toMailDeliveryDetail(delivery, attempts))
}
}
// MailResend handles POST /_gm/mail/deliveries/:delivery_id/resend.
func (h *AdminConsoleHandlers) MailResend() gin.HandlerFunc {
return func(c *gin.Context) {
if h.mail == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
return
}
deliveryID, ok := parseConsoleDeliveryID(c, h)
if !ok {
return
}
back := "/_gm/mail/deliveries/" + deliveryID.String()
if _, err := h.mail.AdminResendDelivery(c.Request.Context(), deliveryID); err != nil {
h.logger.Error("admin console: resend delivery", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "mail", "Resend failed", "Failed to resend the delivery (it may already be sent).", "bad", back)
return
}
c.Redirect(http.StatusSeeOther, back)
}
}
// NotificationsPage renders GET /_gm/notifications — notifications, dead-letters,
// and malformed intents on one overview page.
func (h *AdminConsoleHandlers) NotificationsPage() gin.HandlerFunc {
return func(c *gin.Context) {
if h.notifications == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Notifications", "Notification administration is not available.", "bad", "/_gm/")
return
}
ctx := c.Request.Context()
notifications, err := h.notifications.AdminListNotifications(ctx, 1, consoleSnapshotPageSize)
if err != nil {
h.logger.Error("admin console: list notifications", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load notifications.", "bad", "/_gm/")
return
}
dead, err := h.notifications.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize)
if err != nil {
h.logger.Error("admin console: list notification dead-letters", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load dead-letters.", "bad", "/_gm/")
return
}
malformed, err := h.notifications.AdminListMalformed(ctx, 1, consoleSnapshotPageSize)
if err != nil {
h.logger.Error("admin console: list malformed intents", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load malformed intents.", "bad", "/_gm/")
return
}
h.render(c, http.StatusOK, "notifications", "mail", "Notifications", toNotificationsData(notifications, dead, malformed))
}
}
// BroadcastForm renders GET /_gm/broadcast.
func (h *AdminConsoleHandlers) BroadcastForm() gin.HandlerFunc {
return func(c *gin.Context) {
if h.diplomail == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/")
return
}
h.render(c, http.StatusOK, "broadcast", "mail", "Broadcast", nil)
}
}
// BroadcastSend handles POST /_gm/broadcast — multi-game admin broadcast.
func (h *AdminConsoleHandlers) BroadcastSend() gin.HandlerFunc {
return func(c *gin.Context) {
if h.diplomail == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/")
return
}
username, _ := basicauth.UsernameFromContext(c.Request.Context())
gameIDs, err := parseGameIDList(c.PostForm("game_ids"))
if err != nil {
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "Game IDs must be valid UUIDs.", "bad", "/_gm/broadcast")
return
}
_, total, err := h.diplomail.SendAdminMultiGameBroadcast(c.Request.Context(), diplomail.SendMultiGameBroadcastInput{
CallerUsername: username,
Scope: strings.TrimSpace(c.PostForm("scope")),
GameIDs: gameIDs,
RecipientScope: strings.TrimSpace(c.PostForm("recipients")),
Subject: strings.TrimSpace(c.PostForm("subject")),
Body: c.PostForm("body"),
SenderIP: clientip.ExtractSourceIP(c),
})
if err != nil {
if errors.Is(err, diplomail.ErrInvalidInput) {
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "The broadcast was rejected: check the scope, recipients, and body.", "bad", "/_gm/broadcast")
return
}
h.logger.Error("admin console: broadcast", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "mail", "Broadcast failed", "Failed to send the broadcast.", "bad", "/_gm/broadcast")
return
}
h.renderMessage(c, http.StatusOK, "mail", "Broadcast sent", fmt.Sprintf("Broadcast delivered to %d recipients.", total), "ok", "/_gm/broadcast")
}
}
// parseConsoleDeliveryID parses the delivery_id path parameter, rendering a
// console message page on failure.
func parseConsoleDeliveryID(c *gin.Context, h *AdminConsoleHandlers) (uuid.UUID, bool) {
parsed, err := uuid.Parse(c.Param("delivery_id"))
if err != nil {
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "delivery_id must be a valid UUID.", "bad", "/_gm/mail")
return uuid.Nil, false
}
return parsed, true
}
// parseGameIDList parses a comma-separated list of UUIDs, ignoring blanks.
func parseGameIDList(raw string) ([]uuid.UUID, error) {
fields := strings.Split(raw, ",")
ids := make([]uuid.UUID, 0, len(fields))
for _, field := range fields {
field = strings.TrimSpace(field)
if field == "" {
continue
}
parsed, err := uuid.Parse(field)
if err != nil {
return nil, err
}
ids = append(ids, parsed)
}
return ids, nil
}
func toMailData(deliveries mail.AdminListDeliveriesPage, dead mail.AdminListDeadLettersPage) adminconsole.MailData {
data := adminconsole.MailData{
Deliveries: make([]adminconsole.MailDeliveryRow, 0, len(deliveries.Items)),
DeadLetters: make([]adminconsole.MailDeadLetterRow, 0, len(dead.Items)),
Page: deliveries.Page,
PageSize: deliveries.PageSize,
Total: deliveries.Total,
PrevPage: deliveries.Page - 1,
NextPage: deliveries.Page + 1,
HasPrev: deliveries.Page > 1,
HasNext: int64(deliveries.Page*deliveries.PageSize) < deliveries.Total,
}
for _, d := range deliveries.Items {
data.Deliveries = append(data.Deliveries, adminconsole.MailDeliveryRow{
DeliveryID: d.DeliveryID.String(),
Template: d.TemplateID,
Status: d.Status,
Attempts: d.Attempts,
NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt),
Created: fmtConsoleTime(d.CreatedAt),
})
}
for _, d := range dead.Items {
data.DeadLetters = append(data.DeadLetters, adminconsole.MailDeadLetterRow{
DeliveryID: d.DeliveryID.String(),
Reason: d.Reason,
Archived: fmtConsoleTime(d.ArchivedAt),
})
}
return data
}
func toMailDeliveryDetail(d mail.Delivery, attempts []mail.Attempt) adminconsole.MailDeliveryDetail {
detail := adminconsole.MailDeliveryDetail{
DeliveryID: d.DeliveryID.String(),
Template: d.TemplateID,
Status: d.Status,
Attempts: d.Attempts,
NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt),
LastError: d.LastError,
Created: fmtConsoleTime(d.CreatedAt),
Sent: fmtConsoleTimePtr(d.SentAt),
DeadLettered: fmtConsoleTimePtr(d.DeadLetteredAt),
CanResend: d.Status != mail.StatusSent,
AttemptRows: make([]adminconsole.MailAttemptRow, 0, len(attempts)),
}
for _, a := range attempts {
detail.AttemptRows = append(detail.AttemptRows, adminconsole.MailAttemptRow{
AttemptNo: a.AttemptNo,
Outcome: a.Outcome,
Started: fmtConsoleTime(a.StartedAt),
Finished: fmtConsoleTimePtr(a.FinishedAt),
Error: a.Error,
})
}
return detail
}
func toNotificationsData(notifications notification.AdminListNotificationsPage, dead notification.AdminListDeadLettersPage, malformed notification.AdminListMalformedPage) adminconsole.NotificationsData {
data := adminconsole.NotificationsData{
Notifications: make([]adminconsole.NotificationRow, 0, len(notifications.Items)),
DeadLetters: make([]adminconsole.NotificationDeadLetterRow, 0, len(dead.Items)),
Malformed: make([]adminconsole.MalformedRow, 0, len(malformed.Items)),
}
for _, n := range notifications.Items {
data.Notifications = append(data.Notifications, adminconsole.NotificationRow{
NotificationID: n.NotificationID.String(),
Kind: n.Kind,
UserID: optionalUUID(n.UserID),
Created: fmtConsoleTime(n.CreatedAt),
})
}
for _, d := range dead.Items {
data.DeadLetters = append(data.DeadLetters, adminconsole.NotificationDeadLetterRow{
NotificationID: d.NotificationID.String(),
RouteID: d.RouteID.String(),
Reason: d.Reason,
Archived: fmtConsoleTime(d.ArchivedAt),
})
}
for _, m := range malformed.Items {
data.Malformed = append(data.Malformed, adminconsole.MalformedRow{
ID: m.ID.String(),
Reason: m.Reason,
Received: fmtConsoleTime(m.ReceivedAt),
})
}
return data
}
// optionalUUID renders a nullable user id; system-scoped rows have none.
func optionalUUID(id *uuid.UUID) string {
if id == nil {
return "—"
}
return id.String()
}

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