Compare commits

...

45 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
171 changed files with 9179 additions and 2582 deletions
+40 -5
View File
@@ -148,12 +148,37 @@ jobs:
-v "${{ gitea.workspace }}/pkg/geoip/test-data/test-data:/src:ro" \ -v "${{ gitea.workspace }}/pkg/geoip/test-data/test-data:/src:ro" \
alpine sh -c 'cp /src/GeoIP2-Country-Test.mmdb /dst/geoip.mmdb' 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 - name: Recycle engine containers on image drift
run: | run: |
# Compare the freshly-built `galaxy-engine:dev` SHA against # Compare the freshly-built `galaxy-engine:dev` SHA against
# every running `galaxy-game-*` container. The backend # every running `galaxy-game-*` container. The backend
# reconciler adopts pre-existing labelled engine containers # reconciler adopts pre-existing labelled engine containers
# without checking image drift, so a running sandbox would # without checking image drift, so a running game would
# otherwise keep serving the previous engine code until the # otherwise keep serving the previous engine code until the
# container is recycled by hand. This step makes the recycle # container is recycled by hand. This step makes the recycle
# automatic but only when it is actually needed: # automatic but only when it is actually needed:
@@ -168,10 +193,7 @@ jobs:
# silent state corruption otherwise), and cascade-delete # silent state corruption otherwise), and cascade-delete
# the lobby `games` row (the FKs in `00001_init.sql` # the lobby `games` row (the FKs in `00001_init.sql`
# drop the matching `runtime_records`, `memberships`, # drop the matching `runtime_records`, `memberships`,
# `player_mappings`, etc. in the same write). The # `player_mappings`, etc. in the same write).
# `dev-sandbox` bootstrap on the next backend boot finds
# no live sandbox and provisions a fresh one on the new
# engine image.
# #
# Backend is stopped first to keep the reconciler from # Backend is stopped first to keep the reconciler from
# racing the recycle (mid-stream adoption / restart). The # racing the recycle (mid-stream adoption / restart). The
@@ -234,11 +256,24 @@ jobs:
- name: Bring up the stack - name: Bring up the stack
working-directory: tools/dev-deploy working-directory: tools/dev-deploy
env:
# 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: | run: |
# Resolve in the shell, not in YAML expressions — `env.HOME` # Resolve in the shell, not in YAML expressions — `env.HOME`
# is empty at the workflow-evaluation stage. # is empty at the workflow-evaluation stage.
export GALAXY_DEV_GAME_STATE_DIR="$HOME/.galaxy-dev/game-state" export GALAXY_DEV_GAME_STATE_DIR="$HOME/.galaxy-dev/game-state"
mkdir -p "$GALAXY_DEV_GAME_STATE_DIR" 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 docker compose up -d --wait --remove-orphans
- name: Probe the stack - name: Probe the stack
+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
+13
View File
@@ -119,3 +119,16 @@ jobs:
name: playwright-traces name: playwright-traces
path: ui/frontend/test-results/ path: ui/frontend/test-results/
retention-days: 14 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
+6
View File
@@ -16,6 +16,12 @@ This repository hosts the Galaxy Game project.
mirrored into `docs/FUNCTIONAL_ru.md` in the same patch (translate mirrored into `docs/FUNCTIONAL_ru.md` in the same patch (translate
the changed paragraphs only, do not re-translate the whole file). the changed paragraphs only, do not re-translate the whole file).
A full re-translation only happens on explicit owner request. 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 - `docs/TESTING.md` — testing layers (unit / integration), the
integration runbook, and the principles every test must follow integration runbook, and the principles every test must follow
(no-op observability for testcontainers, `t.Fatal` on (no-op observability for testcontainers, `t.Fatal` on
+8 -1
View File
@@ -27,10 +27,16 @@ The implementation specification lives in `PLAN.md`.
| ------------------ | ----------------------------------------------- | ------------------------------------- | | ------------------ | ----------------------------------------------- | ------------------------------------- |
| `/api/v1/public/*` | none | Registration, code confirmation | | `/api/v1/public/*` | none | Registration, code confirmation |
| `/api/v1/user/*` | `X-User-ID` injected by gateway | Authenticated end users | | `/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 | | `/healthz` | none | Liveness probe |
| `/readyz` | none | Readiness 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 The full contract is documented in `openapi.yaml` and validated at
runtime by the contract tests under `internal/server/`. runtime by the contract tests under `internal/server/`.
@@ -100,6 +106,7 @@ fast.
| `BACKEND_GAME_STATE_ROOT` | yes | — | Host directory bind-mounted into engine containers. | | `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_USER` | no | — | Initial admin username; idempotent insert. |
| `BACKEND_ADMIN_BOOTSTRAP_PASSWORD` | no | — | Initial admin password; required if user is set. | | `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_GEOIP_DB_PATH` | yes | — | Filesystem path to GeoLite2 Country `.mmdb`. |
| `BACKEND_OTEL_TRACES_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`. | | `BACKEND_OTEL_TRACES_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`. |
| `BACKEND_OTEL_METRICS_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`, `prometheus`. | | `BACKEND_OTEL_METRICS_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`, `prometheus`. |
+35 -18
View File
@@ -22,10 +22,10 @@ import (
_ "time/tzdata" _ "time/tzdata"
"galaxy/backend/internal/admin" "galaxy/backend/internal/admin"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/app" "galaxy/backend/internal/app"
"galaxy/backend/internal/auth" "galaxy/backend/internal/auth"
"galaxy/backend/internal/config" "galaxy/backend/internal/config"
"galaxy/backend/internal/devsandbox"
"galaxy/backend/internal/diplomail" "galaxy/backend/internal/diplomail"
"galaxy/backend/internal/diplomail/detector" "galaxy/backend/internal/diplomail/detector"
"galaxy/backend/internal/diplomail/translator" "galaxy/backend/internal/diplomail/translator"
@@ -37,6 +37,7 @@ import (
"galaxy/backend/internal/mail" "galaxy/backend/internal/mail"
"galaxy/backend/internal/metricsapi" "galaxy/backend/internal/metricsapi"
"galaxy/backend/internal/notification" "galaxy/backend/internal/notification"
"galaxy/backend/internal/opsstatus"
backendpostgres "galaxy/backend/internal/postgres" backendpostgres "galaxy/backend/internal/postgres"
"galaxy/backend/push" "galaxy/backend/push"
"galaxy/backend/internal/runtime" "galaxy/backend/internal/runtime"
@@ -272,29 +273,18 @@ func run(ctx context.Context) (err error) {
) )
runtimeGateway.svc = runtimeSvc runtimeGateway.svc = runtimeSvc
// Run a single reconciliation pass before the dev-sandbox // Run a single reconciliation pass at startup so any runtime row
// bootstrap so any runtime row pointing at a vanished engine // pointing at a vanished engine container (a host reboot wiped
// container (host reboot wiped /tmp/galaxy-game-state/<uuid>; // /tmp/galaxy-game-state/<uuid>; `tools/local-dev`'s
// `tools/local-dev`'s `prune-broken-engines` target reaped the // `prune-broken-engines` target reaped the husk) is cascaded
// husk) is already cascaded through `markRemoved` → lobby // through `markRemoved` → lobby `cancelled` before the server
// `cancelled` by the time the bootstrap walks the sandbox list. // starts serving requests. Failures are
// 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
// non-fatal: the periodic ticker started later catches up, and // non-fatal: the periodic ticker started later catches up, and
// the worst case degrades to the legacy two-cycle recovery. // the worst case degrades to the legacy two-cycle recovery.
if err := runtimeSvc.Reconciler().Tick(ctx); err != nil { if err := runtimeSvc.Reconciler().Tick(ctx); err != nil {
logger.Warn("pre-bootstrap reconciler tick failed", zap.Error(err)) 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) notifStore := notification.NewStore(db)
notifSvc := notification.NewService(notification.Deps{ notifSvc := notification.NewService(notification.Deps{
Store: notifStore, Store: notifStore,
@@ -360,6 +350,32 @@ func run(ctx context.Context) (err error) {
return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready() 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{ handler, err := backendserver.NewRouter(backendserver.RouterDependencies{
Logger: logger, Logger: logger,
Telemetry: telemetryRT, Telemetry: telemetryRT,
@@ -388,6 +404,7 @@ func run(ctx context.Context) (err error) {
AdminGeo: adminGeoHandlers, AdminGeo: adminGeoHandlers,
UserGames: userGamesHandlers, UserGames: userGamesHandlers,
UserMail: userMailHandlers, UserMail: userMailHandlers,
AdminConsole: adminConsoleHandlers,
}) })
if err != nil { if err != nil {
return fmt.Errorf("build backend router: %w", err) return fmt.Errorf("build backend router: %w", 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. |
@@ -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
}
+14 -51
View File
@@ -55,6 +55,8 @@ const (
envAdminBootstrapUser = "BACKEND_ADMIN_BOOTSTRAP_USER" envAdminBootstrapUser = "BACKEND_ADMIN_BOOTSTRAP_USER"
envAdminBootstrapPassword = "BACKEND_ADMIN_BOOTSTRAP_PASSWORD" envAdminBootstrapPassword = "BACKEND_ADMIN_BOOTSTRAP_PASSWORD"
envAdminConsoleCSRFKey = "BACKEND_ADMIN_CONSOLE_CSRF_KEY"
envGeoIPDBPath = "BACKEND_GEOIP_DB_PATH" envGeoIPDBPath = "BACKEND_GEOIP_DB_PATH"
envOTelTracesExporter = "BACKEND_OTEL_TRACES_EXPORTER" envOTelTracesExporter = "BACKEND_OTEL_TRACES_EXPORTER"
@@ -103,11 +105,6 @@ const (
envDiplomailTranslatorTimeout = "BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT" envDiplomailTranslatorTimeout = "BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT"
envDiplomailTranslatorMaxAttempts = "BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS" envDiplomailTranslatorMaxAttempts = "BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS"
envDiplomailWorkerInterval = "BACKEND_DIPLOMAIL_WORKER_INTERVAL" envDiplomailWorkerInterval = "BACKEND_DIPLOMAIL_WORKER_INTERVAL"
envDevSandboxEmail = "BACKEND_DEV_SANDBOX_EMAIL"
envDevSandboxEngineImage = "BACKEND_DEV_SANDBOX_ENGINE_IMAGE"
envDevSandboxEngineVersion = "BACKEND_DEV_SANDBOX_ENGINE_VERSION"
envDevSandboxPlayerCount = "BACKEND_DEV_SANDBOX_PLAYER_COUNT"
) )
// Default values applied when an environment variable is absent. // Default values applied when an environment variable is absent.
@@ -176,9 +173,6 @@ const (
defaultDiplomailTranslatorTimeout = 10 * time.Second defaultDiplomailTranslatorTimeout = 10 * time.Second
defaultDiplomailTranslatorMaxAttempts = 5 defaultDiplomailTranslatorMaxAttempts = 5
defaultDiplomailWorkerInterval = 2 * time.Second defaultDiplomailWorkerInterval = 2 * time.Second
defaultDevSandboxEngineVersion = "0.1.0"
defaultDevSandboxPlayerCount = 20
) )
// Allowed values for the closed-set string options. // Allowed values for the closed-set string options.
@@ -208,6 +202,7 @@ type Config struct {
Docker DockerConfig Docker DockerConfig
Game GameConfig Game GameConfig
Admin AdminBootstrapConfig Admin AdminBootstrapConfig
AdminConsole AdminConsoleConfig
GeoIP GeoIPConfig GeoIP GeoIPConfig
Telemetry TelemetryConfig Telemetry TelemetryConfig
Auth AuthConfig Auth AuthConfig
@@ -216,29 +211,12 @@ type Config struct {
Runtime RuntimeConfig Runtime RuntimeConfig
Notification NotificationConfig Notification NotificationConfig
Diplomail DiplomailConfig Diplomail DiplomailConfig
DevSandbox DevSandboxConfig
// FreshnessWindow mirrors the gateway freshness window and is used by the // FreshnessWindow mirrors the gateway freshness window and is used by the
// push server to bound the cursor TTL. // push server to bound the cursor TTL.
FreshnessWindow time.Duration 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. // LoggingConfig stores the parameters used by the structured logger.
type LoggingConfig struct { type LoggingConfig struct {
// Level is the zap level name (e.g. "debug", "info", "warn", "error"). // Level is the zap level name (e.g. "debug", "info", "warn", "error").
@@ -308,6 +286,15 @@ type AdminBootstrapConfig struct {
Password string 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. // GeoIPConfig configures the GeoLite2 country database used by geo lookups.
type GeoIPConfig struct { type GeoIPConfig struct {
DBPath string DBPath string
@@ -560,10 +547,6 @@ func DefaultConfig() Config {
TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts, TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts,
WorkerInterval: defaultDiplomailWorkerInterval, WorkerInterval: defaultDiplomailWorkerInterval,
}, },
DevSandbox: DevSandboxConfig{
EngineVersion: defaultDevSandboxEngineVersion,
PlayerCount: defaultDevSandboxPlayerCount,
},
Runtime: RuntimeConfig{ Runtime: RuntimeConfig{
WorkerPoolSize: defaultRuntimeWorkerPoolSize, WorkerPoolSize: defaultRuntimeWorkerPoolSize,
JobQueueSize: defaultRuntimeJobQueueSize, JobQueueSize: defaultRuntimeJobQueueSize,
@@ -644,6 +627,8 @@ func LoadFromEnv() (Config, error) {
cfg.Admin.User = loadString(envAdminBootstrapUser, cfg.Admin.User) cfg.Admin.User = loadString(envAdminBootstrapUser, cfg.Admin.User)
cfg.Admin.Password = loadString(envAdminBootstrapPassword, cfg.Admin.Password) 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.GeoIP.DBPath = loadString(envGeoIPDBPath, cfg.GeoIP.DBPath)
cfg.Telemetry.TracesExporter = strings.ToLower(loadString(envOTelTracesExporter, cfg.Telemetry.TracesExporter)) cfg.Telemetry.TracesExporter = strings.ToLower(loadString(envOTelTracesExporter, cfg.Telemetry.TracesExporter))
@@ -741,13 +726,6 @@ func LoadFromEnv() (Config, error) {
return Config{}, err 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 {
return Config{}, err
}
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
return Config{}, err return Config{}, err
} }
@@ -959,21 +937,6 @@ func (c Config) Validate() error {
} }
} }
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 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
}
+4 -5
View File
@@ -274,11 +274,10 @@ func (s *Service) ListFinishedGamesBefore(ctx context.Context, cutoff time.Time)
// `ON DELETE CASCADE` constraints declared in `00001_init.sql`. // `ON DELETE CASCADE` constraints declared in `00001_init.sql`.
// Idempotent: returns nil when no game matches. // Idempotent: returns nil when no game matches.
// //
// Phase 14 introduces this method for the dev-sandbox bootstrap so a // `DeleteGame` is destructive — a hard delete that bypasses the
// terminal "Dev Sandbox" tile from a previous local-dev session can // cascade-notification machinery — so production callers stay on the
// be scrubbed before a fresh game spawns. Production callers must // regular cancel / finish lifecycle. It is exercised by the lobby
// stay on the regular cancel / finish lifecycle — `DeleteGame` is // integration tests.
// destructive and bypasses the cascade-notification machinery.
func (s *Service) DeleteGame(ctx context.Context, gameID uuid.UUID) error { func (s *Service) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
if err := s.deps.Store.DeleteGame(ctx, gameID); err != nil { if err := s.deps.Store.DeleteGame(ctx, gameID); err != nil {
return err return err
+2 -2
View File
@@ -248,8 +248,8 @@ func TestEndToEndPrivateGameFlow(t *testing.T) {
} }
} }
// TestDeleteGameCascadesEverything pins the contract the dev-sandbox // TestDeleteGameCascadesEverything pins the DeleteGame contract:
// bootstrap relies on: removing a game wipes every referencing row // removing a game wipes every referencing row
// (memberships, applications, invites, runtime_records, // (memberships, applications, invites, runtime_records,
// player_mappings) in a single SQL statement. Before this is wired // player_mappings) in a single SQL statement. Before this is wired
// the developer's lobby pile up cancelled tiles between // 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 // writes as ApproveApplication: the per-game race-name reservation
// row plus the membership row, and refreshes the in-memory caches. // row plus the membership row, and refreshes the in-memory caches.
// //
// The method is intended for boot-time provisioning by // The method is intended for trusted boot-time provisioning and
// `backend/internal/devsandbox` and similar trusted callers. It is // integration tests; it is not exposed through any HTTP handler. The
// not exposed through any HTTP handler. The caller must guarantee // caller must guarantee
// game.Status == GameStatusEnrollmentOpen — the function returns // game.Status == GameStatusEnrollmentOpen — the function returns
// ErrConflict otherwise — and that the race-name policy and // ErrConflict otherwise — and that the race-name policy and
// canonical-key invariants are honoured (the implementation reuses // canonical-key invariants are honoured (the implementation reuses
@@ -30,9 +30,8 @@ type InsertMembershipDirectInput struct {
// or unsuitable name still fails). // or unsuitable name still fails).
// //
// Idempotency: if a membership for (GameID, UserID) already exists // Idempotency: if a membership for (GameID, UserID) already exists
// the function returns the existing row without modifying state. // the function returns the existing row without modifying state, so
// This makes the helper safe to call on every backend boot from // the helper is safe to call repeatedly.
// devsandbox.Bootstrap.
func (s *Service) InsertMembershipDirect(ctx context.Context, in InsertMembershipDirectInput) (Membership, error) { func (s *Service) InsertMembershipDirect(ctx context.Context, in InsertMembershipDirectInput) (Membership, error) {
displayName, err := ValidateDisplayName(in.RaceName) displayName, err := ValidateDisplayName(in.RaceName)
if err != nil { if err != nil {
+2 -3
View File
@@ -236,9 +236,8 @@ func (s *Store) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameRecord
// referencing table (memberships / applications / invites / // referencing table (memberships / applications / invites /
// runtime_records / player_mappings — all declared with ON DELETE // runtime_records / player_mappings — all declared with ON DELETE
// CASCADE in `00001_init.sql`). Idempotent: returns nil when no row // CASCADE in `00001_init.sql`). Idempotent: returns nil when no row
// matches. Used by the dev-sandbox bootstrap to scrub terminal // matches. A hard delete for trusted callers and integration tests;
// games on every backend boot so the developer's lobby never piles // production lifecycle uses cancel / finish.
// up cancelled tiles.
func (s *Store) DeleteGame(ctx context.Context, gameID uuid.UUID) error { func (s *Store) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
g := table.Games g := table.Games
stmt := g.DELETE().WHERE(g.GameID.EQ(postgres.UUID(gameID))) stmt := g.DELETE().WHERE(g.GameID.EQ(postgres.UUID(gameID)))
+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,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()
}
@@ -0,0 +1,242 @@
package server
import (
"context"
"net/http"
"strings"
"testing"
"time"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/diplomail"
"galaxy/backend/internal/mail"
"galaxy/backend/internal/notification"
"galaxy/backend/internal/server/middleware/basicauth"
"github.com/google/uuid"
"go.uber.org/zap"
)
type fakeMailAdmin struct {
deliveries mail.AdminListDeliveriesPage
dead mail.AdminListDeadLettersPage
delivery mail.Delivery
getErr error
attempts []mail.Attempt
resendCalls int
}
func (f *fakeMailAdmin) AdminListDeliveries(context.Context, int, int) (mail.AdminListDeliveriesPage, error) {
return f.deliveries, nil
}
func (f *fakeMailAdmin) AdminGetDelivery(context.Context, uuid.UUID) (mail.Delivery, error) {
return f.delivery, f.getErr
}
func (f *fakeMailAdmin) AdminListAttempts(context.Context, uuid.UUID) ([]mail.Attempt, error) {
return f.attempts, nil
}
func (f *fakeMailAdmin) AdminResendDelivery(context.Context, uuid.UUID) (mail.Delivery, error) {
f.resendCalls++
return f.delivery, nil
}
func (f *fakeMailAdmin) AdminListDeadLetters(context.Context, int, int) (mail.AdminListDeadLettersPage, error) {
return f.dead, nil
}
type fakeNotificationAdmin struct {
notifications notification.AdminListNotificationsPage
dead notification.AdminListDeadLettersPage
malformed notification.AdminListMalformedPage
}
func (f *fakeNotificationAdmin) AdminListNotifications(context.Context, int, int) (notification.AdminListNotificationsPage, error) {
return f.notifications, nil
}
func (f *fakeNotificationAdmin) AdminListDeadLetters(context.Context, int, int) (notification.AdminListDeadLettersPage, error) {
return f.dead, nil
}
func (f *fakeNotificationAdmin) AdminListMalformed(context.Context, int, int) (notification.AdminListMalformedPage, error) {
return f.malformed, nil
}
type fakeDiplomailAdmin struct {
total int
err error
broadcastCalls int
last diplomail.SendMultiGameBroadcastInput
}
func (f *fakeDiplomailAdmin) SendAdminMultiGameBroadcast(_ context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error) {
f.broadcastCalls++
f.last = in
if f.err != nil {
return nil, 0, f.err
}
return nil, f.total, nil
}
func mailConsoleRouter(t *testing.T, m MailAdmin, n NotificationAdmin, d DiplomailAdmin) (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, Mail: m, Notifications: n, Diplomail: d}),
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
return handler, csrf
}
func TestConsoleMailPage(t *testing.T) {
id := uuid.New()
m := &fakeMailAdmin{
deliveries: mail.AdminListDeliveriesPage{
Items: []mail.Delivery{{DeliveryID: id, TemplateID: "auth.login_code", Status: "pending", CreatedAt: time.Now()}},
Page: 1, PageSize: 50, Total: 1,
},
dead: mail.AdminListDeadLettersPage{
Items: []mail.DeadLetter{{DeliveryID: uuid.New(), Reason: "smtp 550", ArchivedAt: time.Now()}},
},
}
router, _ := mailConsoleRouter(t, m, nil, nil)
rec := consoleGet(t, router, "/_gm/mail")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
for _, want := range []string{"auth.login_code", "pending", "Dead-letters", "smtp 550"} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("mail page missing %q", want)
}
}
}
func TestConsoleMailDeliveryDetail(t *testing.T) {
id := uuid.New()
m := &fakeMailAdmin{
delivery: mail.Delivery{DeliveryID: id, TemplateID: "auth.login_code", Status: "pending", Attempts: 2},
attempts: []mail.Attempt{{AttemptNo: 1, Outcome: "transient_failure", StartedAt: time.Now(), Error: "timeout"}},
}
router, _ := mailConsoleRouter(t, m, nil, nil)
rec := consoleGet(t, router, "/_gm/mail/deliveries/"+id.String())
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
for _, want := range []string{id.String(), "auth.login_code", "Attempts", "transient_failure", "Resend"} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("delivery detail missing %q", want)
}
}
}
func TestConsoleMailDeliveryDetailNotFound(t *testing.T) {
m := &fakeMailAdmin{getErr: mail.ErrDeliveryNotFound}
router, _ := mailConsoleRouter(t, m, nil, nil)
rec := consoleGet(t, router, "/_gm/mail/deliveries/"+uuid.New().String())
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestConsoleMailResend(t *testing.T) {
id := uuid.New()
m := &fakeMailAdmin{delivery: mail.Delivery{DeliveryID: id}}
router, csrf := mailConsoleRouter(t, m, nil, nil)
rec := consolePost(t, router, "/_gm/mail/deliveries/"+id.String()+"/resend", "_csrf="+csrf.Token("ops"))
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
if m.resendCalls != 1 {
t.Errorf("AdminResendDelivery called %d times, want 1", m.resendCalls)
}
}
func TestConsoleMailResendRejectsBadCSRF(t *testing.T) {
id := uuid.New()
m := &fakeMailAdmin{delivery: mail.Delivery{DeliveryID: id}}
router, _ := mailConsoleRouter(t, m, nil, nil)
rec := consolePost(t, router, "/_gm/mail/deliveries/"+id.String()+"/resend", "")
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if m.resendCalls != 0 {
t.Error("resend must not run without a CSRF token")
}
}
func TestConsoleNotificationsPage(t *testing.T) {
n := &fakeNotificationAdmin{
notifications: notification.AdminListNotificationsPage{Items: []notification.Notification{{NotificationID: uuid.New(), Kind: "lobby.invite.received"}}},
dead: notification.AdminListDeadLettersPage{Items: []notification.DeadLetter{{NotificationID: uuid.New(), RouteID: uuid.New(), Reason: "push gone"}}},
malformed: notification.AdminListMalformedPage{Items: []notification.MalformedIntent{{ID: uuid.New(), Reason: "bad shape"}}},
}
router, _ := mailConsoleRouter(t, nil, n, nil)
rec := consoleGet(t, router, "/_gm/notifications")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
for _, want := range []string{"lobby.invite.received", "push gone", "bad shape", "Malformed intents"} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("notifications page missing %q", want)
}
}
}
func TestConsoleBroadcastForm(t *testing.T) {
router, _ := mailConsoleRouter(t, nil, nil, &fakeDiplomailAdmin{})
rec := consoleGet(t, router, "/_gm/broadcast")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Send broadcast") {
t.Error("broadcast form missing")
}
}
func TestConsoleBroadcastSend(t *testing.T) {
d := &fakeDiplomailAdmin{total: 5}
router, csrf := mailConsoleRouter(t, nil, nil, d)
form := "_csrf=" + csrf.Token("ops") + "&scope=all_running&recipients=active&body=hello"
rec := consolePost(t, router, "/_gm/broadcast", form)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "5 recipients") {
t.Errorf("broadcast result missing recipient count; body=%s", rec.Body.String())
}
if d.broadcastCalls != 1 || d.last.Scope != "all_running" || d.last.Body != "hello" || d.last.CallerUsername != "ops" {
t.Errorf("broadcast input = %+v (calls=%d)", d.last, d.broadcastCalls)
}
}
func TestConsoleBroadcastSendBadGameIDs(t *testing.T) {
d := &fakeDiplomailAdmin{}
router, csrf := mailConsoleRouter(t, nil, nil, d)
form := "_csrf=" + csrf.Token("ops") + "&scope=selected&game_ids=not-a-uuid&body=hello"
rec := consolePost(t, router, "/_gm/broadcast", form)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if d.broadcastCalls != 0 {
t.Error("broadcast must not run with invalid game ids")
}
}
func TestConsoleMailUnavailable(t *testing.T) {
router, _ := mailConsoleRouter(t, nil, nil, nil)
rec := consoleGet(t, router, "/_gm/mail")
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
}
@@ -0,0 +1,149 @@
package server
import (
"context"
"errors"
"net/http"
"strings"
"galaxy/backend/internal/admin"
"galaxy/backend/internal/adminconsole"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// OperatorAdmin is the subset of the admin-account service the console uses.
// *admin.Service satisfies it.
type OperatorAdmin interface {
List(ctx context.Context) ([]admin.Admin, error)
Create(ctx context.Context, in admin.CreateInput) (admin.Admin, error)
Disable(ctx context.Context, username string) (admin.Admin, error)
Enable(ctx context.Context, username string) (admin.Admin, error)
ResetPassword(ctx context.Context, username, password string) (admin.Admin, error)
}
// OperatorsList renders GET /_gm/operators.
func (h *AdminConsoleHandlers) OperatorsList() gin.HandlerFunc {
return func(c *gin.Context) {
if h.operators == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
return
}
admins, err := h.operators.List(c.Request.Context())
if err != nil {
h.logger.Error("admin console: list operators", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "operators", "Operators", "Failed to load operators.", "bad", "/_gm/")
return
}
h.render(c, http.StatusOK, "operators", "operators", "Operators", toOperatorsData(admins))
}
}
// OperatorCreate handles POST /_gm/operators.
func (h *AdminConsoleHandlers) OperatorCreate() gin.HandlerFunc {
return func(c *gin.Context) {
if h.operators == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
return
}
_, err := h.operators.Create(c.Request.Context(), admin.CreateInput{
Username: strings.TrimSpace(c.PostForm("username")),
Password: c.PostForm("password"),
})
if err != nil {
switch {
case errors.Is(err, admin.ErrUsernameTaken):
h.renderMessage(c, http.StatusConflict, "operators", "Username taken", "That username is already in use.", "bad", "/_gm/operators")
case errors.Is(err, admin.ErrInvalidInput):
h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "Username and password are required.", "bad", "/_gm/operators")
default:
h.logger.Error("admin console: create operator", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "operators", "Create failed", "Failed to create the operator.", "bad", "/_gm/operators")
}
return
}
c.Redirect(http.StatusSeeOther, "/_gm/operators")
}
}
// OperatorDisable handles POST /_gm/operators/:username/disable.
func (h *AdminConsoleHandlers) OperatorDisable() gin.HandlerFunc {
return h.operatorAction("disable", func(ctx context.Context, username string) error {
_, err := h.operators.Disable(ctx, username)
return err
})
}
// OperatorEnable handles POST /_gm/operators/:username/enable.
func (h *AdminConsoleHandlers) OperatorEnable() gin.HandlerFunc {
return h.operatorAction("enable", func(ctx context.Context, username string) error {
_, err := h.operators.Enable(ctx, username)
return err
})
}
// OperatorResetPassword handles POST /_gm/operators/:username/reset-password.
func (h *AdminConsoleHandlers) OperatorResetPassword() gin.HandlerFunc {
return func(c *gin.Context) {
if h.operators == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
return
}
username := c.Param("username")
password := c.PostForm("password")
if strings.TrimSpace(password) == "" {
h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "A new password is required.", "bad", "/_gm/operators")
return
}
if _, err := h.operators.ResetPassword(c.Request.Context(), username, password); err != nil {
if errors.Is(err, admin.ErrNotFound) {
h.renderMessage(c, http.StatusNotFound, "operators", "Operator not found", "No such operator.", "bad", "/_gm/operators")
return
}
if errors.Is(err, admin.ErrInvalidInput) {
h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "The password was rejected.", "bad", "/_gm/operators")
return
}
h.logger.Error("admin console: reset operator password", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "operators", "Reset failed", "Failed to reset the password.", "bad", "/_gm/operators")
return
}
c.Redirect(http.StatusSeeOther, "/_gm/operators")
}
}
// operatorAction is the shared shape for operator POST actions that take only
// the username and redirect back to the list.
func (h *AdminConsoleHandlers) operatorAction(label string, run func(context.Context, string) error) gin.HandlerFunc {
return func(c *gin.Context) {
if h.operators == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
return
}
if err := run(c.Request.Context(), c.Param("username")); err != nil {
if errors.Is(err, admin.ErrNotFound) {
h.renderMessage(c, http.StatusNotFound, "operators", "Operator not found", "No such operator.", "bad", "/_gm/operators")
return
}
h.logger.Error("admin console: operator "+label, zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "operators", "Action failed", "The "+label+" action failed.", "bad", "/_gm/operators")
return
}
c.Redirect(http.StatusSeeOther, "/_gm/operators")
}
}
// toOperatorsData maps admin accounts into the operators view model.
func toOperatorsData(admins []admin.Admin) adminconsole.OperatorsData {
data := adminconsole.OperatorsData{Items: make([]adminconsole.OperatorRow, 0, len(admins))}
for _, a := range admins {
data.Items = append(data.Items, adminconsole.OperatorRow{
Username: a.Username,
CreatedAt: fmtConsoleTime(a.CreatedAt),
LastUsedAt: fmtConsoleTimePtr(a.LastUsedAt),
Disabled: a.DisabledAt != nil,
})
}
return data
}
@@ -0,0 +1,166 @@
package server
import (
"context"
"net/http"
"strings"
"testing"
"galaxy/backend/internal/admin"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/server/middleware/basicauth"
"go.uber.org/zap"
)
type fakeOperatorAdmin struct {
list []admin.Admin
createErr error
created admin.CreateInput
createCalls int
disableCalls int
enableCalls int
resetCalls int
lastResetUser string
lastResetPass string
}
func (f *fakeOperatorAdmin) List(context.Context) ([]admin.Admin, error) { return f.list, nil }
func (f *fakeOperatorAdmin) Create(_ context.Context, in admin.CreateInput) (admin.Admin, error) {
f.createCalls++
f.created = in
if f.createErr != nil {
return admin.Admin{}, f.createErr
}
return admin.Admin{Username: in.Username}, nil
}
func (f *fakeOperatorAdmin) Disable(_ context.Context, username string) (admin.Admin, error) {
f.disableCalls++
return admin.Admin{Username: username}, nil
}
func (f *fakeOperatorAdmin) Enable(_ context.Context, username string) (admin.Admin, error) {
f.enableCalls++
return admin.Admin{Username: username}, nil
}
func (f *fakeOperatorAdmin) ResetPassword(_ context.Context, username, password string) (admin.Admin, error) {
f.resetCalls++
f.lastResetUser = username
f.lastResetPass = password
return admin.Admin{Username: username}, nil
}
func operatorsRouter(t *testing.T, operators OperatorAdmin) (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, Operators: operators}),
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
return handler, csrf
}
func TestConsoleOperatorsList(t *testing.T) {
fake := &fakeOperatorAdmin{list: []admin.Admin{{Username: "root"}}}
router, _ := operatorsRouter(t, fake)
rec := consoleGet(t, router, "/_gm/operators")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
for _, want := range []string{"root", "Create operator", "Reset"} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("operators page missing %q", want)
}
}
}
func TestConsoleOperatorCreate(t *testing.T) {
fake := &fakeOperatorAdmin{}
router, csrf := operatorsRouter(t, fake)
rec := consolePost(t, router, "/_gm/operators", "_csrf="+csrf.Token("ops")+"&username=mod&password=s3cret")
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
}
if fake.createCalls != 1 || fake.created.Username != "mod" || fake.created.Password != "s3cret" {
t.Errorf("create recorded %d username=%q", fake.createCalls, fake.created.Username)
}
}
func TestConsoleOperatorCreateConflict(t *testing.T) {
fake := &fakeOperatorAdmin{createErr: admin.ErrUsernameTaken}
router, csrf := operatorsRouter(t, fake)
rec := consolePost(t, router, "/_gm/operators", "_csrf="+csrf.Token("ops")+"&username=root&password=x")
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409", rec.Code)
}
}
func TestConsoleOperatorDisableEnable(t *testing.T) {
fake := &fakeOperatorAdmin{}
router, csrf := operatorsRouter(t, fake)
if rec := consolePost(t, router, "/_gm/operators/root/disable", "_csrf="+csrf.Token("ops")); rec.Code != http.StatusSeeOther {
t.Fatalf("disable status = %d, want 303", rec.Code)
}
if rec := consolePost(t, router, "/_gm/operators/root/enable", "_csrf="+csrf.Token("ops")); rec.Code != http.StatusSeeOther {
t.Fatalf("enable status = %d, want 303", rec.Code)
}
if fake.disableCalls != 1 || fake.enableCalls != 1 {
t.Errorf("disable=%d enable=%d, want 1/1", fake.disableCalls, fake.enableCalls)
}
}
func TestConsoleOperatorResetPassword(t *testing.T) {
fake := &fakeOperatorAdmin{}
router, csrf := operatorsRouter(t, fake)
rec := consolePost(t, router, "/_gm/operators/root/reset-password", "_csrf="+csrf.Token("ops")+"&password=newpass")
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
if fake.resetCalls != 1 || fake.lastResetUser != "root" || fake.lastResetPass != "newpass" {
t.Errorf("reset recorded %d user=%q", fake.resetCalls, fake.lastResetUser)
}
}
func TestConsoleOperatorResetPasswordMissing(t *testing.T) {
fake := &fakeOperatorAdmin{}
router, csrf := operatorsRouter(t, fake)
rec := consolePost(t, router, "/_gm/operators/root/reset-password", "_csrf="+csrf.Token("ops"))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if fake.resetCalls != 0 {
t.Error("reset must not run without a password")
}
}
func TestConsoleOperatorRejectsBadCSRF(t *testing.T) {
fake := &fakeOperatorAdmin{}
router, _ := operatorsRouter(t, fake)
rec := consolePost(t, router, "/_gm/operators/root/disable", "")
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if fake.disableCalls != 0 {
t.Error("disable must not run without a CSRF token")
}
}
func TestConsoleOperatorsUnavailable(t *testing.T) {
router, _ := operatorsRouter(t, nil)
rec := consoleGet(t, router, "/_gm/operators")
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
}
@@ -0,0 +1,214 @@
package server
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/opsstatus"
"galaxy/backend/internal/server/middleware/basicauth"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// fakeMonitor is a static opsstatus.Reader for dashboard rendering tests.
type fakeMonitor struct {
snapshot opsstatus.Snapshot
}
func (f fakeMonitor) Collect(context.Context) opsstatus.Snapshot {
return f.snapshot
}
func newConsoleTestRouter(t *testing.T) http.Handler {
t.Helper()
handler, err := NewRouter(RouterDependencies{
Logger: zap.NewNop(),
AdminVerifier: basicauth.NewStaticVerifier("secret"),
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: adminconsole.NewCSRF([]byte("test-key"))}),
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
return handler
}
func TestAdminConsoleRequiresAuth(t *testing.T) {
router := newConsoleTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/_gm/", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
if got := rec.Header().Get("WWW-Authenticate"); !strings.Contains(got, "Basic") {
t.Fatalf("WWW-Authenticate = %q, want a Basic challenge", got)
}
}
func TestAdminConsoleDashboardRenders(t *testing.T) {
router := newConsoleTestRouter(t)
for _, path := range []string{"/_gm", "/_gm/"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.SetBasicAuth("ops", "secret")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s status = %d, want 200; body=%s", path, rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("GET %s content-type = %q, want text/html", path, ct)
}
body := rec.Body.String()
if !strings.Contains(body, "Dashboard") {
t.Errorf("GET %s body missing the dashboard heading", path)
}
if !strings.Contains(body, "ops") {
t.Errorf("GET %s body missing the operator name", path)
}
}
}
func TestAdminConsoleDashboardShowsMonitoring(t *testing.T) {
monitor := fakeMonitor{snapshot: opsstatus.Snapshot{
PostgresHealthy: true,
Runtimes: []opsstatus.StatusCount{{Status: "running", Count: 3}, {Status: "stopped", Count: 1}},
MailDeliveries: []opsstatus.StatusCount{{Status: "pending", Count: 2}},
NotificationRoutes: []opsstatus.StatusCount{{Status: "published", Count: 9}},
NotificationMalformed: 4,
Errors: []string{"notification route counts: boom"},
}}
handler, err := NewRouter(RouterDependencies{
Logger: zap.NewNop(),
AdminVerifier: basicauth.NewStaticVerifier("secret"),
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{
CSRF: adminconsole.NewCSRF([]byte("test-key")),
Monitor: monitor,
Ready: func() bool { return true },
}),
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/_gm/", nil)
req.SetBasicAuth("ops", "secret")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{
"Game runtimes", "running", "stopped",
"Mail deliveries", "pending",
"Notification routes", "published",
"Malformed notifications",
"notification route counts: boom",
"healthy",
} {
if !strings.Contains(body, want) {
t.Errorf("dashboard body missing %q", want)
}
}
}
func TestAdminConsoleDashboardWithoutMonitor(t *testing.T) {
router := newConsoleTestRouter(t) // no monitor wired
req := httptest.NewRequest(http.MethodGet, "/_gm/", nil)
req.SetBasicAuth("ops", "secret")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Monitoring is not wired") {
t.Error("dashboard without a monitor should note that monitoring is unavailable")
}
}
func TestAdminConsoleServesAsset(t *testing.T) {
router := newConsoleTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/_gm/assets/console.css", nil)
req.SetBasicAuth("ops", "secret")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("asset status = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/css") {
t.Errorf("asset content-type = %q, want text/css", ct)
}
}
func TestAdminConsoleRequireCSRF(t *testing.T) {
gin.SetMode(gin.TestMode)
csrf := adminconsole.NewCSRF([]byte("test-key"))
console := NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf})
engine := gin.New()
engine.Use(func(c *gin.Context) {
c.Request = c.Request.WithContext(basicauth.WithUsername(c.Request.Context(), "ops"))
c.Next()
})
engine.Use(console.RequireCSRF())
engine.GET("/x", func(c *gin.Context) { c.Status(http.StatusOK) })
engine.POST("/x", func(c *gin.Context) { c.Status(http.StatusOK) })
token := csrf.Token("ops")
cases := []struct {
name string
method string
form string
origin string
host string
want int
}{
{"get is a safe method", http.MethodGet, "", "", "galaxy.lan", http.StatusOK},
{"valid token, same origin", http.MethodPost, "_csrf=" + token, "https://galaxy.lan", "galaxy.lan", http.StatusOK},
{"valid token, no origin header", http.MethodPost, "_csrf=" + token, "", "galaxy.lan", http.StatusOK},
{"missing token", http.MethodPost, "", "https://galaxy.lan", "galaxy.lan", http.StatusForbidden},
{"wrong token", http.MethodPost, "_csrf=bogus", "https://galaxy.lan", "galaxy.lan", http.StatusForbidden},
{"cross-origin", http.MethodPost, "_csrf=" + token, "https://evil.example", "galaxy.lan", http.StatusForbidden},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var body io.Reader
if tc.form != "" {
body = strings.NewReader(tc.form)
}
req := httptest.NewRequest(tc.method, "/x", body)
if tc.form != "" {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if tc.origin != "" {
req.Header.Set("Origin", tc.origin)
}
req.Host = tc.host
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != tc.want {
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, tc.want, rec.Body.String())
}
})
}
}
@@ -0,0 +1,252 @@
package server
import (
"context"
"errors"
"net/http"
"strings"
"time"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/server/middleware/basicauth"
"galaxy/backend/internal/user"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// UserAdmin is the subset of the user service the operator console depends on.
// *user.Service satisfies it; tests supply a fake so the console pages render
// without a database.
type UserAdmin interface {
ListAccounts(ctx context.Context, page, pageSize int) (user.AccountPage, error)
GetAccount(ctx context.Context, userID uuid.UUID) (user.Account, error)
ApplySanction(ctx context.Context, input user.ApplySanctionInput) (user.Account, error)
ApplyEntitlement(ctx context.Context, input user.ApplyEntitlementInput) (user.Account, error)
SoftDelete(ctx context.Context, userID uuid.UUID, actor user.ActorRef) error
}
// consoleTiers lists the selectable entitlement tiers in display order.
var consoleTiers = []string{user.TierFree, user.TierMonthly, user.TierYearly, user.TierPermanent}
// UsersList renders GET /_gm/users — the paginated account list.
func (h *AdminConsoleHandlers) UsersList() gin.HandlerFunc {
return func(c *gin.Context) {
if h.users == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
return
}
page := parsePositiveQueryInt(c.Query("page"), 1)
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
result, err := h.users.ListAccounts(c.Request.Context(), page, pageSize)
if err != nil {
h.logger.Error("admin console: list users", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "users", "Users", "Failed to load users.", "bad", "/_gm/")
return
}
h.render(c, http.StatusOK, "users", "users", "Users", toUsersListData(result))
}
}
// UserDetail renders GET /_gm/users/:user_id.
func (h *AdminConsoleHandlers) UserDetail() gin.HandlerFunc {
return func(c *gin.Context) {
if h.users == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
return
}
userID, ok := parseUserIDParam(c)
if !ok {
return
}
account, err := h.users.GetAccount(c.Request.Context(), userID)
if err != nil {
if errors.Is(err, user.ErrAccountNotFound) {
h.renderMessage(c, http.StatusNotFound, "users", "User not found", "No such user, or the account has been soft-deleted.", "bad", "/_gm/users")
return
}
h.logger.Error("admin console: get user", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "users", "Users", "Failed to load the user.", "bad", "/_gm/users")
return
}
h.render(c, http.StatusOK, "user_detail", "users", account.Email, toUserDetailData(account))
}
}
// UserBlock handles POST /_gm/users/:user_id/block — applies a permanent block.
func (h *AdminConsoleHandlers) UserBlock() gin.HandlerFunc {
return func(c *gin.Context) {
if h.users == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
return
}
userID, ok := parseUserIDParam(c)
if !ok {
return
}
back := "/_gm/users/" + userID.String()
reason := strings.TrimSpace(c.PostForm("reason_code"))
if reason == "" {
h.renderMessage(c, http.StatusBadRequest, "users", "Invalid input", "A reason is required to block a user.", "bad", back)
return
}
_, err := h.users.ApplySanction(c.Request.Context(), user.ApplySanctionInput{
UserID: userID,
SanctionCode: user.SanctionCodePermanentBlock,
Scope: "account",
ReasonCode: reason,
Actor: actorFromContext(c),
})
if err != nil {
h.logger.Error("admin console: block user", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "users", "Block failed", "Failed to block the user.", "bad", back)
return
}
c.Redirect(http.StatusSeeOther, back)
}
}
// UserEntitlement handles POST /_gm/users/:user_id/entitlement.
func (h *AdminConsoleHandlers) UserEntitlement() gin.HandlerFunc {
return func(c *gin.Context) {
if h.users == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
return
}
userID, ok := parseUserIDParam(c)
if !ok {
return
}
back := "/_gm/users/" + userID.String()
tier := strings.TrimSpace(c.PostForm("tier"))
source := strings.TrimSpace(c.PostForm("source"))
if source == "" {
source = "admin"
}
_, err := h.users.ApplyEntitlement(c.Request.Context(), user.ApplyEntitlementInput{
UserID: userID,
Tier: tier,
Source: source,
Actor: actorFromContext(c),
ReasonCode: strings.TrimSpace(c.PostForm("reason_code")),
})
if err != nil {
if errors.Is(err, user.ErrInvalidInput) {
h.renderMessage(c, http.StatusBadRequest, "users", "Invalid input", "The entitlement request was rejected: check the tier.", "bad", back)
return
}
h.logger.Error("admin console: apply entitlement", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "users", "Entitlement failed", "Failed to update the entitlement.", "bad", back)
return
}
c.Redirect(http.StatusSeeOther, back)
}
}
// UserSoftDelete handles POST /_gm/users/:user_id/soft-delete.
func (h *AdminConsoleHandlers) UserSoftDelete() gin.HandlerFunc {
return func(c *gin.Context) {
if h.users == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
return
}
userID, ok := parseUserIDParam(c)
if !ok {
return
}
if err := h.users.SoftDelete(c.Request.Context(), userID, actorFromContext(c)); err != nil {
if errors.Is(err, user.ErrAccountNotFound) {
h.renderMessage(c, http.StatusNotFound, "users", "User not found", "No such user.", "bad", "/_gm/users")
return
}
// A cascade error does not undo the soft delete; log and proceed.
h.logger.Warn("admin console: soft-delete cascade returned error", zap.Error(err))
}
c.Redirect(http.StatusSeeOther, "/_gm/users")
}
}
// actorFromContext builds the admin ActorRef for audit trails from the
// authenticated operator username stored by the Basic Auth middleware.
func actorFromContext(c *gin.Context) user.ActorRef {
username, _ := basicauth.UsernameFromContext(c.Request.Context())
return user.ActorRef{Type: "admin", ID: username}
}
// toUsersListData maps an account page into the users list view model.
func toUsersListData(page user.AccountPage) adminconsole.UsersListData {
data := adminconsole.UsersListData{
Items: make([]adminconsole.UserRow, 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 _, account := range page.Items {
data.Items = append(data.Items, adminconsole.UserRow{
UserID: account.UserID.String(),
Email: account.Email,
UserName: account.UserName,
DisplayName: account.DisplayName,
Tier: account.Entitlement.Tier,
Blocked: account.PermanentBlock,
Deleted: account.DeletedAt != nil,
CreatedAt: fmtConsoleTime(account.CreatedAt),
})
}
return data
}
// toUserDetailData maps an account aggregate into the detail view model.
func toUserDetailData(account user.Account) adminconsole.UserDetailData {
data := adminconsole.UserDetailData{
UserID: account.UserID.String(),
Email: account.Email,
UserName: account.UserName,
DisplayName: account.DisplayName,
PreferredLanguage: account.PreferredLanguage,
TimeZone: account.TimeZone,
DeclaredCountry: account.DeclaredCountry,
Blocked: account.PermanentBlock,
Deleted: account.DeletedAt != nil,
CreatedAt: fmtConsoleTime(account.CreatedAt),
UpdatedAt: fmtConsoleTime(account.UpdatedAt),
Tier: account.Entitlement.Tier,
IsPaid: account.Entitlement.IsPaid,
EntitlementSource: account.Entitlement.Source,
EntitlementReason: account.Entitlement.ReasonCode,
EntitlementEnds: fmtConsoleTimePtr(account.Entitlement.EndsAt),
Tiers: consoleTiers,
}
for _, sanction := range account.ActiveSanctions {
data.Sanctions = append(data.Sanctions, adminconsole.SanctionView{
SanctionCode: sanction.SanctionCode,
Scope: sanction.Scope,
ReasonCode: sanction.ReasonCode,
AppliedAt: fmtConsoleTime(sanction.AppliedAt),
ExpiresAt: fmtConsoleTimePtr(sanction.ExpiresAt),
})
}
return data
}
// fmtConsoleTime renders a timestamp for display in the console.
func fmtConsoleTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format("2006-01-02 15:04 UTC")
}
// fmtConsoleTimePtr renders an optional timestamp, returning "" when nil.
func fmtConsoleTimePtr(t *time.Time) string {
if t == nil {
return ""
}
return fmtConsoleTime(*t)
}
@@ -0,0 +1,288 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/server/middleware/basicauth"
"galaxy/backend/internal/user"
"github.com/google/uuid"
"go.uber.org/zap"
)
// fakeUserAdmin records calls so the console handlers can be exercised without
// a database.
type fakeUserAdmin struct {
page user.AccountPage
account user.Account
getErr error
sanctionCalls int
lastSanction user.ApplySanctionInput
entitlementCall int
lastEntitlement user.ApplyEntitlementInput
softDeleteCalls int
lastSoftActor user.ActorRef
}
func (f *fakeUserAdmin) ListAccounts(context.Context, int, int) (user.AccountPage, error) {
return f.page, nil
}
func (f *fakeUserAdmin) GetAccount(context.Context, uuid.UUID) (user.Account, error) {
return f.account, f.getErr
}
func (f *fakeUserAdmin) ApplySanction(_ context.Context, in user.ApplySanctionInput) (user.Account, error) {
f.sanctionCalls++
f.lastSanction = in
return f.account, nil
}
func (f *fakeUserAdmin) ApplyEntitlement(_ context.Context, in user.ApplyEntitlementInput) (user.Account, error) {
f.entitlementCall++
f.lastEntitlement = in
return f.account, nil
}
func (f *fakeUserAdmin) SoftDelete(_ context.Context, _ uuid.UUID, actor user.ActorRef) error {
f.softDeleteCalls++
f.lastSoftActor = actor
return nil
}
func newUsersConsoleRouter(t *testing.T, users UserAdmin) (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, Users: users}),
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
return handler, csrf
}
func TestConsoleUsersList(t *testing.T) {
fake := &fakeUserAdmin{page: user.AccountPage{
Items: []user.Account{
{UserID: uuid.New(), Email: "alice@example.test", UserName: "alice"},
{UserID: uuid.New(), Email: "bob@example.test", UserName: "bob", PermanentBlock: true},
},
Page: 1, PageSize: 50, Total: 2,
}}
router, _ := newUsersConsoleRouter(t, fake)
req := httptest.NewRequest(http.MethodGet, "/_gm/users", nil)
req.SetBasicAuth("ops", "secret")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{"alice@example.test", "bob@example.test", "blocked", "page 1"} {
if !strings.Contains(body, want) {
t.Errorf("users list missing %q", want)
}
}
}
func TestConsoleUserDetailRendersForms(t *testing.T) {
id := uuid.New()
fake := &fakeUserAdmin{account: user.Account{
UserID: id, Email: "alice@example.test", UserName: "alice",
Entitlement: user.EntitlementSnapshot{Tier: user.TierFree},
}}
router, csrf := newUsersConsoleRouter(t, fake)
req := httptest.NewRequest(http.MethodGet, "/_gm/users/"+id.String(), nil)
req.SetBasicAuth("ops", "secret")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{
"alice@example.test",
"Permanently block",
"Update entitlement",
"Soft-delete account",
csrf.Token("ops"),
} {
if !strings.Contains(body, want) {
t.Errorf("user detail missing %q", want)
}
}
}
func TestConsoleUserDetailNotFound(t *testing.T) {
fake := &fakeUserAdmin{getErr: user.ErrAccountNotFound}
router, _ := newUsersConsoleRouter(t, fake)
req := httptest.NewRequest(http.MethodGet, "/_gm/users/"+uuid.New().String(), nil)
req.SetBasicAuth("ops", "secret")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
if !strings.Contains(rec.Body.String(), "not found") {
t.Error("expected a not-found message")
}
}
func TestConsoleUserBlock(t *testing.T) {
id := uuid.New()
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
router, csrf := newUsersConsoleRouter(t, fake)
form := "_csrf=" + csrf.Token("ops") + "&reason_code=spam"
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", 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)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
}
if fake.sanctionCalls != 1 {
t.Fatalf("ApplySanction called %d times, want 1", fake.sanctionCalls)
}
if fake.lastSanction.SanctionCode != user.SanctionCodePermanentBlock {
t.Errorf("sanction code = %q, want permanent_block", fake.lastSanction.SanctionCode)
}
if fake.lastSanction.Scope != "account" {
t.Errorf("scope = %q, want account", fake.lastSanction.Scope)
}
if fake.lastSanction.ReasonCode != "spam" {
t.Errorf("reason = %q, want spam", fake.lastSanction.ReasonCode)
}
if fake.lastSanction.Actor.Type != "admin" || fake.lastSanction.Actor.ID != "ops" {
t.Errorf("actor = %+v, want admin/ops", fake.lastSanction.Actor)
}
if fake.lastSanction.UserID != id {
t.Errorf("sanction user id = %s, want %s", fake.lastSanction.UserID, id)
}
}
func TestConsoleUserBlockMissingReason(t *testing.T) {
id := uuid.New()
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
router, csrf := newUsersConsoleRouter(t, fake)
form := "_csrf=" + csrf.Token("ops")
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", 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)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if fake.sanctionCalls != 0 {
t.Errorf("ApplySanction must not be called without a reason")
}
}
func TestConsoleUserBlockRejectsBadCSRF(t *testing.T) {
id := uuid.New()
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
router, _ := newUsersConsoleRouter(t, fake)
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader("reason_code=spam"))
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)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if fake.sanctionCalls != 0 {
t.Errorf("ApplySanction must not run when the CSRF token is missing")
}
}
func TestConsoleUserEntitlement(t *testing.T) {
id := uuid.New()
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
router, csrf := newUsersConsoleRouter(t, fake)
form := "_csrf=" + csrf.Token("ops") + "&tier=monthly&source=admin&reason_code=promo"
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/entitlement", 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)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
}
if fake.entitlementCall != 1 {
t.Fatalf("ApplyEntitlement called %d times, want 1", fake.entitlementCall)
}
if fake.lastEntitlement.Tier != user.TierMonthly {
t.Errorf("tier = %q, want monthly", fake.lastEntitlement.Tier)
}
if fake.lastEntitlement.Actor.ID != "ops" {
t.Errorf("actor id = %q, want ops", fake.lastEntitlement.Actor.ID)
}
}
func TestConsoleUserSoftDelete(t *testing.T) {
id := uuid.New()
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
router, csrf := newUsersConsoleRouter(t, fake)
form := "_csrf=" + csrf.Token("ops")
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/soft-delete", 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)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
if got := rec.Header().Get("Location"); got != "/_gm/users" {
t.Errorf("redirect Location = %q, want /_gm/users", got)
}
if fake.softDeleteCalls != 1 {
t.Fatalf("SoftDelete called %d times, want 1", fake.softDeleteCalls)
}
if fake.lastSoftActor.ID != "ops" {
t.Errorf("soft-delete actor = %q, want ops", fake.lastSoftActor.ID)
}
}
func TestConsoleUsersUnavailable(t *testing.T) {
router, _ := newUsersConsoleRouter(t, nil) // no user service wired
req := httptest.NewRequest(http.MethodGet, "/_gm/users", nil)
req.SetBasicAuth("ops", "secret")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
}
+59
View File
@@ -81,6 +81,13 @@ type RouterDependencies struct {
AdminGeo *AdminGeoHandlers AdminGeo *AdminGeoHandlers
InternalSessions *InternalSessionsHandlers InternalSessions *InternalSessionsHandlers
InternalUsers *InternalUsersHandlers InternalUsers *InternalUsersHandlers
// AdminConsole, when non-nil, mounts the server-rendered operator
// console under the `/_gm` route group behind the same admin Basic
// Auth verifier as `/api/v1/admin`. A nil value leaves the console
// unmounted, which keeps routers built without console wiring (the
// contract test, most unit tests) unchanged.
AdminConsole *AdminConsoleHandlers
} }
// NewRouter constructs the backend gin engine wired with the documented // NewRouter constructs the backend gin engine wired with the documented
@@ -123,6 +130,7 @@ func NewRouter(deps RouterDependencies) (http.Handler, error) {
registerUserRoutes(router, instruments, deps) registerUserRoutes(router, instruments, deps)
registerAdminRoutes(router, instruments, deps) registerAdminRoutes(router, instruments, deps)
registerInternalRoutes(router, instruments, deps) registerInternalRoutes(router, instruments, deps)
registerAdminConsoleRoutes(router, deps)
router.NoMethod(func(c *gin.Context) { router.NoMethod(func(c *gin.Context) {
if allow := allowedMethodsForPath(c.Request.URL.Path); allow != "" { if allow := allowedMethodsForPath(c.Request.URL.Path); allow != "" {
@@ -364,6 +372,57 @@ func registerInternalRoutes(router *gin.Engine, instruments *metrics.Instruments
users.GET("/:user_id/account-internal", deps.InternalUsers.GetAccountInternal()) users.GET("/:user_id/account-internal", deps.InternalUsers.GetAccountInternal())
} }
// registerAdminConsoleRoutes mounts the server-rendered operator console under
// `/_gm` when deps.AdminConsole is wired. The group reuses the same admin Basic
// Auth verifier as `/api/v1/admin`; the CSRF guard then protects every
// state-changing request. A nil AdminConsole leaves the surface unmounted.
func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) {
if deps.AdminConsole == nil {
return
}
group := router.Group("/_gm")
group.Use(basicauth.Middleware(deps.AdminVerifier, adminBasicAuthRealm))
group.Use(deps.AdminConsole.RequireCSRF())
group.GET("/assets/*filepath", deps.AdminConsole.Asset())
group.GET("", deps.AdminConsole.Dashboard())
group.GET("/", deps.AdminConsole.Dashboard())
group.GET("/users", deps.AdminConsole.UsersList())
group.GET("/users/:user_id", deps.AdminConsole.UserDetail())
group.POST("/users/:user_id/block", deps.AdminConsole.UserBlock())
group.POST("/users/:user_id/entitlement", deps.AdminConsole.UserEntitlement())
group.POST("/users/:user_id/soft-delete", deps.AdminConsole.UserSoftDelete())
group.GET("/games", deps.AdminConsole.GamesList())
group.POST("/games", deps.AdminConsole.GameCreate())
group.GET("/games/:game_id", deps.AdminConsole.GameDetail())
group.POST("/games/:game_id/force-start", deps.AdminConsole.GameForceStart())
group.POST("/games/:game_id/force-stop", deps.AdminConsole.GameForceStop())
group.POST("/games/:game_id/ban-member", deps.AdminConsole.GameBanMember())
group.POST("/games/:game_id/runtime/restart", deps.AdminConsole.RuntimeRestart())
group.POST("/games/:game_id/runtime/patch", deps.AdminConsole.RuntimePatch())
group.POST("/games/:game_id/runtime/force-next-turn", deps.AdminConsole.RuntimeForceNextTurn())
group.GET("/engine-versions", deps.AdminConsole.EngineVersionsList())
group.POST("/engine-versions", deps.AdminConsole.EngineVersionRegister())
group.POST("/engine-versions/:version/disable", deps.AdminConsole.EngineVersionDisable())
group.GET("/operators", deps.AdminConsole.OperatorsList())
group.POST("/operators", deps.AdminConsole.OperatorCreate())
group.POST("/operators/:username/disable", deps.AdminConsole.OperatorDisable())
group.POST("/operators/:username/enable", deps.AdminConsole.OperatorEnable())
group.POST("/operators/:username/reset-password", deps.AdminConsole.OperatorResetPassword())
group.GET("/mail", deps.AdminConsole.MailPage())
group.GET("/mail/deliveries/:delivery_id", deps.AdminConsole.MailDeliveryDetail())
group.POST("/mail/deliveries/:delivery_id/resend", deps.AdminConsole.MailResend())
group.GET("/notifications", deps.AdminConsole.NotificationsPage())
group.GET("/broadcast", deps.AdminConsole.BroadcastForm())
group.POST("/broadcast", deps.AdminConsole.BroadcastSend())
}
// allowedMethodsForPath returns the comma-separated list of methods // allowedMethodsForPath returns the comma-separated list of methods
// the gin router accepts on requestPath. Only the probe paths declare // the gin router accepts on requestPath. Only the probe paths declare
// a non-empty list so NoMethod can advertise a useful `Allow` header // a non-empty list so NoMethod can advertise a useful `Allow` header
+45 -1
View File
@@ -581,6 +581,36 @@ directly.
`/api/v1/admin/notifications/*`) reuse the per-domain logic of the `/api/v1/admin/notifications/*`) reuse the per-domain logic of the
module they target. module they target.
### 14.1 Operator console (`/_gm`)
`backend` also serves a server-rendered operator console under the `/_gm`
route group — the human-facing surface for the admin operations otherwise
exposed as JSON under `/api/v1/admin/*`. It reuses the `admin_accounts`
Basic Auth verifier and renders pages with the standard library's
`html/template` (navigation by path and query, Post/Redirect/Get on
writes; no client framework or build step).
Unlike the internal-only JSON admin API, the console is reachable from the
public edge: Caddy routes `/_gm/*` to the gateway public listener, which
classifies it as the `admin` anti-abuse class (per-IP rate limit, body and
method limits) and reverse-proxies it to `backend`'s `/_gm` surface. The
gateway preserves the inbound `Host` and relays the backend's 401 Basic
Auth challenge unchanged, so the browser shows its native credential
dialog. Authentication is enforced by `backend`; the gateway contributes
only the edge anti-abuse layer.
State-changing requests are guarded against CSRF by a stateless token
(HMAC-SHA256 over the authenticated username, keyed by
`BACKEND_ADMIN_CONSOLE_CSRF_KEY`; a per-process random key is used when the
variable is unset) plus a same-origin `Origin`/`Referer` check.
The console landing page is a dashboard that surfaces backend-visible
operational signals — database reachability, per-status game-runtime counts,
and mail/notification queue depths — read directly through the persistence
layer; richer historical metrics come from the Prometheus exporters on
`backend` and `gateway` (see [§17](#17-observability)). See
`backend/docs/admin-console.md` for the console design.
## 15. Transport Security Model (gateway boundary) ## 15. Transport Security Model (gateway boundary)
This section describes the secure exchange model between client and This section describes the secure exchange model between client and
@@ -823,7 +853,8 @@ business validation and authorisation.
| Session revocation propagation | backend → gateway | `session_invalidation` over the gRPC push stream flips the gateway-side cache entry to revoked and closes any active push stream. | | Session revocation propagation | backend → gateway | `session_invalidation` over the gRPC push stream flips the gateway-side cache entry to revoked and closes any active push stream. |
| Authorisation, ownership, state transitions | backend | `X-User-ID` is the sole identity input on the user surface. | | Authorisation, ownership, state transitions | backend | `X-User-ID` is the sole identity input on the user surface. |
| Edge rate limiting | gateway | Backend has no rate-limit responsibility in MVP. | | Edge rate limiting | gateway | Backend has no rate-limit responsibility in MVP. |
| Admin authentication | backend | Basic Auth against `admin_accounts`. | | Admin authentication | backend | Basic Auth against `admin_accounts`; the `/_gm` operator console reuses the same verifier. |
| Admin console CSRF | backend | Stateless HMAC token (`BACKEND_ADMIN_CONSOLE_CSRF_KEY`) + same-origin `Origin`/`Referer` check on `/_gm` writes. |
| Engine API authentication | network | Engine listens only on the trusted network; backend is the only caller. | | Engine API authentication | network | Engine listens only on the trusted network; backend is the only caller. |
### Backend ↔ Gateway trust ### Backend ↔ Gateway trust
@@ -857,6 +888,19 @@ addition.
- Health probes are unauthenticated `GET /healthz` (process liveness) and - Health probes are unauthenticated `GET /healthz` (process liveness) and
`GET /readyz` (Postgres reachable, migrations applied, gRPC listener `GET /readyz` (Postgres reachable, migrations applied, gRPC listener
bound). Probes are excluded from anti-replay and rate limiting. bound). Probes are excluded from anti-replay and rate limiting.
- **Collection (dev, production mirror).** The long-lived dev environment
(`tools/dev-deploy/`) runs a full metrics + logs + traces stack on its
internal network with no host ports: Prometheus scrapes the backend
(`:9100`) and gateway (`:9191`) endpoints plus `node-exporter` and
cAdvisor; Tempo ingests OTLP traces from backend and gateway; Loki
stores container logs shipped by promtail (Docker service-discovery on
the `galaxy.stack=dev-deploy` label). Grafana (provisioned datasources
+ dashboards) and the Mailpit capture UI are reached only through the
operator console's single `/_gm` Basic Auth gate (§14.1) — at
`/_gm/grafana/` and `/_gm/mailpit/` — so one password covers the
console and both UIs. Retention is tuned small (Prometheus 15d, Loki
7d, Tempo 3d). The same compose fragment is meant to back production.
See `tools/dev-deploy/monitoring/README.md`.
## 18. CI and Environments ## 18. CI and Environments
+37 -5
View File
@@ -704,11 +704,18 @@ demand. Backend authorises the caller and forwards the request;
there is no caching or denormalisation in this path. there is no caching or denormalisation in this path.
The web client renders the report as one section per FBS array The web client renders the report as one section per FBS array
(galaxy summary, votes, player status, my / foreign sciences, my / (galaxy summary, races leaving soon, votes, player status, my /
foreign ship classes, battles, bombings, approaching groups, my / foreign sciences, my / foreign ship classes, battles, bombings,
foreign / uninhabited / unknown planets, ships in production, approaching groups, my / foreign / uninhabited / unknown planets,
cargo routes, my fleets, my / foreign / unidentified ship groups). ships in production, cargo routes, my fleets, my / foreign /
Empty sections render explicit empty-state copy. Section unidentified ship groups). Empty sections render explicit
empty-state copy; "races leaving soon" is the exception and hides
entirely when no race is near removal. When the local race is
itself within five turns of being auto-removed for inactivity, a
danger-styled personal warning banner above the section list
carries its own turns-remaining countdown; the public "races
leaving soon" section lists every other race within three turns
of removal. Section
navigation is exposed through a sticky icon-popup menu pinned to navigation is exposed through a sticky icon-popup menu pinned to
the top-right of the report column (an anchored popover on desktop the top-right of the report column (an anchored popover on desktop
and a fixed bottom-sheet on mobile); the trigger label tracks the and a fixed bottom-sheet on mobile); the trigger label tracks the
@@ -1155,6 +1162,31 @@ operator's password manager can match it across deployments.
After the first deployment, the bootstrap password should be After the first deployment, the bootstrap password should be
rotated through the admin surface. rotated through the admin surface.
### 10.2.1 Operator console (`/_gm`)
Administrators drive these operations either programmatically through
the JSON admin API or through a server-rendered web console at `/_gm`.
The console authenticates with the same Basic Auth credentials: opening
any `/_gm` page prompts the browser's native credential dialog, and the
operator stays signed in for the session. Navigation is by ordinary
links and query parameters; every change is submitted as a form and
answered with a redirect back to the affected page.
The console is the only admin surface reachable from outside the trusted
network. It is fronted by the gateway, so it inherits the same edge rate
limiting and request limits as the public API, and it carries an
anti-CSRF token on every change. The JSON admin API stays internal to
the deployment.
The console landing page is a dashboard that summarises operational
health: whether the backend is ready and the database reachable, how many
game runtimes sit in each state, and the depth of the mail and
notification queues. It is a read-only point-in-time view for quick
triage, not a metrics history. The console nav also links to Grafana
(metrics, logs and traces) and the Mailpit capture UI, which the
deployment serves under the same `/_gm` Basic Auth gate — one sign-in
covers the console and both UIs.
### 10.3 Admin account management ### 10.3 Admin account management
Existing admins can list other admins, create new ones, look up a Existing admins can list other admins, create new ones, look up a
+39 -6
View File
@@ -722,12 +722,20 @@ Backend авторизует вызывающего и форвардит зап
нет ни кэширования, ни денормализации. нет ни кэширования, ни денормализации.
Web-клиент рендерит отчёт как одну секцию на каждый FBS-массив Web-клиент рендерит отчёт как одну секцию на каждый FBS-массив
(общие сведения, голоса, статус игроков, мои / чужие науки, мои / (общие сведения, скоро покидающие игру расы, голоса, статус
чужие классы кораблей, сражения, бомбардировки, приближающиеся игроков, мои / чужие науки, мои / чужие классы кораблей, сражения,
группы, мои / чужие / необитаемые / неопознанные планеты, корабли в бомбардировки, приближающиеся группы, мои / чужие / необитаемые /
производстве, грузовые маршруты, мои флоты, мои / чужие / неопознанные планеты, корабли в производстве, грузовые маршруты,
неопознанные группы кораблей). Пустые секции получают явную копию мои флоты, мои / чужие / неопознанные группы кораблей). Пустые
empty-state. Навигация по секциям — sticky icon-popup в правом секции получают явную копию empty-state; исключение — секция
«скоро покидающие игру расы»: она полностью скрывается, когда ни
одна раса не близка к исключению. Если же близка к исключению за
неактивность сама локальная раса (осталось не более пяти ходов),
над списком секций показывается персональный
баннер-предупреждение (стиль danger) с числом оставшихся ходов;
публичная секция «скоро покидающие игру расы» перечисляет все
прочие расы, до исключения которых осталось не более трёх ходов.
Навигация по секциям — sticky icon-popup в правом
верхнем углу колонки отчёта (анкорный popover на десктопе и фикс. верхнем углу колонки отчёта (анкорный popover на десктопе и фикс.
bottom-sheet на мобильном); подпись на кнопке отслеживает раздел, bottom-sheet на мобильном); подпись на кнопке отслеживает раздел,
который сейчас в зоне видимости, выбор пункта меню — скролл к который сейчас в зоне видимости, выбор пункта меню — скролл к
@@ -1189,6 +1197,31 @@ deployments.
После первого деплоя bootstrap-пароль должен быть ротирован После первого деплоя bootstrap-пароль должен быть ротирован
через admin-surface. через admin-surface.
### 10.2.1 Операторская консоль (`/_gm`)
Администраторы выполняют эти операции либо программно через JSON
admin-API, либо через серверно-рендеримую веб-консоль на `/_gm`.
Консоль аутентифицируется теми же Basic Auth-учётными данными:
открытие любой страницы `/_gm` вызывает нативный диалог браузера для
ввода учётных данных, и оператор остаётся залогинен на время сессии.
Навигация — обычными ссылками и query-параметрами; каждое изменение
отправляется формой и завершается редиректом обратно на затронутую
страницу.
Консоль — единственная admin-поверхность, достижимая извне
доверенной сети. Она проксируется через gateway, поэтому наследует те
же edge-rate-limiting и лимиты запросов, что и публичный API, и несёт
анти-CSRF-токен на каждом изменении. JSON admin-API остаётся
внутренним для деплоя.
Стартовая страница консоли — дашборд, сводящий операционное
здоровье: готов ли backend и доступна ли БД, сколько игровых рантаймов
в каждом состоянии, какова глубина очередей почты и уведомлений. Это
read-only-срез на текущий момент для быстрой диагностики, не история
метрик. Навигация консоли также ведёт в Grafana (метрики, логи и
трейсы) и в UI захвата почты Mailpit, которые деплой отдаёт под тем же
шлюзом Basic Auth `/_gm` — один вход покрывает консоль и оба UI.
### 10.3 Управление admin-аккаунтами ### 10.3 Управление admin-аккаунтами
Существующие админы могут перечислять других админов, создавать Существующие админы могут перечислять других админов, создавать
+7 -3
View File
@@ -102,9 +102,13 @@ remove-and-banish flow.
non-empty and must match an existing race in the engine's roster. non-empty and must match an existing race in the engine's roster.
- Successful response: `204 No Content` with an empty body. - Successful response: `204 No Content` with an empty body.
- Error responses follow the same `400` / `500` envelope shape as the - Error responses follow the same `400` / `500` envelope shape as the
other admin endpoints. The engine-side mechanics of `banish` (what other admin endpoints. `banish` only flags the race extinct, so it can
exactly happens to the race's planets, fleets, and pending orders) are no longer submit or have orders applied; its assets are released at the
owned by the engine maintainers. start of the next turn generation (`TurnWipeExtinctRaces`), the same way
an idle/quit timeout is handled but without the wait — ship groups and
fleets are removed, its planets become uninhabited (the working industry
and the capital stockpile are cleared, raw material is retained), and
votes cast for it are reset.
### `GET /healthz` ### `GET /healthz`
+123 -64
View File
@@ -1,7 +1,6 @@
package controller package controller
import ( import (
"iter"
"maps" "maps"
"math/rand/v2" "math/rand/v2"
"slices" "slices"
@@ -19,8 +18,9 @@ type Battle struct {
InitialNumbers map[int]uint // Initial number of ships in the group InitialNumbers map[int]uint // Initial number of ships in the group
Protocol []BattleAction Protocol []BattleAction
shipAmmo map[int]uint // attacker maps an attacking group to every opponent group it is able to
attacker map[int]map[int]float64 // a group able to attack and destroy an opponent with some probability > 0 // destroy, with that pair's per-ship destruction probability (> 0).
attacker map[int]map[int]float64
} }
type BattleAction struct { type BattleAction struct {
@@ -32,15 +32,24 @@ type BattleAction struct {
func CollectPlanetGroups(c *Cache) map[uint]map[int]bool { func CollectPlanetGroups(c *Cache) map[uint]map[int]bool {
planetGroup := make(map[uint]map[int]bool) planetGroup := make(map[uint]map[int]bool)
for groupIndex := range c.ShipGroupsIndex() { for groupIndex := range c.ShipGroupsIndex() {
state := c.ShipGroup(groupIndex).State() sg := c.ShipGroup(groupIndex)
if state == game.StateInOrbit || state == game.StateUpgrade { var planetNumber uint
planetNumber := c.ShipGroup(groupIndex).Destination switch sg.State() {
case game.StateInOrbit, game.StateUpgrade:
planetNumber = sg.Destination
case game.StateLaunched:
// Ordered to depart but still physically at the origin planet, so
// it joins the pre-departure battle there; only survivors then
// enter hyperspace.
planetNumber = sg.StateInSpace.Origin
default:
continue
}
if _, ok := planetGroup[planetNumber]; !ok { if _, ok := planetGroup[planetNumber]; !ok {
planetGroup[planetNumber] = make(map[int]bool) planetGroup[planetNumber] = make(map[int]bool)
} }
planetGroup[planetNumber][groupIndex] = false planetGroup[planetNumber][groupIndex] = false
} }
}
for pl := range planetGroup { for pl := range planetGroup {
if len(planetGroup[pl]) < 2 { if len(planetGroup[pl]) < 2 {
delete(planetGroup, pl) delete(planetGroup, pl)
@@ -50,7 +59,18 @@ func CollectPlanetGroups(c *Cache) map[uint]map[int]bool {
} }
func FilterBattleGroups(c *Cache, groups map[int]bool) []int { func FilterBattleGroups(c *Cache, groups map[int]bool) []int {
return slices.DeleteFunc(slices.Collect(maps.Keys(groups)), func(groupIndex int) bool { return c.ShipGroup(groupIndex).State() != game.StateInOrbit }) return slices.DeleteFunc(slices.Collect(maps.Keys(groups)), func(groupIndex int) bool {
// Everything physically present at the planet fights: ships in orbit,
// ships being upgraded, and ships ordered to depart that have not yet
// entered hyperspace (Launched). Only ships already in hyperspace are
// out of reach.
switch c.ShipGroup(groupIndex).State() {
case game.StateInOrbit, game.StateUpgrade, game.StateLaunched:
return false
default:
return true
}
})
} }
func FilterBattleOpponents(c *Cache, attIdx, defIdx int, cacheProbability map[int]map[int]float64) bool { func FilterBattleOpponents(c *Cache, attIdx, defIdx int, cacheProbability map[int]map[int]float64) bool {
@@ -65,12 +85,20 @@ func FilterBattleOpponents(c *Cache, attIdx, defIdx int, cacheProbability map[in
return true return true
} }
defSg := c.ShipGroup(defIdx)
if defSg.Number == 0 {
return true
}
defSt := c.ShipGroupShipClass(defIdx)
// The shot targets a single enemy ship, so the defending mass is the
// per-ship full mass: a group's full mass spreads evenly across its
// ships, hence FullMass / Number, not the whole group's mass.
p := calc.DestructionProbability( p := calc.DestructionProbability(
c.ShipGroupShipClass(attIdx).Weapons.F(), c.ShipGroupShipClass(attIdx).Weapons.F(),
c.ShipGroup(attIdx).TechLevel(game.TechWeapons).F(), c.ShipGroup(attIdx).TechLevel(game.TechWeapons).F(),
c.ShipGroupShipClass(defIdx).Shields.F(), defSt.Shields.F(),
c.ShipGroup(defIdx).TechLevel(game.TechShields).F(), defSg.TechLevel(game.TechShields).F(),
c.ShipGroup(defIdx).FullMass(c.ShipGroupShipClass(defIdx)), defSg.FullMass(defSt)/float64(defSg.Number),
) )
// Exclude opponent's group which cannot be probably destroyed // Exclude opponent's group which cannot be probably destroyed
if p <= 0 { if p <= 0 {
@@ -108,7 +136,6 @@ func ProduceBattles(c *Cache) []*Battle {
ObserverGroups: observerGroups, ObserverGroups: observerGroups,
InitialNumbers: make(map[int]uint), InitialNumbers: make(map[int]uint),
attacker: make(map[int]map[int]float64), attacker: make(map[int]map[int]float64),
shipAmmo: make(map[int]uint),
} }
for sgi := range observerGroups { for sgi := range observerGroups {
b.InitialNumbers[sgi] = c.ShipGroup(sgi).Number b.InitialNumbers[sgi] = c.ShipGroup(sgi).Number
@@ -126,12 +153,11 @@ func ProduceBattles(c *Cache) []*Battle {
return FilterBattleOpponents(c, attIdx, defIdx, cacheProbability) return FilterBattleOpponents(c, attIdx, defIdx, cacheProbability)
}) })
if len(opponents) > 0 { if len(opponents) > 0 {
b.shipAmmo[attIdx] = c.ShipGroupShipClass(attIdx).Armament
b.ObserverGroups[attIdx] = true b.ObserverGroups[attIdx] = true
for _, defIdx := range opponents { if b.attacker[attIdx] == nil {
if _, ok := b.attacker[attIdx][defIdx]; !ok {
b.attacker[attIdx] = make(map[int]float64) b.attacker[attIdx] = make(map[int]float64)
} }
for _, defIdx := range opponents {
b.attacker[attIdx][defIdx] = cacheProbability[attIdx][defIdx] b.attacker[attIdx][defIdx] = cacheProbability[attIdx][defIdx]
b.ObserverGroups[defIdx] = true b.ObserverGroups[defIdx] = true
} }
@@ -145,78 +171,111 @@ func ProduceBattles(c *Cache) []*Battle {
} }
clear(b.attacker) clear(b.attacker)
clear(b.shipAmmo)
} }
return result return result
} }
// SingleBattle resolves one battle ship by ship. In every round each still
// living ship gets to fire, chosen in random order across all groups; a ship
// fires all of its guns (Armament), each at a randomly chosen enemy ship it is
// able to destroy. A ship destroyed before its turn comes does not fire. The
// battle ends once no ship can damage any remaining enemy.
func SingleBattle(c *Cache, b *Battle) { func SingleBattle(c *Cache, b *Battle) {
roundShooters := make(map[int]bool) for {
for len(b.attacker) > 0 { // Snapshot this round's shooters: one entry per living ship of every
// список участников раунда // group that still has destroyable opponents.
clear(roundShooters) shooters := make([]int, 0)
for sgi := range b.attacker { for attIdx := range b.attacker {
roundShooters[sgi] = true for range c.ShipGroup(attIdx).Number {
shooters = append(shooters, attIdx)
} }
for len(roundShooters) > 0 {
// attacke group id among round participants
attIdx := randomValue(maps.Keys(roundShooters))
delete(roundShooters, attIdx)
for range b.shipAmmo[attIdx] {
// defender group id among all attacker's opponents
defIdx := randomValue(maps.Keys(b.attacker[attIdx]))
destroyed := false
probability := b.attacker[attIdx][defIdx]
switch {
case probability >= 1:
destroyed = true
case probability > 0:
destroyed = rand.Float64() >= probability
default:
panic("SingleBattle: probability unexpected: value <= 0")
} }
if len(shooters) == 0 {
return
}
rand.Shuffle(len(shooters), func(i, j int) { shooters[i], shooters[j] = shooters[j], shooters[i] })
// fired counts, per group, how many of its ships have already shot
// this round; a token beyond the group's current (post-casualty) ship
// count belongs to a ship destroyed earlier in the round and is skipped.
fired := make(map[int]uint)
progressed := false
for _, attIdx := range shooters {
if fired[attIdx] >= c.ShipGroup(attIdx).Number {
continue
}
fired[attIdx]++
for range c.ShipGroupShipClass(attIdx).Armament {
defIdx, ok := c.pickTargetShip(b, attIdx)
if !ok {
break
}
progressed = true
destroyed := destructionRoll(b.attacker[attIdx][defIdx])
b.Protocol = append(b.Protocol, BattleAction{ b.Protocol = append(b.Protocol, BattleAction{
Attacker: attIdx, Attacker: attIdx,
Defender: defIdx, Defender: defIdx,
Destroyed: destroyed, Destroyed: destroyed,
}) })
if destroyed { if destroyed {
c.ShipGroupDestroyItem(defIdx) c.ShipGroupDestroyItem(defIdx)
}
if c.ShipGroup(defIdx).Number == 0 { if c.ShipGroup(defIdx).Number == 0 {
// Eliminated group cant attack anyone b.removeFromBattle(defIdx)
delete(b.attacker, defIdx) }
delete(roundShooters, defIdx) }
}
}
// No shooter found a target this round: every remaining opponent is
// out of reach, the battle is over.
if !progressed {
return
}
}
}
// pickTargetShip selects a random enemy ship for attacker group attIdx among
// the groups it is able to destroy, weighted by each group's current ship
// count so that every living enemy ship is equally likely to be hit.
func (c *Cache) pickTargetShip(b *Battle, attIdx int) (int, bool) {
opponents := b.attacker[attIdx]
var total uint
for defIdx := range opponents {
total += c.ShipGroup(defIdx).Number
}
if total == 0 {
return 0, false
}
r := rand.IntN(int(total))
for defIdx := range opponents {
r -= int(c.ShipGroup(defIdx).Number)
if r < 0 {
return defIdx, true
}
}
return 0, false
}
// removeFromBattle drops an eliminated group: it can no longer attack, and no
// one can target it. Attackers left without any opponent are removed as well.
func (b *Battle) removeFromBattle(groupIdx int) {
delete(b.attacker, groupIdx)
for attIdx := range b.attacker { for attIdx := range b.attacker {
// Other attackers can't attack eliminated group anymore delete(b.attacker[attIdx], groupIdx)
delete(b.attacker[attIdx], defIdx)
if len(b.attacker[attIdx]) == 0 { if len(b.attacker[attIdx]) == 0 {
// Remove attacker if he lost all opponents
delete(b.attacker, attIdx) delete(b.attacker, attIdx)
delete(roundShooters, attIdx)
}
}
}
// When attacker has no more targets to shoot - break its ammo cycle
if len(b.attacker[attIdx]) == 0 {
break
}
}
} }
} }
} }
func randomValue(v iter.Seq[int]) int { func destructionRoll(probability float64) bool {
ids := slices.Collect(v) switch {
return ids[rand.IntN(len(ids))] case probability >= 1:
return true
case probability > 0:
return rand.Float64() < probability
default:
panic("SingleBattle: probability unexpected: value <= 0")
}
} }
+109 -1
View File
@@ -108,7 +108,11 @@ func TestFilterBattleOpponents(t *testing.T) {
assert.False(t, controller.FilterBattleOpponents(c, 0, 2, cacheProbability)) assert.False(t, controller.FilterBattleOpponents(c, 0, 2, cacheProbability))
assert.Contains(t, cacheProbability, 0) assert.Contains(t, cacheProbability, 0)
assert.Contains(t, cacheProbability[0], 2) assert.Contains(t, cacheProbability[0], 2)
assert.InDelta(t, 0.396222, cacheProbability[0][2], 0.0000001) // Group 2 holds 15 ships, but a shot targets a single ship, so the
// defending mass is the per-ship full mass (group mass / 15), which
// yields a far lower destruction probability than the pre-fix group-mass
// calculation (which read ~0.396).
assert.InDelta(t, 0.07064783, cacheProbability[0][2], 0.0000001)
assert.False(t, controller.FilterBattleOpponents(c, 2, 0, cacheProbability)) assert.False(t, controller.FilterBattleOpponents(c, 2, 0, cacheProbability))
assert.Contains(t, cacheProbability, 2) assert.Contains(t, cacheProbability, 2)
assert.Contains(t, cacheProbability[2], 0) assert.Contains(t, cacheProbability[2], 0)
@@ -271,3 +275,107 @@ func TestTransformBattleAggregatesSameShipClass(t *testing.T) {
gunship1.Number, gunship1.NumberLeft) gunship1.Number, gunship1.NumberLeft)
} }
} }
// TestDestructionRollDirection guards the corrected probability application:
// a shot destroys its target with probability p, not 1-p. The pre-fix code
// compared rand >= p, which inverted the rate (a near-certain hit became a
// near-certain miss).
func TestDestructionRollDirection(t *testing.T) {
const trials = 100000
for _, p := range []float64{0.1, 0.5, 0.9} {
hits := 0
for range trials {
if controller.DestructionRoll(p) {
hits++
}
}
rate := float64(hits) / float64(trials)
assert.InDelta(t, p, rate, 0.02, "destruction rate must track p=%.2f, not 1-p", p)
}
assert.True(t, controller.DestructionRoll(1.0))
assert.True(t, controller.DestructionRoll(1.5))
assert.Panics(t, func() { controller.DestructionRoll(0) })
assert.Panics(t, func() { controller.DestructionRoll(-0.1) })
}
// TestSingleBattleOneSidedWipe checks the per-ship model end to end: a group
// of armed ships that always destroys its target wipes a larger group of
// unarmed transports that cannot fire back, while every shot in the protocol
// is accounted for and the attacker takes no losses.
func TestSingleBattleOneSidedWipe(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.RaceRelation(Race_0.Name, Race_1.Name, game.RelationWar.String()))
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
// Killer's effective attack overwhelms the freighter's shields, so every
// shot destroys (probability >= 1).
assert.NoError(t, c.ShipClassCreate(Race_0_idx, "Killer", 10, 1, 40, 10, 0))
assert.NoError(t, c.CreateShips(Race_0_idx, "Killer", R0_Planet_0_num, 3)) // group 0
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Freighter).ID, R0_Planet_0_num, 5) // group 1
battles := controller.ProduceBattles(c)
assert.Len(t, battles, 1)
assert.Zero(t, c.ShipGroup(1).Number, "all unarmed transports must be destroyed")
assert.Equal(t, uint(3), c.ShipGroup(0).Number, "unarmed transports cannot retaliate")
kills := 0
for _, a := range battles[0].Protocol {
if a.Destroyed {
kills++
}
}
assert.Equal(t, 5, kills, "exactly the five transports are destroyed")
}
// TestCollectPlanetGroupsIncludesLaunchedAndUpgrade checks that every group
// physically at a planet — in orbit, being upgraded, or ordered to depart but
// not yet flown (Launched) — is collected for, and kept in, the battle.
func TestCollectPlanetGroupsIncludesLaunchedAndUpgrade(t *testing.T) {
c, _ := newCache()
// group 0: in orbit at Planet_0
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 1))
// group 1: ordered to depart Planet_0 (Launched), still physically there
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1))
c.ShipGroup(1).StateInSpace = &game.InSpace{Origin: R0_Planet_0_num}
c.ShipGroup(1).Destination = R0_Planet_2_num
assert.Equal(t, game.StateLaunched, c.ShipGroup(1).State())
// group 2: being upgraded at Planet_0
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1))
c.ShipGroup(2).StateUpgrade = &game.InUpgrade{UpgradeTech: []game.UpgradePreference{{Tech: game.TechDrive, Level: 2.0, Cost: 100}}}
assert.Equal(t, game.StateUpgrade, c.ShipGroup(2).State())
pg := controller.CollectPlanetGroups(c)
assert.Contains(t, pg, R0_Planet_0_num)
assert.Len(t, pg[R0_Planet_0_num], 3)
for _, idx := range []int{0, 1, 2} {
assert.Contains(t, pg[R0_Planet_0_num], idx)
}
battleGroups := controller.FilterBattleGroups(c, pg[R0_Planet_0_num])
assert.Len(t, battleGroups, 3)
}
// TestProduceBattlesLaunchedFightsAtOrigin checks that a group ordered to
// depart (Launched) still fights the pre-departure battle at its origin
// planet, rather than escaping into hyperspace before the fight.
func TestProduceBattlesLaunchedFightsAtOrigin(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.RaceRelation(Race_0.Name, Race_1.Name, game.RelationWar.String()))
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
// Race_0: armed group in orbit at Planet_0.
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10))
// Race_1: armed group ordered to depart Planet_0 (Launched), still there.
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R0_Planet_0_num, 10)
c.ShipGroup(1).StateInSpace = &game.InSpace{Origin: R0_Planet_0_num}
c.ShipGroup(1).Destination = R0_Planet_2_num
assert.Equal(t, game.StateLaunched, c.ShipGroup(1).State())
battles := controller.ProduceBattles(c)
assert.Len(t, battles, 1)
assert.True(t, battles[0].ObserverGroups[1], "launched group must be marked in-battle")
if c.ShipGroup(0).Number == 0 {
assert.Greater(t, c.ShipGroup(1).Number, uint(0))
} else {
assert.Zero(t, c.ShipGroup(1).Number)
}
}
+24 -7
View File
@@ -1,6 +1,10 @@
package controller package controller
import ( import (
"cmp"
"maps"
"slices"
"galaxy/game/internal/model/game" "galaxy/game/internal/model/game"
"github.com/google/uuid" "github.com/google/uuid"
@@ -13,8 +17,14 @@ func (c *Cache) ProduceBombings() []*game.Bombing {
if !p.Owned() { if !p.Owned() {
continue continue
} }
for ri, groups := range enemies { // The planet is hit by all attacking races at once, accounted from the
br := c.bombingReport(p, ri, groups) // strongest bombing power downwards, until no population remains.
attackers := slices.Collect(maps.Keys(enemies))
slices.SortFunc(attackers, func(a, b int) int {
return cmp.Compare(c.bombingPower(enemies[b]), c.bombingPower(enemies[a]))
})
for _, ri := range attackers {
br := c.bombingReport(p, ri, enemies[ri])
report = append(report, br) report = append(report, br)
if br.Wiped { if br.Wiped {
break break
@@ -22,7 +32,11 @@ func (c *Cache) ProduceBombings() []*game.Bombing {
} }
if p.Population == 0 { if p.Population == 0 {
// Wiped out: the planet turns uninhabited and its industry
// collapses, but the material and capital stockpiles survive for
// whoever colonises it next (rules "Бомбардировка планет").
p.Free() p.Free()
p.Ind(0)
} else { } else {
// Если на планете остались также и колонисты, то они превращаются в население, // Если на планете остались также и колонисты, то они превращаются в население,
// а накопленная промышленность возмещает потери производства. // а накопленная промышленность возмещает потери производства.
@@ -33,13 +47,16 @@ func (c *Cache) ProduceBombings() []*game.Bombing {
return report return report
} }
func (c *Cache) bombingReport(p *game.Planet, ri int, groups []int) *game.Bombing { func (c *Cache) bombingPower(groups []int) float64 {
attackPower := 0. var power float64
for _, i := range groups { for _, i := range groups {
sg := c.ShipGroup(i) power += c.ShipGroup(i).BombingPower(c.ShipGroupShipClass(i))
st := c.ShipGroupShipClass(i)
attackPower += sg.BombingPower(st)
} }
return power
}
func (c *Cache) bombingReport(p *game.Planet, ri int, groups []int) *game.Bombing {
attackPower := c.bombingPower(groups)
r := &game.Bombing{ r := &game.Bombing{
ID: uuid.New(), ID: uuid.New(),
PlanetOwnedID: *p.Owner, PlanetOwnedID: *p.Owner,
+56
View File
@@ -141,3 +141,59 @@ func TestProduceBombings(t *testing.T) {
} }
} }
} }
// TestBombingOrderByPower checks that attacking races are accounted from the
// strongest bombing power downwards (the report order), not in the random map
// iteration order the engine used before.
func TestBombingOrderByPower(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
weakIdx, _ := c.AddRace("Weakling")
assert.NoError(t, c.UpdateRelation(weakIdx, Race_0_idx, game.RelationWar))
assert.NoError(t, c.ShipClassCreate(weakIdx, "Pebble", 1, 1, 1, 1, 0))
// Planet_0 (Race_0) survives both attacks.
c.MustPlanet(R0_Planet_0_num).Population = 1000
// Strong: one Race_1 gunship (~358.9 power); weak: one Pebble (~1.1 power).
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R0_Planet_0_num, 1)
c.CreateShipsUnsafe_T(weakIdx, c.MustShipClass(weakIdx, "Pebble").ID, R0_Planet_0_num, 1)
reports := c.ProduceBombings()
assert.Len(t, reports, 2)
assert.Equal(t, Race_1.Name, reports[0].Attacker, "strongest attacker comes first")
assert.Equal(t, "Weakling", reports[1].Attacker)
assert.Greater(t, reports[0].AttackPower.F(), reports[1].AttackPower.F())
}
// TestBombingWipeZeroesIndustry checks that a planet bombed to extinction loses
// its industry but keeps its material and capital stockpiles for the next
// colonist (rules "Бомбардировка планет").
func TestBombingWipeZeroesIndustry(t *testing.T) {
c, _ := newCache()
bomberIdx, _ := c.AddRace("Bomber")
assert.NoError(t, c.UpdateRelation(bomberIdx, Race_0_idx, game.RelationWar))
// Bombing power ~106.5 (W=60, A=1, weapons tech 1.0): wipes pop 50 while
// only partly converting industry, so the leftover industry is observable.
assert.NoError(t, c.ShipClassCreate(bomberIdx, "Reaper", 1, 1, 60, 1, 0))
p := c.MustPlanet(R0_Planet_0_num)
p.Population = 50
p.Industry = 200
p.Capital = 30
p.Material = 20
p.Colonists = 0
c.CreateShipsUnsafe_T(bomberIdx, c.MustShipClass(bomberIdx, "Reaper").ID, R0_Planet_0_num, 1)
reports := c.ProduceBombings()
assert.Len(t, reports, 1)
assert.True(t, reports[0].Wiped)
pl := c.MustPlanet(R0_Planet_0_num)
assert.False(t, pl.Owned())
assert.Equal(t, 0., pl.Population.F())
assert.Equal(t, 0., pl.Industry.F(), "industry collapses on wipe")
assert.Equal(t, 30., pl.Capital.F(), "capital stockpile survives")
assert.InDelta(t, 126.476, pl.Material.F(), 0.01, "material keeps the converted industry")
}
@@ -149,3 +149,7 @@ func (c *Cache) WipeRace(ri int) {
func (c *Cache) UnsafeDeleteShipGroup(sgi int) { func (c *Cache) UnsafeDeleteShipGroup(sgi int) {
c.unsafeDeleteShipGroup(sgi) c.unsafeDeleteShipGroup(sgi)
} }
func DestructionRoll(probability float64) bool {
return destructionRoll(probability)
}
+11 -7
View File
@@ -20,25 +20,29 @@ func (c *Controller) MakeTurn() error {
c.Cache.g.Turn += 1 c.Cache.g.Turn += 1
c.Cache.g.Stage = 0 c.Cache.g.Stage = 0
// 01. Вышедшие расы удаляются из списка участвующих рас перед началом просчета очередного хода // 01. Вышедшие расы удаляются из списка участвующих рас перед началом просчёта очередного хода.
c.Cache.TurnWipeExtinctRaces() c.Cache.TurnWipeExtinctRaces()
// 02. Товары загружаются на корабли, находящиеся в начале грузовых маршрутов, и корабли входят в гиперпространство (но ещё не полетели) // 02. Корабли, где это возможно, объединяются в группы (до боя и до отправки по маршрутам).
c.Cache.SendRoutedGroups()
// 03. Корабли, где это возможно, объединяются в группы.
c.Cache.TurnMergeEqualShipGroups() c.Cache.TurnMergeEqualShipGroups()
// 04. Враждующие корабли вступают в схватку. // 03. Враждующие корабли вступают в схватку у планеты отправления. Корабли, которым отдан
// приказ на отлёт (статус Launched), ещё стоят на планете и участвуют в бою; в
// гиперпространство уходят только уцелевшие — так нельзя уклониться от боя.
battles := ProduceBattles(c.Cache) battles := ProduceBattles(c.Cache)
// 04. Товары загружаются на корабли в начале грузовых маршрутов, и эти корабли входят в
// гиперпространство. Загрузка после боя: маршрутные транспорты сражаются пустыми и не
// могут уклониться от боя, скрывшись в гиперпространстве.
c.Cache.SendRoutedGroups()
// 05. Корабли пролетают сквозь гиперпространство. // 05. Корабли пролетают сквозь гиперпространство.
c.Cache.MoveShipGroups() c.Cache.MoveShipGroups()
// 06. Корабли, где это возможно, объединяются в группы. // 06. Корабли, где это возможно, объединяются в группы.
c.Cache.TurnMergeEqualShipGroups() c.Cache.TurnMergeEqualShipGroups()
// 07. Враждующие корабли снова вступают в схватку (это происходит после выхода из гиперпространства). // 07. Враждующие корабли снова вступают в схватку (после выхода из гиперпространства).
battles = append(battles, ProduceBattles(c.Cache)...) battles = append(battles, ProduceBattles(c.Cache)...)
// 08. Корабли бомбят вражеские планеты. // 08. Корабли бомбят вражеские планеты.
+9 -4
View File
@@ -162,7 +162,7 @@ func (c *Cache) PlanetProductionCapacity(planetNumber uint) float64 {
p := c.MustPlanet(planetNumber) p := c.MustPlanet(planetNumber)
var busyResources float64 var busyResources float64
for sg := range c.shipGroupsInUpgrade(p.Number) { for sg := range c.shipGroupsInUpgrade(p.Number) {
busyResources += sg.StateUpgrade.Cost() busyResources += c.upgradeCostNow(sg)
} }
return p.ProductionCapacity() - busyResources return p.ProductionCapacity() - busyResources
} }
@@ -181,10 +181,15 @@ func (c *Cache) TurnPlanetProductions() {
ri := c.RaceIndex(*p.Owner) ri := c.RaceIndex(*p.Owner)
r := &c.g.Race[ri] r := &c.g.Race[ri]
// upgrade groups and return to in_orbit state // Upgrade groups (most expensive first) and return them to the
productionAvailable := c.PlanetProductionCapacity(pn) // in-orbit state, paying for each upgrade once out of the planet's
// full production potential; whatever remains feeds this turn's
// production below. Starting from PlanetProductionCapacity here would
// have charged every applied upgrade twice, since that helper already
// nets out the reserved upgrade cost for the report.
productionAvailable := p.ProductionCapacity()
for sg := range c.shipGroupsInUpgrade(p.Number) { for sg := range c.shipGroupsInUpgrade(p.Number) {
cost := sg.StateUpgrade.Cost() cost := c.upgradeCostNow(sg)
if productionAvailable >= cost { if productionAvailable >= cost {
for i := range sg.StateUpgrade.UpgradeTech { for i := range sg.StateUpgrade.UpgradeTech {
sg.Tech = sg.Tech.Set(sg.StateUpgrade.UpgradeTech[i].Tech, util.Fixed3(sg.StateUpgrade.UpgradeTech[i].Level.F())) sg.Tech = sg.Tech.Set(sg.StateUpgrade.UpgradeTech[i].Tech, util.Fixed3(sg.StateUpgrade.UpgradeTech[i].Level.F()))
+34 -1
View File
@@ -208,7 +208,40 @@ func TestProduceShips(t *testing.T) {
assert.Equal(t, game.StateInOrbit, c.ShipGroup(0).State()) assert.Equal(t, game.StateInOrbit, c.ShipGroup(0).State())
assert.Equal(t, 1.5, c.ShipGroup(0).TechLevel(game.TechDrive).F()) assert.Equal(t, 1.5, c.ShipGroup(0).TechLevel(game.TechDrive).F())
assert.Equal(t, 4346.676567656759, c.MustPlanet(R0_Planet_0_num).Material.F()) // The pending upgrade is now charged once (not twice) against the planet's
// production potential, so MAT production keeps the budget it previously
// lost to the double charge (the pre-fix value here was ~4346.68).
assert.InDelta(t, 7173.3432, c.MustPlanet(R0_Planet_0_num).Material.F(), 0.001)
}
// TestUpgradeDoesNotDoubleChargeProduction guards that a pending upgrade is
// paid for once out of the planet's production potential, leaving the rest for
// the turn's production. The pre-fix code subtracted the upgrade cost twice
// (PlanetProductionCapacity already nets it out for the report, and the apply
// loop netted it again), which both starved production and could skip
// affordable upgrades.
func TestUpgradeDoesNotDoubleChargeProduction(t *testing.T) {
c, _ := newCache()
p := c.MustPlanet(R0_Planet_0_num)
p.Population = 1000
p.Industry = 1000 // ProductionCapacity = 1000*0.75 + 1000*0.25 = 1000
p.Resources = 1 // material produced == leftover production budget
p.Colonists = 0
p.Material = 0
assert.NoError(t, c.PlanetProduce(Race_0_idx, int(R0_Planet_0_num), game.ProductionMaterial, ""))
// One Cruiser with a pending drive upgrade 1.1 -> 2.0:
// block cost = (1 - 1.1/2.0) * 10 * 15 = 67.5 for the single ship.
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1))
c.ShipGroup(0).StateUpgrade = &game.InUpgrade{
UpgradeTech: []game.UpgradePreference{{Tech: game.TechDrive, Level: 2.0}},
}
c.TurnPlanetProductions()
assert.InDelta(t, 2.0, c.ShipGroup(0).TechLevel(game.TechDrive).F(), 0.0001)
// 1000 - 67.5 = 932.5; the pre-fix double charge would have left 865.
assert.InDelta(t, 932.5, c.MustPlanet(R0_Planet_0_num).Material.F(), 0.01)
} }
func TestProduceShip(t *testing.T) { func TestProduceShip(t *testing.T) {
+23 -2
View File
@@ -117,13 +117,34 @@ func (c *Cache) raceTechLevel(ri int, t game.Tech, v float64) {
} }
func (c *Cache) TurnWipeExtinctRaces() { func (c *Cache) TurnWipeExtinctRaces() {
for i := range c.listRaceActingIdx() { for i := range c.listRaceIdx() {
if (c.g.Race[i].Extinct && c.g.Race[i].TTL > 0) || (!c.g.Race[i].Extinct && c.g.Race[i].TTL == 0) { r := &c.g.Race[i]
// Idle timeout or voluntary quit: a still-active race whose TTL ran
// out. Administrative banish: a race already flagged extinct that
// still holds assets to release. Once a race is wiped it owns nothing,
// so the asset check keeps this idempotent across later turns.
if (!r.Extinct && r.TTL == 0) || (r.Extinct && c.raceHasAssets(i)) {
c.wipeRace(i) c.wipeRace(i)
} }
} }
} }
// raceHasAssets reports whether the race still owns a planet or a ship group.
func (c *Cache) raceHasAssets(ri int) bool {
id := c.g.Race[ri].ID
for i := range c.g.Map.Planet {
if c.g.Map.Planet[i].OwnedBy(id) {
return true
}
}
for i := range c.g.ShipGroups {
if c.g.ShipGroups[i].OwnerID == id {
return true
}
}
return false
}
func (c *Cache) wipeRace(ri int) { func (c *Cache) wipeRace(ri int) {
c.validateRaceIndex(ri) c.validateRaceIndex(ri)
r := &c.g.Race[ri] r := &c.g.Race[ri]
+37
View File
@@ -1,6 +1,7 @@
package controller_test package controller_test
import ( import (
"slices"
"testing" "testing"
e "galaxy/error" e "galaxy/error"
@@ -90,3 +91,39 @@ func TestRaceID(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, Race_0_ID, id) assert.Equal(t, Race_0_ID, id)
} }
// TestBanishReleasesAssets checks that an administratively banished race only
// gets flagged extinct, and its planets and ships are released during turn
// generation; a second pass is a no-op.
func TestBanishReleasesAssets(t *testing.T) {
c, g := newCache()
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R1_Planet_1_num, 3)
assert.True(t, c.MustPlanet(R1_Planet_1_num).OwnedBy(Race_1_ID))
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_1_idx)), 1)
assert.NoError(t, g.RaceBanish(Race_1.Name))
assert.True(t, c.Race(Race_1_idx).Extinct)
assert.True(t, c.MustPlanet(R1_Planet_1_num).OwnedBy(Race_1_ID), "still owned until the turn runs")
c.TurnWipeExtinctRaces()
assert.False(t, c.MustPlanet(R1_Planet_1_num).Owned())
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_1_idx)), 0)
// Idempotent: re-running over an already-wiped (asset-less) race is a no-op.
c.TurnWipeExtinctRaces()
assert.False(t, c.MustPlanet(R1_Planet_1_num).Owned())
}
// TestIdleRaceWipedOnTimeout guards that a still-active race whose TTL ran out
// (idle timeout or quit) is still wiped after the iterator change.
func TestIdleRaceWipedOnTimeout(t *testing.T) {
c, _ := newCache()
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R1_Planet_1_num, 1)
c.Race(Race_1_idx).TTL = 0
assert.False(t, c.Race(Race_1_idx).Extinct)
c.TurnWipeExtinctRaces()
assert.True(t, c.Race(Race_1_idx).Extinct)
assert.False(t, c.MustPlanet(R1_Planet_1_num).Owned())
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_1_idx)), 0)
}
+118 -37
View File
@@ -129,13 +129,16 @@ func (c *Cache) ReportRace(ri int, rep *mr.Report, battles []*mr.BattleReport, b
rep.Player[i].Relation = "-" rep.Player[i].Relation = "-"
} }
// race exit warnings
c.ReportExitWarnings(ri, rep)
// sciences // sciences
c.ReportLocalScience(ri, rep) c.ReportLocalScience(ri, rep)
c.ReportOtherScience(ri, rep) c.ReportOtherScience(ri, rep)
// ship classes // ship classes
c.ReportLocalShipClass(ri, rep) c.ReportLocalShipClass(ri, rep)
c.ReportOtherShipClass(ri, rep) c.ReportOtherShipClass(ri, rep, battles)
// battles // battles
c.ReportBattle(ri, rep, battles) c.ReportBattle(ri, rep, battles)
@@ -177,6 +180,36 @@ func (c *Cache) ReportRace(ri int, rep *mr.Report, battles []*mr.BattleReport, b
c.ReportUnidentifiedGroup(ri, rep) c.ReportUnidentifiedGroup(ri, rep)
} }
// ReportExitWarnings fills the inactivity-removal warnings. A race's TTL at
// report time equals the number of turns remaining before it is auto-removed
// (it is wiped at the start of turn T+TTL). The recipient gets a personal
// countdown once it is 5 turns out (rep.PersonalExitWarning); every other
// non-extinct race within 3 turns of removal is listed publicly
// (rep.RacesLeavingSoon). Voluntary quit and idle timeout share the TTL
// countdown and are intentionally not distinguished here.
func (c *Cache) ReportExitWarnings(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
rep.PersonalExitWarning = 0
if ttl := c.g.Race[ri].TTL; ttl > 0 && ttl <= 5 {
rep.PersonalExitWarning = ttl
}
rep.RacesLeavingSoon = rep.RacesLeavingSoon[:0]
for i := range c.g.Race {
r := &c.g.Race[i]
if i == ri || r.Extinct {
continue
}
if r.TTL > 0 && r.TTL <= 3 {
rep.RacesLeavingSoon = append(rep.RacesLeavingSoon, mr.RaceExitNotice{
Race: r.Name,
TurnsLeft: r.TTL,
})
}
}
}
func (c *Cache) ReportLocalScience(ri int, rep *mr.Report) { func (c *Cache) ReportLocalScience(ri int, rep *mr.Report) {
c.validateRaceIndex(ri) c.validateRaceIndex(ri)
r := &c.g.Race[ri] r := &c.g.Race[ri]
@@ -249,7 +282,7 @@ func (c *Cache) ReportLocalShipClass(ri int, report *mr.Report) {
slices.SortFunc(report.LocalShipClass, func(a, b mr.ShipClass) int { return cmp.Compare(a.Name, b.Name) }) slices.SortFunc(report.LocalShipClass, func(a, b mr.ShipClass) int { return cmp.Compare(a.Name, b.Name) })
} }
func (c *Cache) ReportOtherShipClass(ri int, rep *mr.Report) { func (c *Cache) ReportOtherShipClass(ri int, rep *mr.Report, battles []*mr.BattleReport) {
c.validateRaceIndex(ri) c.validateRaceIndex(ri)
r := &c.g.Race[ri] r := &c.g.Race[ri]
@@ -273,26 +306,46 @@ func (c *Cache) ReportOtherShipClass(ri int, rep *mr.Report) {
return false return false
} }
// add visible ship classes from battles // Ship classes seen in battles the recipient took part in or witnessed.
// for bi := range battle { // The battle report carries the class name and owner race; the class
// for si := range battle[bi].Ships { // design is looked up from that race's ship types, which stay present in
// g := battle[bi].Ships[si] // the state even though the groups themselves are deleted before reports
// if skip(g.OwnerID, g.ClassName) { // are generated.
// continue for bi := range battles {
// } br := battles[bi]
visible := false
// sliceIndexValidate(&rep.OtherShipClass, i) for k := range br.Races {
// rep.OtherShipClass[i].Race = c.g.Race[c.RaceIndex(g.OwnerID)].Name if br.Races[k] == r.ID {
// rep.OtherShipClass[i].Name = g.ClassName visible = true
// rep.OtherShipClass[i].Drive = g.DriveTech break
// rep.OtherShipClass[i].Armament = g.ClassArmament }
// rep.OtherShipClass[i].Weapons = g.WeaponsTech }
// rep.OtherShipClass[i].Shields = g.ShieldsTech if !visible {
// rep.OtherShipClass[i].Cargo = g.CargoTech continue
// rep.OtherShipClass[i].Mass = g.ClassMass }
// i++ for si := range br.Ships {
// } bg := br.Ships[si]
// } ownerIdx, err := c.raceIndex(bg.Race)
if err != nil {
continue
}
ownerID := c.g.Race[ownerIdx].ID
st, _, ok := c.ShipClass(ownerIdx, bg.ClassName)
if !ok || skip(ownerID, st.Name) {
continue
}
sliceIndexValidate(&rep.OtherShipClass, i)
rep.OtherShipClass[i].Race = bg.Race
rep.OtherShipClass[i].Name = st.Name
rep.OtherShipClass[i].Drive = mr.F(st.Drive.F())
rep.OtherShipClass[i].Armament = st.Armament
rep.OtherShipClass[i].Weapons = mr.F(st.Weapons.F())
rep.OtherShipClass[i].Shields = mr.F(st.Shields.F())
rep.OtherShipClass[i].Cargo = mr.F(st.Cargo.F())
rep.OtherShipClass[i].Mass = mr.F(st.EmptyMass())
i++
}
}
// add visible ships from owned and observed planets // add visible ships from owned and observed planets
for pn := range rep.OnPlanetGroupCache { for pn := range rep.OnPlanetGroupCache {
@@ -392,13 +445,30 @@ func (c *Cache) ReportIncomingGroup(ri int, rep *mr.Report) {
if sg.OwnerID == r.ID || sg.State() != game.StateInSpace { if sg.OwnerID == r.ID || sg.State() != game.StateInSpace {
continue continue
} }
p1 := c.MustPlanet(sg.StateInSpace.Origin)
p2 := c.MustPlanet(sg.Destination) p2 := c.MustPlanet(sg.Destination)
if !p2.OwnedBy(r.ID) { if !p2.OwnedBy(r.ID) {
continue continue
} }
// Beyond the visibility range (driveTech*30) of every owned planet the
// group is not shown at all, even though it heads to one of them.
visible := false
for pi := range c.g.Map.Planet {
op := &c.g.Map.Planet[pi]
if !op.OwnedBy(r.ID) {
continue
}
if d, ok := rep.InSpaceGroupRangeCache[sgi][op.Number]; ok && d <= r.VisibilityDistance() {
visible = true
break
}
}
if !visible {
continue
}
distance := calc.ShortDistance(c.g.Map.Width, c.g.Map.Height, p1.X.F(), p1.Y.F(), p2.X.F(), p2.Y.F()) // Remaining distance is measured from the group's current position in
// hyperspace to the destination, not from its origin planet.
distance := calc.ShortDistance(c.g.Map.Width, c.g.Map.Height, sg.StateInSpace.X.F(), sg.StateInSpace.Y.F(), p2.X.F(), p2.Y.F())
var speed, mass float64 var speed, mass float64
if sg.FleetID != nil { if sg.FleetID != nil {
speed, mass = c.FleetSpeedAndMass(c.MustFleetIndex(*sg.FleetID)) speed, mass = c.FleetSpeedAndMass(c.MustFleetIndex(*sg.FleetID))
@@ -541,12 +611,12 @@ func (c *Cache) ReportShipProduction(ri int, rep *mr.Report) {
st := c.MustShipType(ri, *p.Production.SubjectID) st := c.MustShipType(ri, *p.Production.SubjectID)
sliceIndexValidate(&rep.ShipProduction, i) sliceIndexValidate(&rep.ShipProduction, i)
rep.ShipProduction[pi].Planet = p.Number rep.ShipProduction[i].Planet = p.Number
rep.ShipProduction[pi].Class = st.Name rep.ShipProduction[i].Class = st.Name
rep.ShipProduction[pi].Cost = mr.F(calc.ShipProductionCost(st.EmptyMass())) rep.ShipProduction[i].Cost = mr.F(calc.ShipProductionCost(st.EmptyMass()))
rep.ShipProduction[pi].Free = mr.F(c.PlanetProductionCapacity(p.Number)) rep.ShipProduction[i].Free = mr.F(c.PlanetProductionCapacity(p.Number))
rep.ShipProduction[pi].ProdUsed = mr.F((*p.Production.ProdUsed).F()) rep.ShipProduction[i].ProdUsed = mr.F((*p.Production.ProdUsed).F())
rep.ShipProduction[pi].Percent = mr.F((*p.Production.Progress).F()) rep.ShipProduction[i].Percent = mr.F((*p.Production.Progress).F())
i++ i++
} }
} }
@@ -685,34 +755,45 @@ func (c *Cache) ReportOtherGroup(ri int, rep *mr.Report) {
func (c *Cache) ReportUnidentifiedGroup(ri int, rep *mr.Report) { func (c *Cache) ReportUnidentifiedGroup(ri int, rep *mr.Report) {
c.validateRaceIndex(ri) c.validateRaceIndex(ri)
r := &c.g.Race[ri] r := &c.g.Race[ri]
flightDistance := r.FlightDistance() visibility := r.VisibilityDistance()
clear(rep.UnidentifiedGroup) clear(rep.UnidentifiedGroup)
i := 0 i := 0
for sgi := range rep.InSpaceGroupRangeCache { for sgi := range rep.InSpaceGroupRangeCache {
sg := c.ShipGroup(sgi) sg := c.ShipGroup(sgi)
if sg.OwnerID == rep.RaceID { if sg.OwnerID == r.ID {
continue continue
} }
if sg.StateInSpace == nil { if sg.StateInSpace == nil {
panic(fmt.Sprintf("pre-calculated distance group not in space: i=%d", sgi)) panic(fmt.Sprintf("pre-calculated distance group not in space: i=%d", sgi))
} }
// Groups heading to one of the recipient's planets are listed in full
// under "incoming groups"; the unidentified list is for the rest.
if c.MustPlanet(sg.Destination).OwnedBy(r.ID) {
continue
}
// Shown once, and only within the visibility range (driveTech*30) of at
// least one of the recipient's planets.
visible := false
for pi := range c.g.Map.Planet { for pi := range c.g.Map.Planet {
p := &c.g.Map.Planet[pi] p := &c.g.Map.Planet[pi]
if !p.OwnedBy(r.ID) { if !p.OwnedBy(r.ID) {
continue continue
} }
if v, ok := rep.InSpaceGroupRangeCache[sgi][p.Number]; !ok { if v, ok := rep.InSpaceGroupRangeCache[sgi][p.Number]; ok && v <= visibility {
panic(fmt.Sprintf("distance cache not pre-calculated: i=%d p=#%d", sgi, p.Number)) visible = true
} else if v <= flightDistance { break
}
}
if !visible {
continue
}
sliceIndexValidate(&rep.UnidentifiedGroup, i) sliceIndexValidate(&rep.UnidentifiedGroup, i)
rep.UnidentifiedGroup[i].X = mr.F(sg.StateInSpace.X.F()) rep.UnidentifiedGroup[i].X = mr.F(sg.StateInSpace.X.F())
rep.UnidentifiedGroup[i].Y = mr.F(sg.StateInSpace.Y.F()) rep.UnidentifiedGroup[i].Y = mr.F(sg.StateInSpace.Y.F())
i++ i++
} }
}
}
} }
func (c *Cache) otherGroup(v *mr.OtherGroup, sg *game.ShipGroup, st *game.ShipType) { func (c *Cache) otherGroup(v *mr.OtherGroup, sg *game.ShipGroup, st *game.ShipType) {
+141
View File
@@ -3,8 +3,10 @@ package controller_test
import ( import (
"testing" "testing"
"galaxy/game/internal/model/game"
"galaxy/model/report" "galaxy/model/report"
"github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -86,3 +88,142 @@ func TestReportLocalShipClass(t *testing.T) {
} }
} }
} }
// TestReportIncomingGroupVisibility checks that a group heading to one of the
// recipient's planets is reported only while within the recipient's visibility
// range (driveTech*30); beyond it the group is hidden even though it is inbound.
func TestReportIncomingGroupVisibility(t *testing.T) {
c, _ := newCache()
gi := c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R1_Planet_1_num, 5)
c.ShipGroup(gi).Destination = R0_Planet_0_num
// Within Race_0 visibility (driveTech 1.1 -> 33 ly), near Planet_2 (3,3).
c.ShipGroup(gi).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(5), Y: floatRef(5)}
rep := c.InitReport(1)
c.ReportIncomingGroup(Race_0_idx, rep)
assert.Len(t, rep.IncomingGroup, 1)
// Beyond the visibility of every Race_0 planet: hidden.
c.ShipGroup(gi).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(40), Y: floatRef(40)}
rep = c.InitReport(1)
c.ReportIncomingGroup(Race_0_idx, rep)
assert.Len(t, rep.IncomingGroup, 0)
}
// TestReportUnidentifiedGroup checks the three rules for the unidentified list:
// groups heading to the recipient's planets are excluded (they are "incoming"),
// only groups within visibility (driveTech*30) appear, and each group appears
// once even when several owned planets are in range.
func TestReportUnidentifiedGroup(t *testing.T) {
c, _ := newCache()
cls := c.MustShipClass(Race_1_idx, Race_1_Gunship).ID
// Not inbound to Race_0, within visibility of BOTH Planet_0 and Planet_2.
g0 := c.CreateShipsUnsafe_T(Race_1_idx, cls, R1_Planet_1_num, 1)
c.ShipGroup(g0).Destination = Uninhabited_Planet_3_num
c.ShipGroup(g0).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(5), Y: floatRef(5)}
// Inbound to a Race_0 planet -> reported as incoming, not unidentified.
g1 := c.CreateShipsUnsafe_T(Race_1_idx, cls, R1_Planet_1_num, 1)
c.ShipGroup(g1).Destination = R0_Planet_0_num
c.ShipGroup(g1).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(5), Y: floatRef(5)}
// Not inbound, beyond visibility -> hidden.
g2 := c.CreateShipsUnsafe_T(Race_1_idx, cls, R1_Planet_1_num, 1)
c.ShipGroup(g2).Destination = Uninhabited_Planet_3_num
c.ShipGroup(g2).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(40), Y: floatRef(40)}
rep := c.InitReport(1)
c.ReportUnidentifiedGroup(Race_0_idx, rep)
assert.Len(t, rep.UnidentifiedGroup, 1)
}
// TestReportOtherShipClassFromBattle checks that the class of a foreign ship
// met in a battle the recipient witnessed is surfaced in OtherShipClass, with
// its design looked up from the owner race's ship types, while the recipient's
// own class is skipped.
func TestReportOtherShipClassFromBattle(t *testing.T) {
c, _ := newCache()
br := &report.BattleReport{
Races: map[int]uuid.UUID{0: Race_0.ID, 1: Race_1.ID},
Ships: map[int]report.BattleReportGroup{
0: {Race: Race_1.Name, ClassName: Race_1_Gunship},
1: {Race: Race_0.Name, ClassName: Race_0_Gunship}, // recipient's own -> skipped
},
}
rep := c.InitReport(1)
c.ReportOtherShipClass(Race_0_idx, rep, []*report.BattleReport{br})
assert.Len(t, rep.OtherShipClass, 1)
g := rep.OtherShipClass[0]
assert.Equal(t, Race_1.Name, g.Race)
assert.Equal(t, Race_1_Gunship, g.Name)
assert.Equal(t, report.F(60.), g.Drive)
assert.Equal(t, uint(3), g.Armament)
assert.Equal(t, report.F(30.), g.Weapons)
assert.Equal(t, report.F(100.), g.Shields)
assert.Equal(t, report.F(0.), g.Cargo)
assert.Equal(t, report.F(220.), g.Mass)
}
// TestReportShipProductionIndex guards the report index: when the only
// ship-producing planet is not the first planet in the map, its entry must
// land at the compacted report index, not the planet's map index (which would
// write out of the grown slice and panic).
func TestReportShipProductionIndex(t *testing.T) {
c, _ := newCache()
assert.NoError(t, c.PlanetProduce(Race_0_idx, int(R0_Planet_2_num), game.ProductionShip, ShipType_Cruiser))
rep := c.InitReport(1)
c.ReportShipProduction(Race_0_idx, rep)
assert.Len(t, rep.ShipProduction, 1)
assert.Equal(t, R0_Planet_2_num, rep.ShipProduction[0].Planet)
}
// TestReportIncomingGroupRemainingDistance checks the reported distance is the
// remaining distance from the group's current hyperspace position to the
// destination, not the full origin-to-destination route.
func TestReportIncomingGroupRemainingDistance(t *testing.T) {
c, _ := newCache()
gi := c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R1_Planet_1_num, 1)
c.ShipGroup(gi).Destination = R0_Planet_0_num // Planet_0 at (1,1)
c.ShipGroup(gi).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(5), Y: floatRef(5)}
rep := c.InitReport(1)
c.ReportIncomingGroup(Race_0_idx, rep)
assert.Len(t, rep.IncomingGroup, 1)
// current (5,5) -> dest (1,1) = sqrt(32) ≈ 5.657; the origin (2,2) -> dest
// route would be sqrt(2) ≈ 1.414.
assert.InDelta(t, 5.657, rep.IncomingGroup[0].Distance.F(), 0.01)
}
// TestReportExitWarnings checks the inactivity-removal warnings: the recipient
// gets a personal countdown only at TTL 1..5, other non-extinct races within 3
// turns are listed publicly, the recipient is excluded from its own public
// list, and extinct races never appear.
func TestReportExitWarnings(t *testing.T) {
c, _ := newCache()
c.Race(Race_0_idx).TTL = 5
c.Race(Race_1_idx).TTL = 2
c.Race(2).TTL = 2 // Race_Extinct: extinct, must never appear publicly
// Race_0's report: personal countdown 5; only Race_1 (TTL 2) is public.
r0 := &report.Report{}
c.ReportExitWarnings(Race_0_idx, r0)
assert.Equal(t, uint(5), r0.PersonalExitWarning)
assert.Len(t, r0.RacesLeavingSoon, 1)
assert.Equal(t, Race_1.Name, r0.RacesLeavingSoon[0].Race)
assert.Equal(t, uint(2), r0.RacesLeavingSoon[0].TurnsLeft)
// Race_1's report: personal countdown 2; Race_0 (TTL 5 > 3) is not public.
r1 := &report.Report{}
c.ReportExitWarnings(Race_1_idx, r1)
assert.Equal(t, uint(2), r1.PersonalExitWarning)
assert.Empty(t, r1.RacesLeavingSoon)
// TTL above the 5-turn window → no personal warning.
c.Race(Race_0_idx).TTL = 6
r0b := &report.Report{}
c.ReportExitWarnings(Race_0_idx, r0b)
assert.Zero(t, r0b.PersonalExitWarning)
}
+4 -1
View File
@@ -2,6 +2,7 @@ package controller
import ( import (
"fmt" "fmt"
"math"
"slices" "slices"
"galaxy/util" "galaxy/util"
@@ -36,7 +37,9 @@ func (c *Cache) ScienceCreate(ri int, name string, drive, weapons, shileds, carg
return e.NewCargoValueError(cargo) return e.NewCargoValueError(cargo)
} }
sum := drive + weapons + shileds + cargo sum := drive + weapons + shileds + cargo
if sum != 1 { // The proportions must add up to one; a small tolerance keeps inputs like
// 0.1+0.2+0.3+0.4 (which is 1 only up to float rounding) from being rejected.
if math.Abs(sum-1) > 1e-9 {
return e.NewScienceSumValuesError("D=%f W=%f S=%f C=%f sum=%f", drive, weapons, shileds, cargo, sum) return e.NewScienceSumValuesError("D=%f W=%f S=%f C=%f sum=%f", drive, weapons, shileds, cargo, sum)
} }
c.g.Race[ri].Sciences = append(c.g.Race[ri].Sciences, game.Science{ c.g.Race[ri].Sciences = append(c.g.Race[ri].Sciences, game.Science{
+11
View File
@@ -136,3 +136,14 @@ func TestResearchTech(t *testing.T) {
assert.Equal(t, 1.35, rr.Tech.Value(game.TechShields)) assert.Equal(t, 1.35, rr.Tech.Value(game.TechShields))
assert.Equal(t, 1.45, rr.Tech.Value(game.TechCargo)) assert.Equal(t, 1.45, rr.Tech.Value(game.TechCargo))
} }
// TestScienceCreateFloatTolerance checks that proportions which sum to 1 only
// up to float rounding (0.1+0.2+0.3+0.4 == 1.0000000000000002) are accepted,
// while a sum clearly off one is still rejected.
func TestScienceCreateFloatTolerance(t *testing.T) {
_, g := newCache()
assert.NoError(t, g.ScienceCreate(Race_0.Name, "FloatSum", 0.1, 0.2, 0.3, 0.4))
assert.ErrorContains(t,
g.ScienceCreate(Race_0.Name, "NotOne", 0.1, 0.2, 0.3, 0.3),
e.GenericErrorText(e.ErrInputScienceSumValues))
}
+7 -1
View File
@@ -177,6 +177,12 @@ func (c *Cache) shipGroupDismantle(ri int, groupIndex uuid.UUID) error {
case game.CargoColonist: case game.CargoColonist:
if p.OwnedBy(c.g.Race[ri].ID) { if p.OwnedBy(c.g.Race[ri].ID) {
p = game.UnloadColonists(p, load) p = game.UnloadColonists(p, load)
} else if !p.Owned() {
// Over a neutral planet the colonists settle it: the planet
// becomes the dismantling race's and the colonists join its
// population. Over a foreign planet they are simply lost.
p.Own(c.g.Race[ri].ID)
p = game.UnloadColonists(p, load)
} }
case game.CargoMaterial: case game.CargoMaterial:
p.Material = p.Material.Add(load) p.Material = p.Material.Add(load)
@@ -491,7 +497,7 @@ func (c *Cache) shipGroupsInUpgrade(planetNumber uint) iter.Seq[*game.ShipGroup]
} }
} }
slices.SortFunc(result, func(a, b int) int { slices.SortFunc(result, func(a, b int) int {
return cmp.Compare(c.g.ShipGroups[b].StateUpgrade.Cost(), c.g.ShipGroups[a].StateUpgrade.Cost()) return cmp.Compare(c.upgradeCostNow(&c.g.ShipGroups[b]), c.upgradeCostNow(&c.g.ShipGroups[a]))
}) })
for i := range result { for i := range result {
if !yield(&c.g.ShipGroups[result[i]]) { if !yield(&c.g.ShipGroups[result[i]]) {
+12 -7
View File
@@ -34,15 +34,20 @@ func (c *Cache) MoveShipGroups() {
func (c *Cache) moveShipGroup(i int, delta float64) { func (c *Cache) moveShipGroup(i int, delta float64) {
sg := c.ShipGroup(i) sg := c.ShipGroup(i)
originX, originY, ok := sg.Coord() var originX, originY float64
if !ok { switch sg.State() {
panic(fmt.Sprintf("ship group state invalid: %v", sg.State())) case game.StateLaunched:
// Just launched: the group is still at its origin planet and has not
// stored a hyperspace position yet, so the first leg starts there.
origin := c.MustPlanet(sg.StateInSpace.Origin)
originX, originY = origin.X.F(), origin.Y.F()
case game.StateInSpace:
originX, originY = sg.StateInSpace.X.F(), sg.StateInSpace.Y.F()
default:
panic(fmt.Sprintf("ship group state invalid for move: %v", sg.State()))
} }
destPlanet := c.MustPlanet(sg.Destination) destPlanet := c.MustPlanet(sg.Destination)
arrived := false x, y, arrived := util.NextTravelCoord(c.g.Map.Width, c.g.Map.Height, originX, originY, destPlanet.X.F(), destPlanet.Y.F(), delta)
var x, y float64
x, y, arrived =
util.NextTravelCoord(c.g.Map.Width, c.g.Map.Height, originX, originY, destPlanet.X.F(), destPlanet.Y.F(), delta)
fx, fy := game.F(x), game.F(y) fx, fy := game.F(x), game.F(y)
sg.StateInSpace.X = &fx sg.StateInSpace.X = &fx
sg.StateInSpace.Y = &fy sg.StateInSpace.Y = &fy
@@ -45,3 +45,20 @@ func TestListMoveableGroupIds(t *testing.T) {
assert.NotEqual(t, game.StateTransfer, sg.State()) assert.NotEqual(t, game.StateTransfer, sg.State())
} }
} }
// TestMoveLaunchedGroupFromOrigin guards the launched-coordinate fix: a group
// just sent by an order is Launched with no stored hyperspace position, so its
// first leg must start from the origin planet. The pre-fix code dereferenced
// the nil launch coordinate and panicked.
func TestMoveLaunchedGroupFromOrigin(t *testing.T) {
c, g := newCache()
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1))
assert.NoError(t, g.ShipGroupSend(Race_0.Name, c.ShipGroup(0).ID, R0_Planet_2_num))
assert.Equal(t, game.StateLaunched, c.ShipGroup(0).State())
// Must not panic on the nil launch coordinate. Planet_0 (1,1) -> Planet_2
// (3,3) is ~2.83 ly; a Cruiser covers it in one turn and arrives.
c.MoveShipGroups()
assert.Equal(t, game.StateInOrbit, c.ShipGroup(0).State())
assert.Equal(t, R0_Planet_2_num, c.ShipGroup(0).Destination)
}
@@ -567,3 +567,20 @@ func TestUnsafeDeleteShipGroup(t *testing.T) {
assert.Equal(t, uint(3), c.ShipGroup(0).Number) assert.Equal(t, uint(3), c.ShipGroup(0).Number)
assert.Equal(t, uint(7), c.ShipGroup(1).Number) assert.Equal(t, uint(7), c.ShipGroup(1).Number)
} }
// TestShipGroupDismantleColonizesNeutralPlanet checks that dismantling a
// colonist-laden group over an uninhabited planet settles it: the planet
// becomes the race's and the colonists join its population.
func TestShipGroupDismantleColonizesNeutralPlanet(t *testing.T) {
c, g := newCache()
gi := c.CreateShipsUnsafe_T(Race_0_idx, c.MustShipClass(Race_0_idx, Race_0_Freighter).ID, Uninhabited_Planet_4_num, 10)
c.ShipGroup(gi).CargoType = game.CargoColonist.Ref()
c.ShipGroup(gi).Load = 10.0
assert.False(t, c.MustPlanet(Uninhabited_Planet_4_num).Owned())
assert.NoError(t, g.ShipGroupDismantle(Race_0.Name, c.ShipGroup(gi).ID))
p := c.MustPlanet(Uninhabited_Planet_4_num)
assert.True(t, p.OwnedBy(Race_0_ID))
assert.Equal(t, 80., p.Population.F()) // 10 colonists * 8
}
@@ -196,6 +196,24 @@ func FutureUpgradeLevel(raceLevel, groupLevel, limit float64) float64 {
return target return target
} }
// upgradeCostNow returns the production cost to apply group sg's pending
// upgrade to its CURRENT ship count. The cost stored on StateUpgrade is fixed
// when the order is validated; if the group has since lost ships (for example
// in the pre-departure battle, now that upgrading groups take part in it), the
// stored total is stale, so the cost is recomputed from the stored target
// levels, the group's current tech, and its current ship count.
func (c *Cache) upgradeCostNow(sg *game.ShipGroup) float64 {
if sg.StateUpgrade == nil {
return 0
}
st := c.MustShipType(c.RaceIndex(sg.OwnerID), sg.TypeID)
var perShip float64
for _, pref := range sg.StateUpgrade.UpgradeTech {
perShip += calc.BlockUpgradeCost(st.BlockMass(pref.Tech), sg.TechLevel(pref.Tech).F(), pref.Level.F())
}
return perShip * float64(sg.Number)
}
func UpgradeGroupPreference(sg game.ShipGroup, st game.ShipType, tech game.Tech, v float64) game.ShipGroup { func UpgradeGroupPreference(sg game.ShipGroup, st game.ShipType, tech game.Tech, v float64) game.ShipGroup {
if v <= 0 || st.BlockMass(tech) == 0 || sg.TechLevel(tech).F() >= v { if v <= 0 || st.BlockMass(tech) == 0 || sg.TechLevel(tech).F() >= v {
return sg return sg
@@ -170,3 +170,24 @@ func TestShipGroupUpgrade(t *testing.T) {
g.ShipGroupUpgrade(Race_0.Name, c.ShipGroup(3).ID, "DRIVE", 1.3), g.ShipGroupUpgrade(Race_0.Name, c.ShipGroup(3).ID, "DRIVE", 1.3),
e.GenericErrorText(e.ErrShipsBusy)) e.GenericErrorText(e.ErrShipsBusy))
} }
// TestUpgradeCostTracksShipLosses checks that the production reserved for a
// pending upgrade follows the group's CURRENT ship count. Upgrading groups now
// take part in the pre-departure battle and may lose ships, which would leave
// the cost stored at order time stale; the cost is recomputed instead.
func TestUpgradeCostTracksShipLosses(t *testing.T) {
c, _ := newCache()
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 4))
// Pending drive upgrade with a deliberately stale stored cost.
c.ShipGroup(0).StateUpgrade = &game.InUpgrade{
UpgradeTech: []game.UpgradePreference{{Tech: game.TechDrive, Level: 2.0, Cost: 9999}},
}
before := c.PlanetProductionCapacity(R0_Planet_0_num)
// Two ships lost, as in the pre-departure battle.
c.ShipGroupDestroyItem(0)
c.ShipGroupDestroyItem(0)
after := c.PlanetProductionCapacity(R0_Planet_0_num)
// Fewer ships reserve less production. With the stale stored cost this
// would be unchanged.
assert.Greater(t, after, before)
}
+2 -2
View File
@@ -46,7 +46,7 @@ type PlanetSetting struct {
MinDistanceHW uint32 MinDistanceHW uint32
MinSize float64 MinSize float64
MaxSize float64 MaxSize float64
MinResource float64 // Rules: [0.1, 20] MinResource float64 // minimum natural resources for the class
MaxResource float64 MaxResource float64
Ratio float64 // The proportion of the total number of free planets in the galaxy Ratio float64 // The proportion of the total number of free planets in the galaxy
} }
@@ -71,7 +71,7 @@ func DefaultMapSetting() MapSetting {
MinDistanceHW: 20, MinDistanceHW: 20,
MinSize: 1500, MinSize: 1500,
MaxSize: 2500, MaxSize: 2500,
MinResource: 0.1, MinResource: 0.001,
MaxResource: 3, MaxResource: 3,
Ratio: 0.06, Ratio: 0.06,
}, },
+10 -10
View File
@@ -29,8 +29,8 @@ func TestGetBattleValidation(t *testing.T) {
{"Invalid uuid", "0", invalidId, http.StatusBadRequest}, {"Invalid uuid", "0", invalidId, http.StatusBadRequest},
} { } {
t.Run(tc.description, func(t *testing.T) { t.Run(tc.description, func(t *testing.T) {
e := &dummyExecutor{} e := &fakeEngine{}
r := setupRouterExecutor(e) r := setupRouterEngine(e)
w := httptest.NewRecorder() w := httptest.NewRecorder()
path := fmt.Sprintf("/api/v1/battle/%s/%s", tc.turn, tc.battleID) path := fmt.Sprintf("/api/v1/battle/%s/%s", tc.turn, tc.battleID)
@@ -81,11 +81,11 @@ func TestGetBattleFound(t *testing.T) {
{Attacker: 0, AttackerShipClass: 10, Defender: 1, DefenderShipClass: 20, Destroyed: true}, {Attacker: 0, AttackerShipClass: 10, Defender: 1, DefenderShipClass: 20, Destroyed: true},
}, },
} }
e := &dummyExecutor{ e := &fakeEngine{
FetchBattleResult: stored, FetchBattleResult: stored,
FetchBattleOK: true, FetchBattleOK: true,
} }
r := setupRouterExecutor(e) r := setupRouterEngine(e)
w := httptest.NewRecorder() w := httptest.NewRecorder()
path := fmt.Sprintf("/api/v1/battle/%d/%s", 7, id.String()) path := fmt.Sprintf("/api/v1/battle/%d/%s", 7, id.String())
@@ -111,11 +111,11 @@ func TestGetBattleFound(t *testing.T) {
func TestGetBattleTurnZero(t *testing.T) { func TestGetBattleTurnZero(t *testing.T) {
id := uuid.New() id := uuid.New()
e := &dummyExecutor{ e := &fakeEngine{
FetchBattleResult: &report.BattleReport{ID: id}, FetchBattleResult: &report.BattleReport{ID: id},
FetchBattleOK: true, FetchBattleOK: true,
} }
r := setupRouterExecutor(e) r := setupRouterEngine(e)
w := httptest.NewRecorder() w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/battle/0/%s", id.String()), nil) req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/battle/0/%s", id.String()), nil)
@@ -128,8 +128,8 @@ func TestGetBattleTurnZero(t *testing.T) {
func TestGetBattleNotFound(t *testing.T) { func TestGetBattleNotFound(t *testing.T) {
id := uuid.New() id := uuid.New()
e := &dummyExecutor{FetchBattleOK: false} e := &fakeEngine{FetchBattleOK: false}
r := setupRouterExecutor(e) r := setupRouterEngine(e)
w := httptest.NewRecorder() w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/battle/3/%s", id.String()), nil) req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/battle/3/%s", id.String()), nil)
@@ -141,8 +141,8 @@ func TestGetBattleNotFound(t *testing.T) {
} }
func TestGetBattleEngineError(t *testing.T) { func TestGetBattleEngineError(t *testing.T) {
e := &dummyExecutor{FetchBattleErr: errors.New("engine boom")} e := &fakeEngine{FetchBattleErr: errors.New("engine boom")}
r := setupRouterExecutor(e) r := setupRouterEngine(e)
w := httptest.NewRecorder() w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/battle/3/%s", uuid.NewString()), nil) req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/battle/3/%s", uuid.NewString()), nil)
+23 -23
View File
@@ -68,8 +68,8 @@ func TestOrderRaceQuit(t *testing.T) {
payload = &rest.Command{ payload = &rest.Command{
Actor: commandDefaultActor, Actor: commandDefaultActor,
} }
exec := &dummyExecutor{} exec := &fakeEngine{}
emptyRouter := setupRouterExecutor(exec) emptyRouter := setupRouterEngine(exec)
w = httptest.NewRecorder() w = httptest.NewRecorder()
req, _ = http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload)) req, _ = http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
@@ -927,8 +927,8 @@ func TestOrderPlanetRouteRemove(t *testing.T) {
} }
func TestMultipleCommandOrder(t *testing.T) { func TestMultipleCommandOrder(t *testing.T) {
e := newExecutor() e := newFakeEngine()
r := setupRouterExecutor(e) r := setupRouterEngine(e)
payload := &rest.Command{ payload := &rest.Command{
Actor: commandDefaultActor, Actor: commandDefaultActor,
@@ -951,11 +951,11 @@ func TestMultipleCommandOrder(t *testing.T) {
assert.Equal(t, commandNoErrorsStatus, w.Code, w.Body) assert.Equal(t, commandNoErrorsStatus, w.Code, w.Body)
assert.Equal(t, 2, e.(*dummyExecutor).CommandsExecuted) assert.Equal(t, 2, e.(*fakeEngine).CommandsExecuted)
} }
func TestPutOrderResponseBody(t *testing.T) { func TestPutOrderResponseBody(t *testing.T) {
e := &dummyExecutor{ e := &fakeEngine{
ValidateOrderResult: &order.UserGamesOrder{ ValidateOrderResult: &order.UserGamesOrder{
GameID: uuid.New(), GameID: uuid.New(),
UpdatedAt: 1700, UpdatedAt: 1700,
@@ -967,7 +967,7 @@ func TestPutOrderResponseBody(t *testing.T) {
}, },
}, },
} }
r := setupRouterExecutor(e) r := setupRouterEngine(e)
payload := &rest.Command{ payload := &rest.Command{
Actor: commandDefaultActor, Actor: commandDefaultActor,
@@ -997,8 +997,8 @@ func TestPutOrderResponseBody(t *testing.T) {
} }
func TestPutOrderEngineError(t *testing.T) { func TestPutOrderEngineError(t *testing.T) {
e := &dummyExecutor{ValidateOrderErr: errors.New("engine boom")} e := &fakeEngine{ValidateOrderErr: errors.New("engine boom")}
r := setupRouterExecutor(e) r := setupRouterEngine(e)
payload := &rest.Command{ payload := &rest.Command{
Actor: commandDefaultActor, Actor: commandDefaultActor,
@@ -1054,8 +1054,8 @@ func TestPutOrderPerCommandRejection(t *testing.T) {
}, },
}, },
} }
executor := &dummyExecutor{ValidateOrderResult: result} executor := &fakeEngine{ValidateOrderResult: result}
r := setupRouterExecutor(executor) r := setupRouterEngine(executor)
payload := &rest.Command{ payload := &rest.Command{
Actor: commandDefaultActor, Actor: commandDefaultActor,
@@ -1112,8 +1112,8 @@ func TestPutOrderPerCommandRejection(t *testing.T) {
// *GenericError on the input shelf, which must map to HTTP 400 with // *GenericError on the input shelf, which must map to HTTP 400 with
// the `{"generic_error","code"}` envelope rather than 500. // the `{"generic_error","code"}` envelope rather than 500.
func TestPutOrderStructuralRejection(t *testing.T) { func TestPutOrderStructuralRejection(t *testing.T) {
executor := &dummyExecutor{ValidateOrderErr: e.NewQuitCommandFollowedByCommandError()} executor := &fakeEngine{ValidateOrderErr: e.NewQuitCommandFollowedByCommandError()}
r := setupRouterExecutor(executor) r := setupRouterEngine(executor)
payload := &rest.Command{ payload := &rest.Command{
Actor: commandDefaultActor, Actor: commandDefaultActor,
@@ -1152,8 +1152,8 @@ func TestGetOrderQueryValidation(t *testing.T) {
{"Non-numeric turn", "?player=Race_01&turn=abc", http.StatusBadRequest}, {"Non-numeric turn", "?player=Race_01&turn=abc", http.StatusBadRequest},
} { } {
t.Run(tc.description, func(t *testing.T) { t.Run(tc.description, func(t *testing.T) {
e := &dummyExecutor{} e := &fakeEngine{}
r := setupRouterExecutor(e) r := setupRouterEngine(e)
w := httptest.NewRecorder() w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, apiOrderPath+tc.query, nil) req, _ := http.NewRequest(http.MethodGet, apiOrderPath+tc.query, nil)
@@ -1176,11 +1176,11 @@ func TestGetOrderFound(t *testing.T) {
}, },
}, },
} }
e := &dummyExecutor{ e := &fakeEngine{
FetchOrderResult: stored, FetchOrderResult: stored,
FetchOrderOK: true, FetchOrderOK: true,
} }
r := setupRouterExecutor(e) r := setupRouterEngine(e)
w := httptest.NewRecorder() w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, apiOrderPath+"?player=Race_01&turn=3", nil) req, _ := http.NewRequest(http.MethodGet, apiOrderPath+"?player=Race_01&turn=3", nil)
@@ -1202,11 +1202,11 @@ func TestGetOrderFound(t *testing.T) {
} }
func TestGetOrderTurnDefaultsToZero(t *testing.T) { func TestGetOrderTurnDefaultsToZero(t *testing.T) {
e := &dummyExecutor{ e := &fakeEngine{
FetchOrderResult: &order.UserGamesOrder{GameID: uuid.New(), UpdatedAt: 1, Commands: []order.DecodableCommand{}}, FetchOrderResult: &order.UserGamesOrder{GameID: uuid.New(), UpdatedAt: 1, Commands: []order.DecodableCommand{}},
FetchOrderOK: true, FetchOrderOK: true,
} }
r := setupRouterExecutor(e) r := setupRouterEngine(e)
w := httptest.NewRecorder() w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, apiOrderPath+"?player=Race_01", nil) req, _ := http.NewRequest(http.MethodGet, apiOrderPath+"?player=Race_01", nil)
@@ -1217,8 +1217,8 @@ func TestGetOrderTurnDefaultsToZero(t *testing.T) {
} }
func TestGetOrderNotFound(t *testing.T) { func TestGetOrderNotFound(t *testing.T) {
e := &dummyExecutor{FetchOrderOK: false} e := &fakeEngine{FetchOrderOK: false}
r := setupRouterExecutor(e) r := setupRouterEngine(e)
w := httptest.NewRecorder() w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, apiOrderPath+"?player=Race_01&turn=2", nil) req, _ := http.NewRequest(http.MethodGet, apiOrderPath+"?player=Race_01&turn=2", nil)
@@ -1231,8 +1231,8 @@ func TestGetOrderNotFound(t *testing.T) {
} }
func TestGetOrderEngineError(t *testing.T) { func TestGetOrderEngineError(t *testing.T) {
e := &dummyExecutor{FetchOrderErr: errors.New("engine boom")} e := &fakeEngine{FetchOrderErr: errors.New("engine boom")}
r := setupRouterExecutor(e) r := setupRouterEngine(e)
w := httptest.NewRecorder() w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, apiOrderPath+"?player=Race_01&turn=0", nil) req, _ := http.NewRequest(http.MethodGet, apiOrderPath+"?player=Race_01&turn=0", nil)
+13 -13
View File
@@ -31,7 +31,7 @@ func id() string {
return uuid.New().String() return uuid.New().String()
} }
type dummyExecutor struct { type fakeEngine struct {
CommandsExecuted int CommandsExecuted int
// ValidateOrderResult, when non-nil, is returned from ValidateOrder. // ValidateOrderResult, when non-nil, is returned from ValidateOrder.
@@ -55,7 +55,7 @@ type dummyExecutor struct {
FetchBattleErr error FetchBattleErr error
} }
func (e *dummyExecutor) ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) { func (e *fakeEngine) ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) {
e.CommandsExecuted = len(cmd) e.CommandsExecuted = len(cmd)
if e.ValidateOrderErr != nil { if e.ValidateOrderErr != nil {
return nil, e.ValidateOrderErr return nil, e.ValidateOrderErr
@@ -70,48 +70,48 @@ func (e *dummyExecutor) ValidateOrder(actor string, cmd ...order.DecodableComman
}, nil }, nil
} }
func (e *dummyExecutor) FetchOrder(actor string, turn uint) (*order.UserGamesOrder, bool, error) { func (e *fakeEngine) FetchOrder(actor string, turn uint) (*order.UserGamesOrder, bool, error) {
e.FetchOrderActor = actor e.FetchOrderActor = actor
e.FetchOrderTurn = turn e.FetchOrderTurn = turn
return e.FetchOrderResult, e.FetchOrderOK, e.FetchOrderErr return e.FetchOrderResult, e.FetchOrderOK, e.FetchOrderErr
} }
func (e *dummyExecutor) FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, bool, error) { func (e *fakeEngine) FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, bool, error) {
e.FetchBattleTurn = turn e.FetchBattleTurn = turn
e.FetchBattleID = ID e.FetchBattleID = ID
return e.FetchBattleResult, e.FetchBattleOK, e.FetchBattleErr return e.FetchBattleResult, e.FetchBattleOK, e.FetchBattleErr
} }
func (e *dummyExecutor) GenerateGame(gameID uuid.UUID, races []string) (game.State, error) { func (e *fakeEngine) GenerateGame(gameID uuid.UUID, races []string) (game.State, error) {
return game.State{ID: gameID}, nil return game.State{ID: gameID}, nil
} }
func (e *dummyExecutor) GenerateTurn() (game.State, error) { func (e *fakeEngine) GenerateTurn() (game.State, error) {
return game.State{}, nil return game.State{}, nil
} }
func (e *dummyExecutor) BanishRace(raceName string) error { func (e *fakeEngine) BanishRace(raceName string) error {
return nil return nil
} }
func (e *dummyExecutor) GameState() (game.State, error) { func (e *fakeEngine) GameState() (game.State, error) {
return game.State{}, nil return game.State{}, nil
} }
func (e *dummyExecutor) LoadReport(actor string, turn uint) (*report.Report, error) { func (e *fakeEngine) LoadReport(actor string, turn uint) (*report.Report, error) {
return &report.Report{}, nil return &report.Report{}, nil
} }
func setupRouter() *gin.Engine { func setupRouter() *gin.Engine {
return setupRouterExecutor(newExecutor()) return setupRouterEngine(newFakeEngine())
} }
func setupRouterExecutor(e handler.Engine) *gin.Engine { func setupRouterEngine(e handler.Engine) *gin.Engine {
return router.SetupRouter(e) return router.SetupRouter(e)
} }
func newExecutor() handler.Engine { func newFakeEngine() handler.Engine {
return &dummyExecutor{} return &fakeEngine{}
} }
// newService builds a real controller.Service backed by a storage directory, // newService builds a real controller.Service backed by a storage directory,
+9 -6
View File
@@ -436,21 +436,24 @@ components:
CommandRequest: CommandRequest:
type: object type: object
description: | description: |
Batch command payload. `actor` identifies the race submitting the commands. Order payload for `PUT /api/v1/order`. `actor` identifies the race
Each element of `cmd` is a polymorphic command object discriminated by the submitting the order. Each element of `cmd` is a polymorphic command
`@type` field. At least one command is required. object discriminated by the `@type` field. An empty `cmd` array is
valid: it clears the player's stored order for the turn (equivalent to
removing every command).
required: required:
- actor - actor
- cmd - cmd
properties: properties:
actor: actor:
type: string type: string
description: Race name of the actor submitting the commands. Must be non-blank. description: Race name of the actor submitting the order. Must be non-blank.
minLength: 1 minLength: 1
cmd: cmd:
type: array type: array
description: One or more commands to execute in order. description: >-
minItems: 1 The player's commands, applied in submission order at turn
generation. May be empty to clear the stored order.
items: items:
$ref: "#/components/schemas/Command" $ref: "#/components/schemas/Command"
UserGamesOrder: UserGamesOrder:
+2 -1
View File
@@ -275,7 +275,8 @@ func TestGameOpenAPISpecFreezesCommandRequest(t *testing.T) {
cmdSchema := schema.Value.Properties["cmd"] cmdSchema := schema.Value.Properties["cmd"]
require.NotNil(t, cmdSchema, "CommandRequest.cmd schema must exist") require.NotNil(t, cmdSchema, "CommandRequest.cmd schema must exist")
require.Equal(t, uint64(1), cmdSchema.Value.MinItems, "CommandRequest.cmd minItems must be 1") require.Zero(t, cmdSchema.Value.MinItems,
"CommandRequest.cmd must allow an empty batch — an empty order clears the player's stored order")
} }
func TestGameOpenAPISpecFreezesGetBattleOperation(t *testing.T) { func TestGameOpenAPISpecFreezesGetBattleOperation(t *testing.T) {
-1448
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -178,6 +178,30 @@ bootstrap or asset traffic through a pluggable public handler or proxy.
That traffic belongs to dedicated public route classes and must not share rate That traffic belongs to dedicated public route classes and must not share rate
limit buckets or abuse counters with the public auth API. limit buckets or abuse counters with the public auth API.
### Operator Console Proxy (`/_gm`)
The gateway also fronts the backend operator console. The edge Caddy routes
`/_gm` and `/_gm/*` to this public listener; the gateway classifies that
traffic as the `admin` public route class and reverse-proxies it to the
backend at `GATEWAY_BACKEND_HTTP_URL`, preserving the request path and the
inbound `Host` header (so the backend's same-origin CSRF check observes the
public host).
Authentication is delegated entirely to the backend (HTTP Basic Auth against
`admin_accounts`): the backend's `401` challenge is relayed unchanged so the
browser shows its native credential dialog. The gateway contributes only the
edge anti-abuse layer — a per-IP rate limit, a body size limit, and a
`GET`/`HEAD`/`POST` method allow-list for the class — and answers
`502 bad_gateway` when the backend is unreachable.
The `admin` class carries its own budgets, isolated from the other public
classes:
- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_MAX_BODY_BYTES` (default `65536`);
- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_REQUESTS` (default `120`);
- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_WINDOW` (default `1m`);
- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_BURST` (default `40`).
### Operational Admin Surface ### Operational Admin Surface
The gateway may expose one private operational HTTP listener used for metrics. The gateway may expose one private operational HTTP listener used for metrics.
+9
View File
@@ -78,6 +78,15 @@ func run(ctx context.Context) (err error) {
AuthService: authServiceAdapter{rest: backend.REST()}, AuthService: authServiceAdapter{rest: backend.REST()},
} }
adminConsoleProxy, err := restapi.NewBackendConsoleProxy(cfg.Backend.HTTPBaseURL, logger)
if err != nil {
_ = backend.Close()
_ = telemetryRuntime.Shutdown(context.Background())
_ = logging.Sync(logger)
return fmt.Errorf("build admin console proxy: %w", err)
}
publicRESTDeps.AdminConsoleProxy = adminConsoleProxy
grpcDeps, components, cleanup, err := newAuthenticatedGRPCDependencies(ctx, cfg, logger, telemetryRuntime, backend) grpcDeps, components, cleanup, err := newAuthenticatedGRPCDependencies(ctx, cfg, logger, telemetryRuntime, backend)
if err != nil { if err != nil {
_ = backend.Close() _ = backend.Close()
+1 -1
View File
@@ -11,7 +11,7 @@ require (
github.com/alicebob/miniredis/v2 v2.37.0 github.com/alicebob/miniredis/v2 v2.37.0
github.com/getkin/kin-openapi v0.135.0 github.com/getkin/kin-openapi v0.135.0
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/google/flatbuffers v25.12.19+incompatible github.com/google/flatbuffers v25.9.23+incompatible
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.18.0 github.com/redis/go-redis/v9 v9.18.0
+2 -2
View File
@@ -73,8 +73,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo=
github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw=
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs= github.com/google/flatbuffers v25.9.23+incompatible h1:rGZKv+wOb6QPzIdkM2KxhBZCDrA0DeN6DNmRDrqIsQU=
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/flatbuffers v25.9.23+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+52
View File
@@ -276,6 +276,22 @@ const (
// configures the public_misc rate-limit burst. // configures the public_misc rate-limit burst.
publicMiscRateLimitBurstEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_RATE_LIMIT_BURST" publicMiscRateLimitBurstEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_RATE_LIMIT_BURST"
// adminMaxBodyBytesEnvVar names the environment variable that configures
// the maximum accepted request body size for the admin console class.
adminMaxBodyBytesEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_MAX_BODY_BYTES"
// adminRateLimitRequestsEnvVar names the environment variable that
// configures the admin console request budget per window.
adminRateLimitRequestsEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_REQUESTS"
// adminRateLimitWindowEnvVar names the environment variable that configures
// the admin console rate-limit window.
adminRateLimitWindowEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_WINDOW"
// adminRateLimitBurstEnvVar names the environment variable that configures
// the admin console rate-limit burst.
adminRateLimitBurstEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_BURST"
// sendEmailCodeIdentityRateLimitRequestsEnvVar names the environment // sendEmailCodeIdentityRateLimitRequestsEnvVar names the environment
// variable that configures the send-email-code identity request budget per // variable that configures the send-email-code identity request budget per
// window. // window.
@@ -372,6 +388,14 @@ const (
defaultPublicMiscRateLimitRequests = 30 defaultPublicMiscRateLimitRequests = 30
defaultPublicMiscRateLimitBurst = 10 defaultPublicMiscRateLimitBurst = 10
// Admin console class: sized for a human operator clicking through pages
// and submitting forms, while still throttling Basic Auth brute-force at
// the edge. The body budget accommodates form posts.
defaultAdminMaxBodyBytes = int64(65536)
defaultAdminRateLimitRequests = 120
defaultAdminRateLimitBurst = 40
defaultSendEmailCodeIdentityRateLimitRequests = 3 defaultSendEmailCodeIdentityRateLimitRequests = 3
defaultSendEmailCodeIdentityRateLimitBurst = 1 defaultSendEmailCodeIdentityRateLimitBurst = 1
@@ -439,6 +463,11 @@ type PublicHTTPAntiAbuseConfig struct {
// PublicMisc applies to the stable public_misc route class. // PublicMisc applies to the stable public_misc route class.
PublicMisc PublicRoutePolicyConfig PublicMisc PublicRoutePolicyConfig
// Admin applies to the stable admin route class — the `/_gm` operator
// console reverse-proxied to the backend. Only per-IP limiting applies;
// the class carries no identity buckets.
Admin PublicRoutePolicyConfig
// SendEmailCodeIdentity applies the additional identity limiter for // SendEmailCodeIdentity applies the additional identity limiter for
// send-email-code. // send-email-code.
SendEmailCodeIdentity PublicAuthIdentityPolicyConfig SendEmailCodeIdentity PublicAuthIdentityPolicyConfig
@@ -708,6 +737,14 @@ func DefaultPublicHTTPConfig() PublicHTTPConfig {
Burst: defaultPublicMiscRateLimitBurst, Burst: defaultPublicMiscRateLimitBurst,
}, },
}, },
Admin: PublicRoutePolicyConfig{
MaxBodyBytes: defaultAdminMaxBodyBytes,
RateLimit: PublicRateLimitConfig{
Requests: defaultAdminRateLimitRequests,
Window: defaultClassRateLimitWindow,
Burst: defaultAdminRateLimitBurst,
},
},
SendEmailCodeIdentity: PublicAuthIdentityPolicyConfig{ SendEmailCodeIdentity: PublicAuthIdentityPolicyConfig{
RateLimit: PublicRateLimitConfig{ RateLimit: PublicRateLimitConfig{
Requests: defaultSendEmailCodeIdentityRateLimitRequests, Requests: defaultSendEmailCodeIdentityRateLimitRequests,
@@ -1092,6 +1129,18 @@ func LoadFromEnv() (Config, error) {
} }
cfg.PublicHTTP.AntiAbuse.PublicMisc = publicMiscPolicy cfg.PublicHTTP.AntiAbuse.PublicMisc = publicMiscPolicy
adminPolicy, err := loadPublicRoutePolicyConfigFromEnv(
cfg.PublicHTTP.AntiAbuse.Admin,
adminMaxBodyBytesEnvVar,
adminRateLimitRequestsEnvVar,
adminRateLimitWindowEnvVar,
adminRateLimitBurstEnvVar,
)
if err != nil {
return Config{}, err
}
cfg.PublicHTTP.AntiAbuse.Admin = adminPolicy
sendIdentityPolicy, err := loadPublicAuthIdentityPolicyConfigFromEnv( sendIdentityPolicy, err := loadPublicAuthIdentityPolicyConfigFromEnv(
cfg.PublicHTTP.AntiAbuse.SendEmailCodeIdentity, cfg.PublicHTTP.AntiAbuse.SendEmailCodeIdentity,
sendEmailCodeIdentityRateLimitRequestsEnvVar, sendEmailCodeIdentityRateLimitRequestsEnvVar,
@@ -1247,6 +1296,9 @@ func LoadFromEnv() (Config, error) {
if err := validatePublicRoutePolicyConfig(cfg.PublicHTTP.AntiAbuse.PublicMisc, publicMiscMaxBodyBytesEnvVar, publicMiscRateLimitRequestsEnvVar, publicMiscRateLimitWindowEnvVar, publicMiscRateLimitBurstEnvVar); err != nil { if err := validatePublicRoutePolicyConfig(cfg.PublicHTTP.AntiAbuse.PublicMisc, publicMiscMaxBodyBytesEnvVar, publicMiscRateLimitRequestsEnvVar, publicMiscRateLimitWindowEnvVar, publicMiscRateLimitBurstEnvVar); err != nil {
return Config{}, err return Config{}, err
} }
if err := validatePublicRoutePolicyConfig(cfg.PublicHTTP.AntiAbuse.Admin, adminMaxBodyBytesEnvVar, adminRateLimitRequestsEnvVar, adminRateLimitWindowEnvVar, adminRateLimitBurstEnvVar); err != nil {
return Config{}, err
}
if err := validatePublicAuthIdentityPolicyConfig(cfg.PublicHTTP.AntiAbuse.SendEmailCodeIdentity, sendEmailCodeIdentityRateLimitRequestsEnvVar, sendEmailCodeIdentityRateLimitWindowEnvVar, sendEmailCodeIdentityRateLimitBurstEnvVar); err != nil { if err := validatePublicAuthIdentityPolicyConfig(cfg.PublicHTTP.AntiAbuse.SendEmailCodeIdentity, sendEmailCodeIdentityRateLimitRequestsEnvVar, sendEmailCodeIdentityRateLimitWindowEnvVar, sendEmailCodeIdentityRateLimitBurstEnvVar); err != nil {
return Config{}, err return Config{}, err
} }
+49
View File
@@ -0,0 +1,49 @@
package restapi
import (
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"go.uber.org/zap"
)
// NewBackendConsoleProxy builds the reverse proxy that forwards operator
// console traffic (`/_gm` and `/_gm/*`) to the backend at backendBaseURL.
//
// The proxy is intentionally thin: it preserves the inbound request path and
// the inbound Host header — the latter so the backend's same-origin CSRF check
// observes the public host rather than the internal upstream — and relays the
// backend response unchanged, including its 401 Basic Auth challenge. It
// answers 502 when the backend is unreachable. Authentication, rendering, and
// every state change live in the backend; the gateway contributes only the
// public anti-abuse layer that runs ahead of this handler.
func NewBackendConsoleProxy(backendBaseURL string, logger *zap.Logger) (http.Handler, error) {
target, err := url.Parse(backendBaseURL)
if err != nil {
return nil, fmt.Errorf("parse backend base URL %q: %w", backendBaseURL, err)
}
if target.Scheme == "" || target.Host == "" {
return nil, fmt.Errorf("backend base URL %q must be absolute", backendBaseURL)
}
if logger == nil {
logger = zap.NewNop()
}
logger = logger.Named("admin_console_proxy")
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(target)
// SetURL clears Out.Host so the target host is used; restore the
// inbound Host so the backend sees the public origin.
pr.Out.Host = pr.In.Host
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
logger.Warn("admin console upstream error",
zap.String("path", r.URL.Path), zap.Error(err))
w.WriteHeader(http.StatusBadGateway)
},
}, nil
}
@@ -0,0 +1,198 @@
package restapi
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"galaxy/gateway/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// proxyRequest builds a test request whose context carries a cancellation
// signal. A real http.Server always supplies one; httptest.NewRequest does not,
// and without it httputil.ReverseProxy falls back to the legacy CloseNotifier
// path, which panics under gin's ResponseWriter wrapping an
// httptest.ResponseRecorder. Cancelling at test cleanup keeps the context live
// for the synchronous ServeHTTP call.
func proxyRequest(t *testing.T, method, target string, body io.Reader) *http.Request {
t.Helper()
req := httptest.NewRequest(method, target, body)
ctx, cancel := context.WithCancel(req.Context())
t.Cleanup(cancel)
return req.WithContext(ctx)
}
func TestAdminConsoleProxyForwardsToBackend(t *testing.T) {
var gotPath, gotHost, gotAuth string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotHost = r.Host
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte("<h1>Dashboard</h1>"))
}))
defer backend.Close()
proxy, err := NewBackendConsoleProxy(backend.URL, nil)
require.NoError(t, err)
handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{AdminConsoleProxy: proxy})
req := proxyRequest(t, http.MethodGet, "http://galaxy.lan/_gm/", nil)
req.SetBasicAuth("ops", "secret")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "Dashboard")
assert.Equal(t, "/_gm/", gotPath)
assert.Equal(t, "galaxy.lan", gotHost, "inbound Host must be preserved for same-origin CSRF checks")
assert.True(t, strings.HasPrefix(gotAuth, "Basic "), "Authorization header must be forwarded to the backend")
}
func TestAdminConsoleProxyForwardsFormPost(t *testing.T) {
var gotPath, gotBody, gotContentType string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotContentType = r.Header.Get("Content-Type")
body, _ := io.ReadAll(r.Body)
gotBody = string(body)
w.WriteHeader(http.StatusSeeOther)
}))
defer backend.Close()
proxy, err := NewBackendConsoleProxy(backend.URL, nil)
require.NoError(t, err)
handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{AdminConsoleProxy: proxy})
const form = "_csrf=token&reason=spam"
req := proxyRequest(t, http.MethodPost, "http://galaxy.lan/_gm/users/1/sanctions", strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth("ops", "secret")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusSeeOther, rec.Code)
assert.Equal(t, "/_gm/users/1/sanctions", gotPath)
assert.Equal(t, form, gotBody, "request body must reach the backend intact through the anti-abuse buffer")
assert.Contains(t, gotContentType, "x-www-form-urlencoded")
}
func TestAdminConsoleProxyRelaysAuthChallenge(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("WWW-Authenticate", `Basic realm="galaxy-admin"`)
w.WriteHeader(http.StatusUnauthorized)
}))
defer backend.Close()
proxy, err := NewBackendConsoleProxy(backend.URL, nil)
require.NoError(t, err)
handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{AdminConsoleProxy: proxy})
req := proxyRequest(t, http.MethodGet, "http://galaxy.lan/_gm/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusUnauthorized, rec.Code)
assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Basic")
}
func TestAdminConsoleProxyRejectsDisallowedMethod(t *testing.T) {
var hits int32
backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
atomic.AddInt32(&hits, 1)
}))
defer backend.Close()
proxy, err := NewBackendConsoleProxy(backend.URL, nil)
require.NoError(t, err)
handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{AdminConsoleProxy: proxy})
req := proxyRequest(t, http.MethodDelete, "http://galaxy.lan/_gm/users/1", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
assert.Equal(t, int32(0), atomic.LoadInt32(&hits), "backend must not be reached for a rejected method")
}
func TestAdminConsoleProxyRejectsOversizedBody(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer backend.Close()
proxy, err := NewBackendConsoleProxy(backend.URL, nil)
require.NoError(t, err)
cfg := config.DefaultPublicHTTPConfig()
cfg.AntiAbuse.Admin.MaxBodyBytes = 8
handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AdminConsoleProxy: proxy})
req := proxyRequest(t, http.MethodPost, "http://galaxy.lan/_gm/users/1/sanctions",
strings.NewReader("this body is well beyond eight bytes"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
}
func TestAdminConsoleProxyRateLimitsPerIP(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()
proxy, err := NewBackendConsoleProxy(backend.URL, nil)
require.NoError(t, err)
cfg := config.DefaultPublicHTTPConfig()
cfg.AntiAbuse.Admin.RateLimit = config.PublicRateLimitConfig{Requests: 1, Window: time.Minute, Burst: 1}
handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AdminConsoleProxy: proxy})
do := func() int {
req := proxyRequest(t, http.MethodGet, "http://galaxy.lan/_gm/", nil)
req.RemoteAddr = "203.0.113.7:5555"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec.Code
}
assert.Equal(t, http.StatusOK, do(), "first request within budget")
assert.Equal(t, http.StatusTooManyRequests, do(), "second request exhausts the per-IP admin budget")
}
func TestAdminConsoleProxyReturns502WhenBackendUnreachable(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
backendURL := backend.URL
backend.Close() // close immediately so the next dial is refused
proxy, err := NewBackendConsoleProxy(backendURL, nil)
require.NoError(t, err)
handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{AdminConsoleProxy: proxy})
req := proxyRequest(t, http.MethodGet, "http://galaxy.lan/_gm/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadGateway, rec.Code)
}
func TestAdminConsoleNotMountedWhenProxyNil(t *testing.T) {
handler := newPublicHandler(ServerDependencies{})
req := proxyRequest(t, http.MethodGet, "http://galaxy.lan/_gm/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusNotFound, rec.Code)
}
func TestNewBackendConsoleProxyRejectsRelativeURL(t *testing.T) {
_, err := NewBackendConsoleProxy("/not-absolute", nil)
assert.Error(t, err)
}
@@ -234,6 +234,8 @@ func publicRoutePolicyForClass(policy config.PublicHTTPAntiAbuseConfig, class Pu
return policy.BrowserBootstrap return policy.BrowserBootstrap
case PublicRouteClassBrowserAsset: case PublicRouteClassBrowserAsset:
return policy.BrowserAsset return policy.BrowserAsset
case PublicRouteClassAdmin:
return policy.Admin
default: default:
return policy.PublicMisc return policy.PublicMisc
} }
@@ -252,6 +254,8 @@ func publicAuthIdentityPolicyForPath(requestPath string, policy config.PublicHTT
func allowedMethodsForRequestShape(r *http.Request) []string { func allowedMethodsForRequestShape(r *http.Request) []string {
switch { switch {
case isAdminConsolePath(r.URL.Path):
return []string{http.MethodGet, http.MethodHead, http.MethodPost}
case isPublicAuthPath(r.URL.Path): case isPublicAuthPath(r.URL.Path):
return []string{http.MethodPost} return []string{http.MethodPost}
case isProbePath(r.URL.Path): case isProbePath(r.URL.Path):
@@ -284,6 +288,17 @@ func isPublicAuthPath(requestPath string) bool {
} }
} }
// isAdminConsoleRequest reports whether r targets the operator console surface.
func isAdminConsoleRequest(r *http.Request) bool {
return isAdminConsolePath(r.URL.Path)
}
// isAdminConsolePath reports whether requestPath is the admin console root
// (`/_gm`) or any path beneath it (`/_gm/...`).
func isAdminConsolePath(requestPath string) bool {
return requestPath == "/_gm" || strings.HasPrefix(requestPath, "/_gm/")
}
func isProbePath(requestPath string) bool { func isProbePath(requestPath string) bool {
switch requestPath { switch requestPath {
case "/healthz", "/readyz": case "/healthz", "/readyz":
+21
View File
@@ -48,6 +48,10 @@ const (
// PublicRouteClassPublicMisc identifies public traffic that does not match a // PublicRouteClassPublicMisc identifies public traffic that does not match a
// more specific class. // more specific class.
PublicRouteClassPublicMisc PublicRouteClass = "public_misc" PublicRouteClassPublicMisc PublicRouteClass = "public_misc"
// PublicRouteClassAdmin identifies operator console traffic reverse-proxied
// to the backend under the `/_gm` prefix.
PublicRouteClassAdmin PublicRouteClass = "admin"
) )
var configureGinModeOnce sync.Once var configureGinModeOnce sync.Once
@@ -60,6 +64,7 @@ func (c PublicRouteClass) Normalized() PublicRouteClass {
case PublicRouteClassPublicAuth, case PublicRouteClassPublicAuth,
PublicRouteClassBrowserBootstrap, PublicRouteClassBrowserBootstrap,
PublicRouteClassBrowserAsset, PublicRouteClassBrowserAsset,
PublicRouteClassAdmin,
PublicRouteClassPublicMisc: PublicRouteClassPublicMisc:
return c return c
default: default:
@@ -110,6 +115,14 @@ type ServerDependencies struct {
// Telemetry records low-cardinality edge metrics. When nil, metrics are // Telemetry records low-cardinality edge metrics. When nil, metrics are
// disabled. // disabled.
Telemetry *telemetry.Runtime Telemetry *telemetry.Runtime
// AdminConsoleProxy, when non-nil, handles `/_gm` and `/_gm/*` by
// reverse-proxying to the backend operator console after the public
// anti-abuse layer (per-IP rate limit, body, and method checks for the
// admin route class) has run. Authentication is delegated to the
// backend's admin Basic Auth, whose 401 challenge passes straight back
// to the browser. When nil, the admin console surface is not mounted.
AdminConsoleProxy http.Handler
} }
// Server owns the public unauthenticated REST listener exposed by the gateway. // Server owns the public unauthenticated REST listener exposed by the gateway.
@@ -229,6 +242,8 @@ type defaultPublicTrafficClassifier struct{}
// later drive anti-abuse policy and rate limiting. // later drive anti-abuse policy and rate limiting.
func (defaultPublicTrafficClassifier) Classify(r *http.Request) PublicRouteClass { func (defaultPublicTrafficClassifier) Classify(r *http.Request) PublicRouteClass {
switch { switch {
case isAdminConsoleRequest(r):
return PublicRouteClassAdmin
case isPublicAuthRequest(r): case isPublicAuthRequest(r):
return PublicRouteClassPublicAuth return PublicRouteClassPublicAuth
case isBrowserBootstrapRequest(r): case isBrowserBootstrapRequest(r):
@@ -290,6 +305,12 @@ func newPublicHandlerWithConfig(cfg config.PublicHTTPConfig, deps ServerDependen
router.POST("/api/v1/public/auth/send-email-code", handleSendEmailCode(deps.AuthService, cfg.AuthUpstreamTimeout)) router.POST("/api/v1/public/auth/send-email-code", handleSendEmailCode(deps.AuthService, cfg.AuthUpstreamTimeout))
router.POST("/api/v1/public/auth/confirm-email-code", handleConfirmEmailCode(deps.AuthService, cfg.AuthUpstreamTimeout)) router.POST("/api/v1/public/auth/confirm-email-code", handleConfirmEmailCode(deps.AuthService, cfg.AuthUpstreamTimeout))
if deps.AdminConsoleProxy != nil {
adminConsole := gin.WrapH(deps.AdminConsoleProxy)
router.Any("/_gm", adminConsole)
router.Any("/_gm/*proxyPath", adminConsole)
}
router.NoMethod(func(c *gin.Context) { router.NoMethod(func(c *gin.Context) {
allowMethods := allowedMethodsForPath(c.Request.URL.Path) allowMethods := allowedMethodsForPath(c.Request.URL.Path)
if allowMethods != "" { if allowMethods != "" {
+30
View File
@@ -169,6 +169,31 @@ func TestDefaultPublicTrafficClassifier(t *testing.T) {
accept: "text/html", accept: "text/html",
wantClass: PublicRouteClassBrowserBootstrap, wantClass: PublicRouteClassBrowserBootstrap,
}, },
{
name: "admin console root",
method: http.MethodGet,
target: "/_gm",
wantClass: PublicRouteClassAdmin,
},
{
name: "admin console page wins over browser accept header",
method: http.MethodGet,
target: "/_gm/users",
accept: "text/html",
wantClass: PublicRouteClassAdmin,
},
{
name: "admin console asset wins over browser asset shape",
method: http.MethodGet,
target: "/_gm/assets/console.css",
wantClass: PublicRouteClassAdmin,
},
{
name: "admin console form post",
method: http.MethodPost,
target: "/_gm/users/123/sanctions",
wantClass: PublicRouteClassAdmin,
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -215,6 +240,11 @@ func TestPublicRouteClassNormalized(t *testing.T) {
input: PublicRouteClassPublicMisc, input: PublicRouteClassPublicMisc,
want: PublicRouteClassPublicMisc, want: PublicRouteClassPublicMisc,
}, },
{
name: "admin",
input: PublicRouteClassAdmin,
want: PublicRouteClassAdmin,
},
{ {
name: "unknown collapses to misc", name: "unknown collapses to misc",
input: PublicRouteClass("unexpected"), input: PublicRouteClass("unexpected"),
+63
View File
@@ -0,0 +1,63 @@
package integration_test
import (
"context"
"io"
"net/http"
"strings"
"testing"
"time"
"galaxy/integration/testenv"
)
// TestAdminConsole_ThroughGateway verifies the full operator-console pipe:
// the edge gateway reverse-proxies `/_gm/*` to the backend, the backend
// enforces the admin Basic Auth and relays its 401 challenge unchanged, and an
// authenticated request renders the server-side dashboard.
func TestAdminConsole_ThroughGateway(t *testing.T) {
plat := testenv.Bootstrap(t, testenv.BootstrapOptions{})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
base := plat.Gateway.HTTPURL
client := &http.Client{Timeout: 10 * time.Second}
// Unauthenticated: the backend's 401 + Basic challenge must reach the client
// through the gateway.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/_gm/", nil)
if err != nil {
t.Fatalf("build request: %v", err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("GET /_gm/ (no auth): %v", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("no-auth status = %d, want 401; body=%s", resp.StatusCode, body)
}
if !strings.Contains(resp.Header.Get("WWW-Authenticate"), "Basic") {
t.Fatalf("WWW-Authenticate = %q, want a Basic challenge", resp.Header.Get("WWW-Authenticate"))
}
// Authenticated with the bootstrap operator: the dashboard renders.
req, err = http.NewRequestWithContext(ctx, http.MethodGet, base+"/_gm/", nil)
if err != nil {
t.Fatalf("build request: %v", err)
}
req.SetBasicAuth(plat.Backend.AdminUser, plat.Backend.AdminPassword)
resp, err = client.Do(req)
if err != nil {
t.Fatalf("GET /_gm/ (auth): %v", err)
}
body, _ = io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("authenticated status = %d, want 200; body=%s", resp.StatusCode, body)
}
if !strings.Contains(string(body), "Dashboard") {
t.Fatalf("dashboard body missing the heading; got: %s", body)
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ require (
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-ole/go-ole v1.2.6 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect github.com/google/flatbuffers v25.9.23+incompatible // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/compress v1.18.5 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
+2 -2
View File
@@ -47,8 +47,8 @@ github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs= github.com/google/flatbuffers v25.9.23+incompatible h1:rGZKv+wOb6QPzIdkM2KxhBZCDrA0DeN6DNmRDrqIsQU=
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/flatbuffers v25.9.23+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+1 -1
View File
@@ -15,7 +15,7 @@ func ValidateShipTypeValues(d float64, a int, w, s, c float64) error {
return e.NewShieldsValueError(s) return e.NewShieldsValueError(s)
} }
if !CheckShipTypeValueDWSC(c) { if !CheckShipTypeValueDWSC(c) {
return e.NewCargoValueError(s) return e.NewCargoValueError(c)
} }
if a < 0 { if a < 0 {
return e.NewShipTypeArmamentValueError(a) return e.NewShipTypeArmamentValueError(a)
+14
View File
@@ -47,6 +47,13 @@ type Report struct {
OtherGroup []OtherGroup `json:"otherGroup,omitempty"` OtherGroup []OtherGroup `json:"otherGroup,omitempty"`
UnidentifiedGroup []UnidentifiedGroup `json:"unidentifiedGroup,omitempty"` UnidentifiedGroup []UnidentifiedGroup `json:"unidentifiedGroup,omitempty"`
// Race exit warnings. PersonalExitWarning is the recipient race's own
// number of turns remaining before auto-removal for inactivity (set when
// it is 1..5, otherwise 0). RacesLeavingSoon lists other races within 3
// turns of removal and is shown to every recipient.
PersonalExitWarning uint `json:"personalExitWarning,omitempty"`
RacesLeavingSoon []RaceExitNotice `json:"racesLeavingSoon,omitempty"`
OnPlanetGroupCache map[uint][]int `json:"-"` OnPlanetGroupCache map[uint][]int `json:"-"`
InSpaceGroupRangeCache map[int]map[uint]float64 `json:"-"` InSpaceGroupRangeCache map[int]map[uint]float64 `json:"-"`
} }
@@ -71,6 +78,13 @@ type Player struct {
Extinct bool `json:"extinct"` Extinct bool `json:"extinct"`
} }
// RaceExitNotice is a public notice that a race is within a few turns of being
// auto-removed for inactivity; TurnsLeft is the number of turns until removal.
type RaceExitNotice struct {
Race string `json:"race"`
TurnsLeft uint `json:"turnsLeft"`
}
func (r Report) MarshalBinary() (data []byte, err error) { func (r Report) MarshalBinary() (data []byte, err error) {
return json.Marshal(&r) return json.Marshal(&r)
} }
+26
View File
@@ -0,0 +1,26 @@
.PHONY: help fbs-go flatc-check
# Pinned flatc version. The committed Go bindings under <schema>/ and the
# flatbuffers Go runtime (go.mod) are on this version; the TS side mirrors
# it via `make -C ui fbs-ts`. Generating with a different flatc silently
# churns output and can flip nullable-scalar wire defaults, so fbs-go
# refuses to run on a mismatch. Distro packages (e.g. Debian's
# flatbuffers-compiler) are too old — use the release binary at $(FLATC_URL).
REQUIRED_FLATC := 25.9.23
FLATC_URL := https://github.com/google/flatbuffers/releases/tag/v$(REQUIRED_FLATC)
# Every schema in this directory. Listed explicitly (not a wildcard) so a
# stray .fbs cannot silently change the generated set.
FBS_INPUTS := common.fbs lobby.fbs user.fbs report.fbs order.fbs diplomail.fbs battle.fbs gateway.fbs notification.fbs
help:
@echo "schema/fbs targets:"
@echo " fbs-go FlatBuffers Go generation from *.fbs into <schema>/ (requires flatc $(REQUIRED_FLATC))"
flatc-check:
@command -v flatc >/dev/null || { echo "flatc not found; install flatc $(REQUIRED_FLATC) from $(FLATC_URL) (distro packages such as Debian's flatbuffers-compiler are too old)"; exit 1; }
@flatc --version | grep -q "$(REQUIRED_FLATC)" || { echo "flatc $(REQUIRED_FLATC) required; found '$$(flatc --version)'. Get the pinned binary from $(FLATC_URL)"; exit 1; }
# --go-module-name rewrites cross-namespace imports to the fully-qualified
# module path so the generated code links without local replace directives.
fbs-go: flatc-check
flatc --go --go-module-name galaxy/schema/fbs $(FBS_INPUTS)
+25 -2
View File
@@ -1,9 +1,32 @@
# Flatbuffers schemas # Flatbuffers schemas
## Pinned flatc version
The committed bindings — Go under `<schema>/` and TS under
`ui/frontend/src/proto/galaxy/fbs/` — and the flatbuffers runtimes
(`github.com/google/flatbuffers` in the Go modules, `flatbuffers` in
`ui/frontend/package.json`) are all on **flatc 25.9.23**. Regenerate
only with that exact version: a different flatc silently churns output
and can flip nullable-scalar wire defaults (`= null` builder slots
switch between the presence-preserving `null` form and the value-omitting
`0` form). The `fbs-go` / `fbs-ts` targets refuse to run on a mismatch,
and the `fbs-codegen` CI workflow fails if the committed bindings differ
from a pinned-flatc regeneration.
Distro packages are too old (e.g. Debian trixie ships `flatbuffers-compiler`
23.5.26). Install the pinned binary from the release page:
<https://github.com/google/flatbuffers/releases/tag/v25.9.23>.
## Generating sources ## Generating sources
Given a `.fbs` file, source code can be generated using `flatc` from Regenerate both sides with the pinned `flatc` on `PATH`:
this directory:
```shell
make -C pkg/schema/fbs fbs-go # Go bindings into pkg/schema/fbs/<schema>/
make -C ui fbs-ts # TS bindings into ui/frontend/src/proto/galaxy/fbs/
```
`fbs-go` runs, for every schema in this directory:
```shell ```shell
flatc --go --go-module-name galaxy/schema/fbs {file}.fbs flatc --go --go-module-name galaxy/schema/fbs {file}.fbs

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