Compare commits

...

24 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
103 changed files with 7289 additions and 2349 deletions
+40 -5
View File
@@ -148,12 +148,37 @@ jobs:
-v "${{ gitea.workspace }}/pkg/geoip/test-data/test-data:/src:ro" \
alpine sh -c 'cp /src/GeoIP2-Country-Test.mmdb /dst/geoip.mmdb'
- name: Seed mailpit relay config
env:
GALAXY_DEV_MAIL_RELAY_USERNAME: ${{ secrets.GALAXY_DEV_MAIL_RELAY_USERNAME }}
GALAXY_DEV_MAIL_RELAY_PASSWORD: ${{ secrets.GALAXY_DEV_MAIL_RELAY_PASSWORD }}
run: |
# Render the Mailpit relay upstream config from the template,
# substituting the Gmail App Password from a Gitea secret, then
# seed it into a named volume (same rationale as the geoip seed:
# a workspace bind-mount would vanish with the runner workspace).
# The secret never lands in git or a committed file; it is
# rendered to a tmpfile outside the repo and removed after. Gmail
# App Passwords are [a-z]{16}, so the `|` sed delimiter is safe.
# When the secret is unset the creds render empty and the compose
# default relay-match is non-routable, so the stack only captures.
rendered="$(mktemp)"
sed -e "s|\${GALAXY_DEV_MAIL_RELAY_USERNAME}|${GALAXY_DEV_MAIL_RELAY_USERNAME}|g" \
-e "s|\${GALAXY_DEV_MAIL_RELAY_PASSWORD}|${GALAXY_DEV_MAIL_RELAY_PASSWORD}|g" \
"${{ gitea.workspace }}/tools/dev-deploy/mailpit/relay.conf.tmpl" > "$rendered"
docker volume create galaxy-dev-mailpit-config >/dev/null
docker run --rm \
-v galaxy-dev-mailpit-config:/dst \
-v "$rendered:/src/relay.conf:ro" \
alpine sh -c 'cp /src/relay.conf /dst/relay.conf && chmod 600 /dst/relay.conf'
rm -f "$rendered"
- name: Recycle engine containers on image drift
run: |
# Compare the freshly-built `galaxy-engine:dev` SHA against
# every running `galaxy-game-*` container. The backend
# reconciler adopts pre-existing labelled engine containers
# without checking image drift, so a running sandbox would
# without checking image drift, so a running game would
# otherwise keep serving the previous engine code until the
# container is recycled by hand. This step makes the recycle
# automatic but only when it is actually needed:
@@ -168,10 +193,7 @@ jobs:
# silent state corruption otherwise), and cascade-delete
# the lobby `games` row (the FKs in `00001_init.sql`
# drop the matching `runtime_records`, `memberships`,
# `player_mappings`, etc. in the same write). The
# `dev-sandbox` bootstrap on the next backend boot finds
# no live sandbox and provisions a fresh one on the new
# engine image.
# `player_mappings`, etc. in the same write).
#
# Backend is stopped first to keep the reconciler from
# racing the recycle (mid-stream adoption / restart). The
@@ -234,11 +256,24 @@ jobs:
- name: Bring up the stack
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: |
# Resolve in the shell, not in YAML expressions — `env.HOME`
# is empty at the workflow-evaluation stage.
export GALAXY_DEV_GAME_STATE_DIR="$HOME/.galaxy-dev/game-state"
mkdir -p "$GALAXY_DEV_GAME_STATE_DIR"
# Seed the monitoring config to a stable, reboot-surviving host
# path (compose binds \${GALAXY_DEV_MONITORING_DIR} read-only).
export GALAXY_DEV_MONITORING_DIR="$HOME/.galaxy-dev/monitoring"
rm -rf "$GALAXY_DEV_MONITORING_DIR"
mkdir -p "$GALAXY_DEV_MONITORING_DIR"
cp -r monitoring/. "$GALAXY_DEV_MONITORING_DIR/"
docker compose up -d --wait --remove-orphans
- name: Probe the stack
+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
the changed paragraphs only, do not re-translate the whole file).
A full re-translation only happens on explicit owner request.
- `site/ru/rules.md` — the player-facing game rules (ported from the
former `game/rules.txt`). **Russian is authoritative here**, inverting
the usual English-first rule: the game's rules and lore are
Russian-native, so `site/ru/rules.md` leads and the English
`site/rules.md` is its mirror. Mirror point edits the same way as
`docs/FUNCTIONAL.md`, but RU → EN.
- `docs/TESTING.md` — testing layers (unit / integration), the
integration runbook, and the principles every test must follow
(no-op observability for testcontainers, `t.Fatal` on
+8 -1
View File
@@ -27,10 +27,16 @@ The implementation specification lives in `PLAN.md`.
| ------------------ | ----------------------------------------------- | ------------------------------------- |
| `/api/v1/public/*` | none | Registration, code confirmation |
| `/api/v1/user/*` | `X-User-ID` injected by gateway | Authenticated end users |
| `/api/v1/admin/*` | HTTP Basic Auth against `admin_accounts` | Platform administrators |
| `/api/v1/admin/*` | HTTP Basic Auth against `admin_accounts` | Platform administrators (JSON) |
| `/_gm`, `/_gm/*` | HTTP Basic Auth against `admin_accounts` | Operator console (server-rendered HTML)|
| `/healthz` | none | Liveness probe |
| `/readyz` | none | Readiness probe |
The `/_gm` operator console is the human-facing surface for the admin
operations; it reuses the admin Basic Auth verifier, renders with
`html/template`, and is the only admin surface exposed publicly (through
the gateway). See `docs/admin-console.md`.
The full contract is documented in `openapi.yaml` and validated at
runtime by the contract tests under `internal/server/`.
@@ -100,6 +106,7 @@ fast.
| `BACKEND_GAME_STATE_ROOT` | yes | — | Host directory bind-mounted into engine containers. |
| `BACKEND_ADMIN_BOOTSTRAP_USER` | no | — | Initial admin username; idempotent insert. |
| `BACKEND_ADMIN_BOOTSTRAP_PASSWORD` | no | — | Initial admin password; required if user is set. |
| `BACKEND_ADMIN_CONSOLE_CSRF_KEY` | no | random per-process | Secret keying the `/_gm` console CSRF token. Set a shared value across replicas; unset uses a per-process random key (forms reset on restart). |
| `BACKEND_GEOIP_DB_PATH` | yes | — | Filesystem path to GeoLite2 Country `.mmdb`. |
| `BACKEND_OTEL_TRACES_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`. |
| `BACKEND_OTEL_METRICS_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`, `prometheus`. |
+35 -18
View File
@@ -22,10 +22,10 @@ import (
_ "time/tzdata"
"galaxy/backend/internal/admin"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/app"
"galaxy/backend/internal/auth"
"galaxy/backend/internal/config"
"galaxy/backend/internal/devsandbox"
"galaxy/backend/internal/diplomail"
"galaxy/backend/internal/diplomail/detector"
"galaxy/backend/internal/diplomail/translator"
@@ -37,6 +37,7 @@ import (
"galaxy/backend/internal/mail"
"galaxy/backend/internal/metricsapi"
"galaxy/backend/internal/notification"
"galaxy/backend/internal/opsstatus"
backendpostgres "galaxy/backend/internal/postgres"
"galaxy/backend/push"
"galaxy/backend/internal/runtime"
@@ -272,29 +273,18 @@ func run(ctx context.Context) (err error) {
)
runtimeGateway.svc = runtimeSvc
// Run a single reconciliation pass before the dev-sandbox
// bootstrap so any runtime row pointing at a vanished engine
// container (host reboot wiped /tmp/galaxy-game-state/<uuid>;
// `tools/local-dev`'s `prune-broken-engines` target reaped the
// husk) is already cascaded through `markRemoved` → lobby
// `cancelled` by the time the bootstrap walks the sandbox list.
// Without this pre-tick the bootstrap would reuse the
// soon-to-be-cancelled game and force the developer into a
// second `make up` cycle to land a healthy sandbox. Failures are
// Run a single reconciliation pass at startup so any runtime row
// pointing at a vanished engine container (a host reboot wiped
// /tmp/galaxy-game-state/<uuid>; `tools/local-dev`'s
// `prune-broken-engines` target reaped the husk) is cascaded
// through `markRemoved` → lobby `cancelled` before the server
// starts serving requests. Failures are
// non-fatal: the periodic ticker started later catches up, and
// the worst case degrades to the legacy two-cycle recovery.
if err := runtimeSvc.Reconciler().Tick(ctx); err != nil {
logger.Warn("pre-bootstrap reconciler tick failed", zap.Error(err))
}
if err := devsandbox.Bootstrap(ctx, devsandbox.Deps{
Users: userSvc,
Lobby: lobbySvc,
EngineVersions: engineVersionSvc,
}, cfg.DevSandbox, logger); err != nil {
return fmt.Errorf("dev sandbox bootstrap: %w", err)
}
notifStore := notification.NewStore(db)
notifSvc := notification.NewService(notification.Deps{
Store: notifStore,
@@ -360,6 +350,32 @@ func run(ctx context.Context) (err error) {
return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready()
}
var consoleCSRF *adminconsole.CSRF
if cfg.AdminConsole.CSRFKey != "" {
consoleCSRF = adminconsole.NewCSRF([]byte(cfg.AdminConsole.CSRFKey))
} else {
consoleCSRF, err = adminconsole.NewRandomCSRF()
if err != nil {
return fmt.Errorf("init admin console CSRF: %w", err)
}
logger.Warn("admin console CSRF key not set; using a per-process random key (forms reset on restart, not valid across replicas)",
zap.String("env", "BACKEND_ADMIN_CONSOLE_CSRF_KEY"))
}
adminConsoleHandlers := backendserver.NewAdminConsoleHandlers(backendserver.AdminConsoleDeps{
CSRF: consoleCSRF,
Monitor: opsstatus.NewStore(db),
Ready: ready,
Users: userSvc,
Games: lobbySvc,
Runtime: runtimeSvc,
EngineVersions: engineVersionSvc,
Operators: adminSvc,
Mail: mailSvc,
Notifications: notifSvc,
Diplomail: diplomailSvc,
Logger: logger,
})
handler, err := backendserver.NewRouter(backendserver.RouterDependencies{
Logger: logger,
Telemetry: telemetryRT,
@@ -388,6 +404,7 @@ func run(ctx context.Context) (err error) {
AdminGeo: adminGeoHandlers,
UserGames: userGamesHandlers,
UserMail: userMailHandlers,
AdminConsole: adminConsoleHandlers,
})
if err != nil {
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"
envAdminBootstrapPassword = "BACKEND_ADMIN_BOOTSTRAP_PASSWORD"
envAdminConsoleCSRFKey = "BACKEND_ADMIN_CONSOLE_CSRF_KEY"
envGeoIPDBPath = "BACKEND_GEOIP_DB_PATH"
envOTelTracesExporter = "BACKEND_OTEL_TRACES_EXPORTER"
@@ -103,11 +105,6 @@ const (
envDiplomailTranslatorTimeout = "BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT"
envDiplomailTranslatorMaxAttempts = "BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS"
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.
@@ -176,9 +173,6 @@ const (
defaultDiplomailTranslatorTimeout = 10 * time.Second
defaultDiplomailTranslatorMaxAttempts = 5
defaultDiplomailWorkerInterval = 2 * time.Second
defaultDevSandboxEngineVersion = "0.1.0"
defaultDevSandboxPlayerCount = 20
)
// Allowed values for the closed-set string options.
@@ -208,6 +202,7 @@ type Config struct {
Docker DockerConfig
Game GameConfig
Admin AdminBootstrapConfig
AdminConsole AdminConsoleConfig
GeoIP GeoIPConfig
Telemetry TelemetryConfig
Auth AuthConfig
@@ -216,29 +211,12 @@ type Config struct {
Runtime RuntimeConfig
Notification NotificationConfig
Diplomail DiplomailConfig
DevSandbox DevSandboxConfig
// FreshnessWindow mirrors the gateway freshness window and is used by the
// push server to bound the cursor TTL.
FreshnessWindow time.Duration
}
// DevSandboxConfig configures the boot-time bootstrap implemented in
// `backend/internal/devsandbox`. When Email is empty the bootstrap
// is a no-op, which is the production posture. When Email is set —
// from `BACKEND_DEV_SANDBOX_EMAIL` in the `tools/local-dev` stack —
// the bootstrap idempotently provisions a real user, the configured
// number of dummy participants, a private "Dev Sandbox" game, the
// matching memberships, and drives the lifecycle to `running`. The
// engine image and engine version refer to a row that the bootstrap
// also seeds in `engine_versions`.
type DevSandboxConfig struct {
Email string
EngineImage string
EngineVersion string
PlayerCount int
}
// LoggingConfig stores the parameters used by the structured logger.
type LoggingConfig struct {
// Level is the zap level name (e.g. "debug", "info", "warn", "error").
@@ -308,6 +286,15 @@ type AdminBootstrapConfig struct {
Password string
}
// AdminConsoleConfig configures the server-rendered operator console.
// CSRFKey is the secret keying the console's stateless anti-CSRF token.
// When empty the console falls back to a per-process random key, which is
// secure but means forms do not survive a restart and do not validate across
// replicas; set a shared key when running more than one backend instance.
type AdminConsoleConfig struct {
CSRFKey string
}
// GeoIPConfig configures the GeoLite2 country database used by geo lookups.
type GeoIPConfig struct {
DBPath string
@@ -560,10 +547,6 @@ func DefaultConfig() Config {
TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts,
WorkerInterval: defaultDiplomailWorkerInterval,
},
DevSandbox: DevSandboxConfig{
EngineVersion: defaultDevSandboxEngineVersion,
PlayerCount: defaultDevSandboxPlayerCount,
},
Runtime: RuntimeConfig{
WorkerPoolSize: defaultRuntimeWorkerPoolSize,
JobQueueSize: defaultRuntimeJobQueueSize,
@@ -644,6 +627,8 @@ func LoadFromEnv() (Config, error) {
cfg.Admin.User = loadString(envAdminBootstrapUser, cfg.Admin.User)
cfg.Admin.Password = loadString(envAdminBootstrapPassword, cfg.Admin.Password)
cfg.AdminConsole.CSRFKey = loadString(envAdminConsoleCSRFKey, cfg.AdminConsole.CSRFKey)
cfg.GeoIP.DBPath = loadString(envGeoIPDBPath, cfg.GeoIP.DBPath)
cfg.Telemetry.TracesExporter = strings.ToLower(loadString(envOTelTracesExporter, cfg.Telemetry.TracesExporter))
@@ -741,13 +726,6 @@ func LoadFromEnv() (Config, error) {
return Config{}, err
}
cfg.DevSandbox.Email = strings.TrimSpace(loadString(envDevSandboxEmail, cfg.DevSandbox.Email))
cfg.DevSandbox.EngineImage = strings.TrimSpace(loadString(envDevSandboxEngineImage, cfg.DevSandbox.EngineImage))
cfg.DevSandbox.EngineVersion = strings.TrimSpace(loadString(envDevSandboxEngineVersion, cfg.DevSandbox.EngineVersion))
if cfg.DevSandbox.PlayerCount, err = loadInt(envDevSandboxPlayerCount, cfg.DevSandbox.PlayerCount); err != nil {
return Config{}, err
}
if err := cfg.Validate(); err != nil {
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
}
-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`.
// Idempotent: returns nil when no game matches.
//
// Phase 14 introduces this method for the dev-sandbox bootstrap so a
// terminal "Dev Sandbox" tile from a previous local-dev session can
// be scrubbed before a fresh game spawns. Production callers must
// stay on the regular cancel / finish lifecycle — `DeleteGame` is
// destructive and bypasses the cascade-notification machinery.
// `DeleteGame` is destructive — a hard delete that bypasses the
// cascade-notification machinery — so production callers stay on the
// regular cancel / finish lifecycle. It is exercised by the lobby
// integration tests.
func (s *Service) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
if err := s.deps.Store.DeleteGame(ctx, gameID); err != nil {
return err
+2 -2
View File
@@ -248,8 +248,8 @@ func TestEndToEndPrivateGameFlow(t *testing.T) {
}
}
// TestDeleteGameCascadesEverything pins the contract the dev-sandbox
// bootstrap relies on: removing a game wipes every referencing row
// TestDeleteGameCascadesEverything pins the DeleteGame contract:
// removing a game wipes every referencing row
// (memberships, applications, invites, runtime_records,
// player_mappings) in a single SQL statement. Before this is wired
// the developer's lobby pile up cancelled tiles between
+5 -6
View File
@@ -20,9 +20,9 @@ type InsertMembershipDirectInput struct {
// writes as ApproveApplication: the per-game race-name reservation
// row plus the membership row, and refreshes the in-memory caches.
//
// The method is intended for boot-time provisioning by
// `backend/internal/devsandbox` and similar trusted callers. It is
// not exposed through any HTTP handler. The caller must guarantee
// The method is intended for trusted boot-time provisioning and
// integration tests; it is not exposed through any HTTP handler. The
// caller must guarantee
// game.Status == GameStatusEnrollmentOpen — the function returns
// ErrConflict otherwise — and that the race-name policy and
// canonical-key invariants are honoured (the implementation reuses
@@ -30,9 +30,8 @@ type InsertMembershipDirectInput struct {
// or unsuitable name still fails).
//
// Idempotency: if a membership for (GameID, UserID) already exists
// the function returns the existing row without modifying state.
// This makes the helper safe to call on every backend boot from
// devsandbox.Bootstrap.
// the function returns the existing row without modifying state, so
// the helper is safe to call repeatedly.
func (s *Service) InsertMembershipDirect(ctx context.Context, in InsertMembershipDirectInput) (Membership, error) {
displayName, err := ValidateDisplayName(in.RaceName)
if err != nil {
+2 -3
View File
@@ -236,9 +236,8 @@ func (s *Store) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameRecord
// referencing table (memberships / applications / invites /
// runtime_records / player_mappings — all declared with ON DELETE
// CASCADE in `00001_init.sql`). Idempotent: returns nil when no row
// matches. Used by the dev-sandbox bootstrap to scrub terminal
// games on every backend boot so the developer's lobby never piles
// up cancelled tiles.
// matches. A hard delete for trusted callers and integration tests;
// production lifecycle uses cancel / finish.
func (s *Store) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
g := table.Games
stmt := g.DELETE().WHERE(g.GameID.EQ(postgres.UUID(gameID)))
+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
InternalSessions *InternalSessionsHandlers
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
@@ -123,6 +130,7 @@ func NewRouter(deps RouterDependencies) (http.Handler, error) {
registerUserRoutes(router, instruments, deps)
registerAdminRoutes(router, instruments, deps)
registerInternalRoutes(router, instruments, deps)
registerAdminConsoleRoutes(router, deps)
router.NoMethod(func(c *gin.Context) {
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())
}
// 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
// the gin router accepts on requestPath. Only the probe paths declare
// 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
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)
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. |
| 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. |
| 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. |
### Backend ↔ Gateway trust
@@ -857,6 +888,19 @@ addition.
- Health probes are unauthenticated `GET /healthz` (process liveness) and
`GET /readyz` (Postgres reachable, migrations applied, gRPC listener
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
+25
View File
@@ -1162,6 +1162,31 @@ operator's password manager can match it across deployments.
After the first deployment, the bootstrap password should be
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
Existing admins can list other admins, create new ones, look up a
+25
View File
@@ -1197,6 +1197,31 @@ deployments.
После первого деплоя bootstrap-пароль должен быть ротирован
через 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-аккаунтами
Существующие админы могут перечислять других админов, создавать
-1462
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
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
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()},
}
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)
if err != nil {
_ = backend.Close()
+52
View File
@@ -276,6 +276,22 @@ const (
// configures the 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
// variable that configures the send-email-code identity request budget per
// window.
@@ -372,6 +388,14 @@ const (
defaultPublicMiscRateLimitRequests = 30
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
defaultSendEmailCodeIdentityRateLimitBurst = 1
@@ -439,6 +463,11 @@ type PublicHTTPAntiAbuseConfig struct {
// PublicMisc applies to the stable public_misc route class.
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
// send-email-code.
SendEmailCodeIdentity PublicAuthIdentityPolicyConfig
@@ -708,6 +737,14 @@ func DefaultPublicHTTPConfig() PublicHTTPConfig {
Burst: defaultPublicMiscRateLimitBurst,
},
},
Admin: PublicRoutePolicyConfig{
MaxBodyBytes: defaultAdminMaxBodyBytes,
RateLimit: PublicRateLimitConfig{
Requests: defaultAdminRateLimitRequests,
Window: defaultClassRateLimitWindow,
Burst: defaultAdminRateLimitBurst,
},
},
SendEmailCodeIdentity: PublicAuthIdentityPolicyConfig{
RateLimit: PublicRateLimitConfig{
Requests: defaultSendEmailCodeIdentityRateLimitRequests,
@@ -1092,6 +1129,18 @@ func LoadFromEnv() (Config, error) {
}
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(
cfg.PublicHTTP.AntiAbuse.SendEmailCodeIdentity,
sendEmailCodeIdentityRateLimitRequestsEnvVar,
@@ -1247,6 +1296,9 @@ func LoadFromEnv() (Config, error) {
if err := validatePublicRoutePolicyConfig(cfg.PublicHTTP.AntiAbuse.PublicMisc, publicMiscMaxBodyBytesEnvVar, publicMiscRateLimitRequestsEnvVar, publicMiscRateLimitWindowEnvVar, publicMiscRateLimitBurstEnvVar); err != nil {
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 {
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
case PublicRouteClassBrowserAsset:
return policy.BrowserAsset
case PublicRouteClassAdmin:
return policy.Admin
default:
return policy.PublicMisc
}
@@ -252,6 +254,8 @@ func publicAuthIdentityPolicyForPath(requestPath string, policy config.PublicHTT
func allowedMethodsForRequestShape(r *http.Request) []string {
switch {
case isAdminConsolePath(r.URL.Path):
return []string{http.MethodGet, http.MethodHead, http.MethodPost}
case isPublicAuthPath(r.URL.Path):
return []string{http.MethodPost}
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 {
switch requestPath {
case "/healthz", "/readyz":
+21
View File
@@ -48,6 +48,10 @@ const (
// PublicRouteClassPublicMisc identifies public traffic that does not match a
// more specific class.
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
@@ -60,6 +64,7 @@ func (c PublicRouteClass) Normalized() PublicRouteClass {
case PublicRouteClassPublicAuth,
PublicRouteClassBrowserBootstrap,
PublicRouteClassBrowserAsset,
PublicRouteClassAdmin,
PublicRouteClassPublicMisc:
return c
default:
@@ -110,6 +115,14 @@ type ServerDependencies struct {
// Telemetry records low-cardinality edge metrics. When nil, metrics are
// disabled.
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.
@@ -229,6 +242,8 @@ type defaultPublicTrafficClassifier struct{}
// later drive anti-abuse policy and rate limiting.
func (defaultPublicTrafficClassifier) Classify(r *http.Request) PublicRouteClass {
switch {
case isAdminConsoleRequest(r):
return PublicRouteClassAdmin
case isPublicAuthRequest(r):
return PublicRouteClassPublicAuth
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/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) {
allowMethods := allowedMethodsForPath(c.Request.URL.Path)
if allowMethods != "" {
+30
View File
@@ -169,6 +169,31 @@ func TestDefaultPublicTrafficClassifier(t *testing.T) {
accept: "text/html",
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 {
@@ -215,6 +240,11 @@ func TestPublicRouteClassNormalized(t *testing.T) {
input: PublicRouteClassPublicMisc,
want: PublicRouteClassPublicMisc,
},
{
name: "admin",
input: PublicRouteClassAdmin,
want: PublicRouteClassAdmin,
},
{
name: "unknown collapses to misc",
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)
}
}
+8 -2
View File
@@ -36,7 +36,10 @@ export default defineConfig({
sidebar: [
{
text: "Galaxy",
items: [{ text: "Overview", link: "/" }],
items: [
{ text: "Overview", link: "/" },
{ text: "Rules", link: "/rules" },
],
},
],
},
@@ -50,7 +53,10 @@ export default defineConfig({
sidebar: [
{
text: "Galaxy",
items: [{ text: "Обзор", link: "/ru/" }],
items: [
{ text: "Обзор", link: "/ru/" },
{ text: "Правила", link: "/ru/rules" },
],
},
],
},
+5
View File
@@ -20,6 +20,11 @@ under `/game/` (see `tools/dev-deploy/Caddyfile.dev`).
- Add a page as Markdown and register it in the `sidebar` of each locale
in `.vitepress/config.ts`.
- Localised content mirrors the English tree under `ru/`.
- **Game rules** (`ru/rules.md`) are the one exception to English-first:
the Russian page is the authoritative source (ported from the former
`game/rules.txt`), and the English `rules.md` is its mirror/translation.
Keep the two in sync as with `FUNCTIONAL.md`/`FUNCTIONAL_ru.md`, but with
Russian leading.
- Math uses LaTeX: inline `$E = mc^2$`, block `$$ … $$`.
- Link to the game with the root-relative `/game/` path so the build
stays domain-agnostic (no hard-coded host).
+8 -2
View File
@@ -1,5 +1,11 @@
# Galaxy
A turn-based space strategy game.
A turn-based multiplayer strategy about the struggle for the galaxy.
[Play the game →](/game/){target="_self"}
You lead a race among the stars. Grow planets, research technologies, design and build your own ships, settle new worlds. Whom to fight and whom to befriend is up to you — and victory is not won by force of arms alone: votes, alliances, and diplomacy count for as much as guns.
Each turn the server resolves every player's orders at once, and the galaxy comes alive: planets are colonised, battles flare, a stream of diplomatic mail flows between races. Players are anonymous — so each race may hide an invented character, and here it is customary not only to count resources but to play a role.
To play, it is enough to send a few lines of orders per turn. The room for strategy, though, will last you a long time.
[Play →](/game/){target="_self"} · [Game rules →](/rules)
+1 -1
View File
@@ -10,7 +10,7 @@
"preview": "vitepress preview"
},
"devDependencies": {
"markdown-it-mathjax3": "^5.2.0",
"markdown-it-mathjax3": "^4.3.2",
"vitepress": "^1.6.4"
}
}
+299 -110
View File
@@ -9,11 +9,11 @@ importers:
.:
devDependencies:
markdown-it-mathjax3:
specifier: ^5.2.0
version: 5.2.0
specifier: ^4.3.2
version: 4.3.2
vitepress:
specifier: ^1.6.4
version: 1.6.4(@algolia/client-search@5.52.1)(markdown-it-mathjax3@5.2.0)(postcss@8.5.15)(search-insights@2.17.3)
version: 1.6.4(@algolia/client-search@5.52.1)(markdown-it-mathjax3@4.3.2)(postcss@8.5.15)(search-insights@2.17.3)
packages:
@@ -418,62 +418,6 @@ packages:
cpu: [x64]
os: [win32]
'@se-oss/deasync-darwin-arm64@1.0.1':
resolution: {integrity: sha512-0YWmIDEGQfW3GGopmZHhfA6mamsG0HFKZhmBzHVyFiMKkJts8kpQwGbGrWlK8eOAoPCihOsG6tCotYR3p7HZaQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@se-oss/deasync-darwin-x64@1.0.1':
resolution: {integrity: sha512-r3FRTLIXqGqOb1DjTLW3YhO/Dd1vA2qRLP0Ym3Wmk3yMv6c/nm15zg6UVoXbgBu8cjbvcsI/OfbHPdErmjMWsw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@se-oss/deasync-linux-arm64-gnu@1.0.1':
resolution: {integrity: sha512-657uRew7fZAx663Li03ilLV2lN09Dqb/NxawlDu8kKmboK1BLitHJRS+taiT5oFZqyIDrU45tlQKfCrW0p0sYA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@se-oss/deasync-linux-arm64-musl@1.0.1':
resolution: {integrity: sha512-IE3fIQPIJtko4lx9sRam+Zz0P4xbpAPJgDCHaz6k9cP1yUvVI179B4IZRnFx0GyjyQpm0KhHoIGHJc4KUmA81Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@se-oss/deasync-linux-x64-gnu@1.0.1':
resolution: {integrity: sha512-XQl7etZESGIjIraCyxfAey8ZTIJUB4dUFU3rPR/xLVn9bKpZGlJLIms0z3hoHX9mipO+Cqo53vK4IVm6A7U/ww==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@se-oss/deasync-linux-x64-musl@1.0.1':
resolution: {integrity: sha512-vWgFAZlqImqMV6jhCWV7C9wcCS1eb1ajhlKduBRPfyUxxkoObe+EqTG2BKJAuafxp3/KS1aUsIMJma9mhwFvow==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@se-oss/deasync-win32-arm64-msvc@1.0.1':
resolution: {integrity: sha512-yk7lEE7Zd8GX7o6CuUbg3HnnmUhBx4tgfn5ff3eoq05CgBO6Z3ZtL4l+utAe1cxcFaXPhyvcgnHYyA4OF544tg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@se-oss/deasync-win32-x64-msvc@1.0.1':
resolution: {integrity: sha512-ixizmuLGRPGyAesWUNWVzVOsvuunNb/qMqU8SmjfLR/vVgzdQEkSHFf+fkX9GXPN6FDv+DAz5uskTzhjUyCXFA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@se-oss/deasync@1.0.1':
resolution: {integrity: sha512-Ha7P/xCNxOuH72BNdLRWs4TT8rsMMrERnHtfKWBeTWu+UFW9OBTrRgfZJOlbAAQFR0l4Q30cpAn8CuR7PXWcPg==}
engines: {node: '>= 10'}
'@shikijs/core@2.5.0':
resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==}
@@ -620,13 +564,24 @@ packages:
'@vueuse/shared@12.8.2':
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
'@xmldom/xmldom@0.9.10':
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
engines: {node: '>=14.6'}
algoliasearch@5.52.1:
resolution: {integrity: sha512-fHA8+kXTbjagw3jkLiaS7KKrH8qe2DyOsiUhGlN4cdT77PEsfqXZl7ewDk1hsg+pJnPlnE50XtLxjR91iJOpmg==}
engines: {node: '>= 14.0.0'}
ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -636,13 +591,35 @@ packages:
character-entities-legacy@3.0.0:
resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
cheerio-select@1.6.0:
resolution: {integrity: sha512-eq0GdBvxVFbqWgmCm7M3XGs1I8oLy/nExUnh6oLqmBditPO9AqQJrkslDpMun/hZ0yyTs8L0m85OHp4ho6Qm9g==}
cheerio@1.0.0-rc.10:
resolution: {integrity: sha512-g0J0q/O6mW8z5zxQ3A8E8J1hUgp4SMOvEoW/x84OwyHKe/Zccz83PVT4y5Crcr530FV6NgmKI1qvGTKVl9XXVw==}
engines: {node: '>= 6'}
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
commander@13.1.0:
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
engines: {node: '>=18'}
commander@6.2.1:
resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==}
engines: {node: '>= 6'}
copy-anything@4.0.5:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
css-select@4.3.0:
resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
css-what@6.2.2:
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
engines: {node: '>= 6'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -653,9 +630,29 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
dom-serializer@1.4.1:
resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
domhandler@3.3.0:
resolution: {integrity: sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==}
engines: {node: '>= 4'}
domhandler@4.3.1:
resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==}
engines: {node: '>= 4'}
domutils@2.8.0:
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
emoji-regex-xs@1.0.0:
resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==}
entities@2.2.0:
resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
@@ -665,6 +662,14 @@ packages:
engines: {node: '>=12'}
hasBin: true
escape-goat@3.0.0:
resolution: {integrity: sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==}
engines: {node: '>=10'}
esm@3.2.25:
resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==}
engines: {node: '>=6'}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
@@ -688,25 +693,43 @@ packages:
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
htmlparser2@5.0.1:
resolution: {integrity: sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==}
htmlparser2@6.1.0:
resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==}
is-what@5.5.0:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
juice@8.1.0:
resolution: {integrity: sha512-FLzurJrx5Iv1e7CfBSZH68dC04EEvXvvVvPYB7Vx1WAuhCp1ZPIMtqxc+WTWxVkpTIC2Ach/GAv0rQbtGf6YMA==}
engines: {node: '>=10.0.0'}
hasBin: true
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
mark.js@8.11.1:
resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==}
markdown-it-mathjax3@5.2.0:
resolution: {integrity: sha512-R+XAy5/7vSGuhG9Z0/cJm6zKxOzStcScfSKVwoarh4nBra+v1KClvbALr/xFTEe9iQhwfQM4SJnO68LXL+btMA==}
markdown-it-mathjax3@4.3.2:
resolution: {integrity: sha512-TX3GW5NjmupgFtMJGRauioMbbkGsOXAAt1DZ/rzzYmTHqzkO1rNAdiMD4NiruurToPApn2kYy76x02QN26qr2w==}
mathxyjax3@0.8.3:
resolution: {integrity: sha512-eXjFaiyQsTdVOeTFoFaFJ/r1FITpB1f9c5MW4FETfcoVV/+xa5SD9pS05AwugzL/gNuDtWXrTOSmoD2e0Du+UA==}
mathjax-full@3.2.2:
resolution: {integrity: sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==}
deprecated: Version 4 replaces this package with the scoped package @mathjax/src
mdast-util-to-hast@13.2.1:
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
mensch@0.3.4:
resolution: {integrity: sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==}
mhchemparser@4.2.1:
resolution: {integrity: sha512-kYmyrCirqJf3zZ9t/0wGgRZ4/ZJw//VwaRVGA75C4nhE60vtnIzhl9J9ndkX/h6hxSN7pjg/cE0VxbnNM+bnDQ==}
micromark-util-character@2.1.1:
resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
@@ -722,20 +745,46 @@ packages:
micromark-util-types@2.0.2:
resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
mime@2.6.0:
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
engines: {node: '>=4.0.0'}
hasBin: true
minisearch@7.2.0:
resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mj-context-menu@0.6.1:
resolution: {integrity: sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==}
nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
oniguruma-to-es@3.1.1:
resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==}
parse5-htmlparser2-tree-adapter@6.0.1:
resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==}
parse5@6.0.1:
resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
@@ -775,6 +824,9 @@ packages:
shiki@2.5.0:
resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==}
slick@1.12.2:
resolution: {integrity: sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -786,6 +838,10 @@ packages:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
speech-rule-engine@4.1.4:
resolution: {integrity: sha512-i/VCLG1fvRc95pMHRqG4aQNscv+9aIsqA2oI7ZQS51sTdUcDHYX6cpT8/tqZ+enjs1tKVwbRBWgxut9SWn+f9g==}
hasBin: true
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@@ -796,12 +852,14 @@ packages:
tabbable@6.4.0:
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
@@ -818,6 +876,10 @@ packages:
unist-util-visit@5.1.0:
resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
valid-data-url@3.0.1:
resolution: {integrity: sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==}
engines: {node: '>=10'}
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -875,6 +937,19 @@ packages:
typescript:
optional: true
web-resource-inliner@6.0.1:
resolution: {integrity: sha512-kfqDxt5dTB1JhqsCUQVFDj0rmY+4HLwGQIsLPbyrsN9y9WV/1oFDSx3BQ4GfCv9X+jVeQ7rouTqwK53rA/7t8A==}
engines: {node: '>=10.0.0'}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
wicked-good-xpath@1.3.0:
resolution: {integrity: sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -1181,43 +1256,6 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.60.4':
optional: true
'@se-oss/deasync-darwin-arm64@1.0.1':
optional: true
'@se-oss/deasync-darwin-x64@1.0.1':
optional: true
'@se-oss/deasync-linux-arm64-gnu@1.0.1':
optional: true
'@se-oss/deasync-linux-arm64-musl@1.0.1':
optional: true
'@se-oss/deasync-linux-x64-gnu@1.0.1':
optional: true
'@se-oss/deasync-linux-x64-musl@1.0.1':
optional: true
'@se-oss/deasync-win32-arm64-msvc@1.0.1':
optional: true
'@se-oss/deasync-win32-x64-msvc@1.0.1':
optional: true
'@se-oss/deasync@1.0.1':
dependencies:
type-fest: 4.41.0
optionalDependencies:
'@se-oss/deasync-darwin-arm64': 1.0.1
'@se-oss/deasync-darwin-x64': 1.0.1
'@se-oss/deasync-linux-arm64-gnu': 1.0.1
'@se-oss/deasync-linux-arm64-musl': 1.0.1
'@se-oss/deasync-linux-x64-gnu': 1.0.1
'@se-oss/deasync-linux-x64-musl': 1.0.1
'@se-oss/deasync-win32-arm64-msvc': 1.0.1
'@se-oss/deasync-win32-x64-msvc': 1.0.1
'@shikijs/core@2.5.0':
dependencies:
'@shikijs/engine-javascript': 2.5.0
@@ -1387,6 +1425,8 @@ snapshots:
transitivePeerDependencies:
- typescript
'@xmldom/xmldom@0.9.10': {}
algoliasearch@5.52.1:
dependencies:
'@algolia/abtesting': 1.18.1
@@ -1404,20 +1444,56 @@ snapshots:
'@algolia/requester-fetch': 5.52.1
'@algolia/requester-node-http': 5.52.1
ansi-colors@4.1.3: {}
birpc@2.9.0: {}
boolbase@1.0.0: {}
ccount@2.0.1: {}
character-entities-html4@2.1.0: {}
character-entities-legacy@3.0.0: {}
cheerio-select@1.6.0:
dependencies:
css-select: 4.3.0
css-what: 6.2.2
domelementtype: 2.3.0
domhandler: 4.3.1
domutils: 2.8.0
cheerio@1.0.0-rc.10:
dependencies:
cheerio-select: 1.6.0
dom-serializer: 1.4.1
domhandler: 4.3.1
htmlparser2: 6.1.0
parse5: 6.0.1
parse5-htmlparser2-tree-adapter: 6.0.1
tslib: 2.8.1
comma-separated-tokens@2.0.3: {}
commander@13.1.0: {}
commander@6.2.1: {}
copy-anything@4.0.5:
dependencies:
is-what: 5.5.0
css-select@4.3.0:
dependencies:
boolbase: 1.0.0
css-what: 6.2.2
domhandler: 4.3.1
domutils: 2.8.0
nth-check: 2.1.1
css-what@6.2.2: {}
csstype@3.2.3: {}
dequal@2.0.3: {}
@@ -1426,8 +1502,32 @@ snapshots:
dependencies:
dequal: 2.0.3
dom-serializer@1.4.1:
dependencies:
domelementtype: 2.3.0
domhandler: 4.3.1
entities: 2.2.0
domelementtype@2.3.0: {}
domhandler@3.3.0:
dependencies:
domelementtype: 2.3.0
domhandler@4.3.1:
dependencies:
domelementtype: 2.3.0
domutils@2.8.0:
dependencies:
dom-serializer: 1.4.1
domelementtype: 2.3.0
domhandler: 4.3.1
emoji-regex-xs@1.0.0: {}
entities@2.2.0: {}
entities@7.0.1: {}
esbuild@0.21.5:
@@ -1456,6 +1556,10 @@ snapshots:
'@esbuild/win32-ia32': 0.21.5
'@esbuild/win32-x64': 0.21.5
escape-goat@3.0.0: {}
esm@3.2.25: {}
estree-walker@2.0.2: {}
focus-trap@7.8.0:
@@ -1487,20 +1591,51 @@ snapshots:
html-void-elements@3.0.0: {}
htmlparser2@5.0.1:
dependencies:
domelementtype: 2.3.0
domhandler: 3.3.0
domutils: 2.8.0
entities: 2.2.0
htmlparser2@6.1.0:
dependencies:
domelementtype: 2.3.0
domhandler: 4.3.1
domutils: 2.8.0
entities: 2.2.0
is-what@5.5.0: {}
juice@8.1.0:
dependencies:
cheerio: 1.0.0-rc.10
commander: 6.2.1
mensch: 0.3.4
slick: 1.12.2
web-resource-inliner: 6.0.1
transitivePeerDependencies:
- encoding
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
mark.js@8.11.1: {}
markdown-it-mathjax3@5.2.0:
markdown-it-mathjax3@4.3.2:
dependencies:
'@se-oss/deasync': 1.0.1
mathxyjax3: 0.8.3
juice: 8.1.0
mathjax-full: 3.2.2
transitivePeerDependencies:
- encoding
mathxyjax3@0.8.3: {}
mathjax-full@3.2.2:
dependencies:
esm: 3.2.25
mhchemparser: 4.2.1
mj-context-menu: 0.6.1
speech-rule-engine: 4.1.4
mdast-util-to-hast@13.2.1:
dependencies:
@@ -1514,6 +1649,10 @@ snapshots:
unist-util-visit: 5.1.0
vfile: 6.0.3
mensch@0.3.4: {}
mhchemparser@4.2.1: {}
micromark-util-character@2.1.1:
dependencies:
micromark-util-symbol: 2.0.1
@@ -1531,18 +1670,36 @@ snapshots:
micromark-util-types@2.0.2: {}
mime@2.6.0: {}
minisearch@7.2.0: {}
mitt@3.0.1: {}
mj-context-menu@0.6.1: {}
nanoid@3.3.12: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
nth-check@2.1.1:
dependencies:
boolbase: 1.0.0
oniguruma-to-es@3.1.1:
dependencies:
emoji-regex-xs: 1.0.0
regex: 6.1.0
regex-recursion: 6.0.2
parse5-htmlparser2-tree-adapter@6.0.1:
dependencies:
parse5: 6.0.1
parse5@6.0.1: {}
perfect-debounce@1.0.0: {}
picocolors@1.1.1: {}
@@ -1613,12 +1770,20 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
slick@1.12.2: {}
source-map-js@1.2.1: {}
space-separated-tokens@2.0.2: {}
speakingurl@14.0.1: {}
speech-rule-engine@4.1.4:
dependencies:
'@xmldom/xmldom': 0.9.10
commander: 13.1.0
wicked-good-xpath: 1.3.0
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
@@ -1630,9 +1795,11 @@ snapshots:
tabbable@6.4.0: {}
tr46@0.0.3: {}
trim-lines@3.0.1: {}
type-fest@4.41.0: {}
tslib@2.8.1: {}
unist-util-is@6.0.1:
dependencies:
@@ -1657,6 +1824,8 @@ snapshots:
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
valid-data-url@3.0.1: {}
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3
@@ -1675,7 +1844,7 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
vitepress@1.6.4(@algolia/client-search@5.52.1)(markdown-it-mathjax3@5.2.0)(postcss@8.5.15)(search-insights@2.17.3):
vitepress@1.6.4(@algolia/client-search@5.52.1)(markdown-it-mathjax3@4.3.2)(postcss@8.5.15)(search-insights@2.17.3):
dependencies:
'@docsearch/css': 3.8.2
'@docsearch/js': 3.8.2(@algolia/client-search@5.52.1)(search-insights@2.17.3)
@@ -1696,7 +1865,7 @@ snapshots:
vite: 5.4.21
vue: 3.5.34
optionalDependencies:
markdown-it-mathjax3: 5.2.0
markdown-it-mathjax3: 4.3.2
postcss: 8.5.15
transitivePeerDependencies:
- '@algolia/client-search'
@@ -1733,4 +1902,24 @@ snapshots:
'@vue/server-renderer': 3.5.34(vue@3.5.34)
'@vue/shared': 3.5.34
web-resource-inliner@6.0.1:
dependencies:
ansi-colors: 4.1.3
escape-goat: 3.0.0
htmlparser2: 5.0.1
mime: 2.6.0
node-fetch: 2.7.0
valid-data-url: 3.0.1
transitivePeerDependencies:
- encoding
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
wicked-good-xpath@1.3.0: {}
zwitch@2.0.4: {}
+8 -2
View File
@@ -1,5 +1,11 @@
# Galaxy
Пошаговая космическая стратегия.
Пошаговая многопользовательская стратегия о борьбе за галактику.
[Играть →](/game/){target="_self"}
Вы — лидер расы среди звёзд. Развивайте планеты, исследуйте технологии, проектируйте и стройте собственные корабли, осваивайте новые миры. С кем воевать, а с кем дружить — решаете только вы, и победа достаётся не одной лишь силой оружия: голоса, союзы и дипломатия значат не меньше пушек.
Каждый ход сервер просчитывает приказы всех игроков разом, и галактика живёт своей жизнью: колонизируются планеты, вспыхивают сражения, между расами течёт поток дипломатической почты. Игроки анонимны — поэтому за каждой расой может стоять придуманный характер, и здесь принято не только считать ресурсы, но и отыгрывать свою роль.
Чтобы играть, достаточно отправлять несколько строк приказов за ход. А вот простора для стратегии хватит надолго.
[Играть →](/game/){target="_self"} · [Правила игры →](/ru/rules)
+666
View File
@@ -0,0 +1,666 @@
# «Галактика» (Galaxy Game)
Руководство игрока.
## Процесс игры {#turn-cycle}
Весь процесс игры разделен на ходы, которые в свою очередь можно разделить на два чередующихся процесса: "производство хода" и "ожидание производства нового хода". Считается, что "производство хода" процесс не требующий времени и условно происходящий мгновенно. В этот момент совершается само действие, т.е. выполняется процесс производства на планетах, происходят [сражения](#combat) флотов враждующих рас и [передвижение кораблей](#movement) от планеты к планете — полный порядок этих шагов описан в разделе [«Последовательность действий»](#turn-sequence).
Существует также понятие "состояние игры", которое соответствует текущему положению дел (в том числе, у кого сколько чего есть, кто что делает, какие корабли куда летят и т.п.). Состояние игры соответствует отчёту, который получает игрок сразу после производства очередного хода.
Производство хода происходит регулярно в заранее установленное время, известное всем участникам. Он заканчивается рассылкой всем участникам отчетов с информацией о состоянии их рас на этот ход. После чего начинается ожидание производства нового хода. Это процесс специально предназначен, чтобы игроки имели возможность обдумывать команды на будущий ход и тем самым изменять состояние игры.
Каждая из команд может лишь задать действие (например, тип производства на планете), но не приведёт к моментальному выполнения этого действия. Так, например, можно дать команду постройки корабля, но строиться сам корабль будет лишь во время выполнения очередного хода. Или можно дать приказ об отправке кораблей куда-либо, но полетят они лишь во время производства хода.
Когда приказ (последовательность команд) поступает на сервер, игрок получает уведомление о том, что его команды приняты к производству. Каждая команда из приказа проверяется на корректность и получает отдельное подтверждение. Игрок может послать любое количество приказов по своему усмотрению, однако, каждый новый приказ отменяет предыдущий. Таким образом, можно исправить неверно составленный приказ, но при этом необходимо повторить те команды, которые были отданы верно. К счастью, программа-клиент помогает игроку не запутаться в этом процессе и берёт на себя контроль за целостностью приказов.
Во время ожидания производства нового хода можно также производить дипломатическую переписку между расами. Все ходы нумеруются, чтобы удобнее было планировать свои действия.
Игра начинается с того, что происходит производство хода с номером ноль, во время которого, собственно, и создается сама галактика (в галактике размещаются планеты, участники получают свои развитые планеты и т.д.). После чего всем участникам рассылаются отчеты с информацией об их начальном состоянии. Получив такой отчет, каждый из участников должен внимательно его изучить и на его основе разработать (или скорректировать) план развития собственной расы. Важно отметить, что с момента рассылки отчетов за ход с номером N и вплоть до рассылки отчетов за ход с номером N+1 длится ход номер N (происходит ожидание производства нового хода с номером N+1).
Для более полного понимания приведем пример работы сервера:
- всем участникам рассылаются отчеты за ход с номером N (начался ход номер N);
- принимаются приказы, рассылаются подтверждения приказов в соответствии с полученными командами, распространяется дипломатическая почта, новости и т.п. (продолжается ход номер N - идет "ожидание производства нового хода");
- согласно расписанию, обозначенному при наборе в партию, сервер просчитывает новый ход с номером N+1 (происходит процесс производства хода);
- всем участникам рассылаются отчеты за ход с номером N+1 (начался ход номер N+1);
## Галактика {#galaxy}
Пространство Галактики, где разворачиваются действия, представляет собой поверхность тора, а визуально это просто квадрат у которого закольцованы противоположные стороны. Галактика содержит некоторое число звёздных систем. Несмотря на то, что любая звёздная система может содержать некоторое число планет и других небесных тел, в рамках игры такая детализация несущественна: при добыче ресурсов используется суммарные ресурсы всей звёздной системы, а организации полётов основана на перемещении между центрами масс в галактике.
Поэтому для простоты обозначения игра оперирует термином "планета", что на самом деле могло бы соответствовать понятию "звёздная система".
## Единицы измерений {#units}
Единицы измерений в игре соответствуют действительным. Расстония между планетами измеряются в световых годах. Размеры планет исчисляются в десятках километров в диаметре. Каждая единица населения обозначает 10 миллионов человек и каждая единица товаров, сырья и т.п. представляет собой 10 миллионов тонн. Каждый ход игры соответствует одному году жизни Галактики. В большинстве случаев вместо указания реальных единиц, удобнее пользоваться определением "единица измерения". Например: "5 единиц массы" или "10 единиц населения".
## Числовые величины {#precision}
Числа, используемые в командах и отчетах, представляются с точностью до трёх десятичных знаков после запятой. Сервер хранит числа и производит вычисления с большей точностью, округляя результаты лишь для представления игрокам в отчётах. Например, если в отчете стоит число 2.000, это может означать, что на самом деле число может колебаться от 1.9995 до 2.0004.
Исключение составляют технологические уровни кораблей — в отчётах они приводятся без такого округления (см. [«Технологии»](#tech)).
## Наименования {#names}
Произвольные наименования, выбираемые игроком, могут иметь классы кораблей, планеты, флоты и науки. Имена не могут быть длиннее 30 символов. Символы могут быть буквами алфавита, цифрами и спецсимволами '!@#$%^*-_=+~()[]{}'. Спецсимволы не могут находиться в начале или конце имени а так же повторяться более двух раза подряд.
Выбирая имя, игроку рекомендуется руководствоваться в первую очередь здравым смыслом и не злоупотреблять возможностью создавать нечитаемые имена. Хоть это напрямую и не запрещено, но такой способ коммуникации с внешним миром будет выглядеть не самым лучшим образом в глазах других участников, ведь многие выбранные игроком имена увидит вся галактика. Не следует использовать в наименованиях лексику, которая наносит грубые оскорбления кому-либо из игроков, а так же разжигает межнациональную или межконфессиональную рознь (разумеется, речь про реальный, не игровой мир). Администрация игры оставляет за собой право принимать меры, вплоть до исключения игрока с сервера, за злоупотребление наименованиями.
## Планеты {#planets}
Каждая из рас, начиная игру, владеет одной или тремя планетами (в зависимости от типа партии), все остальные планеты - необитаемы. В процессе игры допустимо как заселение необитаемых планет, так и завоевание планет, заселенных другими расами.
В начале игры все планеты имеют уникальные имена. После колонизации планеты можно изменить ее имя. Вы можете также пожелать изменить имя Ваших первых планет сразу после начала, чтобы придать им более интригующий вид.
Любая планета имеет две неизменяемые характеристики: размер и природные ресурсы. В стандартной партии каждый участник в начале игры владеет одной планетой размером 1000 (такая планета называется "Домашним Миром" или "Home World", HW) и двумя планетам размером по 500 (такие планеты называются "Дочерними Мирами" или "Daughter World", DW). HW игроков находятся на расстоянии не менее 30 световых лет друг от друга, DW - на расстоянии от 5 до 15 световых лет от HW.
Все остальные планеты не обладают жестко установленными характеристиками и встречаются в галактике в следующих пропорциях:
| тип планет | размер | ресурсы | примерное количество от общего числа планет |
|---|---|---|---|
| Супер большие планеты | 1500-2500 | 0-3 | 6% |
| Просто большие планеты | 1000-2000 | 1-10 | 18% |
| Обычные планеты | 0-1000 | 0.1-10 | 50% |
| Маленькие, но сказочно богатые | 0-500 | 5-25 | 18% |
| Астероиды | 0 | 0 | 8% |
При этом супер большие планеты могут встречаться лишь на расстоянии не менее 20 св. лет от HW и друг от друга; просто большие - 10 св. лет. Как можно заметить, диапазоны характеристик супер больших планет и просто больших планет пересекаются. Это не ошибка, а реальность. В среднем на каждого участника в галактике приходится по 10 планет(включая три начальные).
Прочие характеристики планет могут изменяться в течение игры как в результате действий игроков, так и в результате естественного развития планет. Такие характеристики включаются в себя:
- Население
- Колонисты - избыток населения.
- Сырьё
- Промышленность
## Население {#population}
Каждая планета имеет атрибут "Размер". Он характеризует не только физический размер планеты, но также обуславливает наличие гор, пустынь и океанов, климат и т. п. Население планеты не может превышать её размер, но может быть меньше. Ваша первая планета имеет размер и население равными 1000. Население планеты увеличивается на 8% на каждом ходу. Часть населения планеты, превышающая её размер, превращается в [колонистов](#colonists). Следует помнить, что население планеты ограничивает рост [промышленности](#industry): количество единиц промышленности не может превышать количество единиц населения.
## Колонисты {#colonists}
Каждые 8 единиц населения, превышающих размер планеты, автоматически превращаются в единицу колонистов. Эти жители сохраняются в контейнерах при низкой температуре. Если колонисты перевозятся на другие планеты, имеющие место для расселения, они автоматически размораживаются и добавляются к населению планеты. Этим способом могут быть обжиты необитаемые планеты. Из каждой единицы колонистов вновь получается 8 единиц населения. Современные технологии позволяют производить процесс заморозки и разморозки колонистов без каких-либо производственных и материальных затрат.
## Промышленность {#industry}
Уровень промышленности планеты (или "количество промышленности") соответствует таким вещам, как инструменты, компьютеры, транспорт и т.п. В начале игры все ваши планеты имеют максимальный уровень промышленности, равный населению (1000 для HW и 500 для DW).
## Производство на планете {#production}
Доступные производственные единицы могут быть израсходованы на добычу сырья, постройку кораблей, производство промышленности или на исследования технологий. На одной планете за один ход можно производить только однотипный продукт (например, только один тип кораблей).
## Производственные единицы {#production-capacity}
Каждая заселенная планета имеет определенный производственный потенциал, выраженные в единицах производства. Он не может превышать население планеты, но может оказаться меньше. Производственный потенциал определяет уровень производительности планеты и складывается из производственной мощности населения и уровня промышленности.
$$\text{Производственный потенциал} = \text{промышленность} \times 0.75 + \text{население} \times 0.25$$
В начале игры Вы располагаете одной планетой с производственным потенциалом 1000 и двумя планетами с производственным потенциалом по 500. Это означает, что каждый ход Вы располагаете 1000, 500 и еще 500 единицами производства, которые Вы можете заставить работать так, как Вам понравится. Если Ваша планета имеет 500 единиц промышленности и население 1000, то такая планета может произвести лишь около 625 единиц выбранной продукции за один ход. Другими словами - Ваша планета имеет производственный потенциал в 625 производственных единиц.
## Технологии {#tech}
Вы начинаете с технологическим уровнем 1 в следующих областях: Двигатели (Drive), Оружие (Weapons), Защита (Shields) и Грузоперевозки (Cargo). Эти уровни могут быть повышены переключением производства планет на исследования. Чтобы увеличить технологический уровень на единицу, необходимо затратить 5000 производственных единиц. Дробные показатели затрат на исследования технологий непременно будут полезными. Так, если вы затратили 500 единиц на исследования в области Оружия, Ваш технологический уровень в этой области возрастет на одну десятую и это даст немедленный эффект при постройке кораблей, нет необходимости ждать, пока уровень возрастет на целую единицу. В момент постройки корабля он получает те уровни технологий, какие были у Вас на момент начала производства хода (см. [«Последовательность действий»](#turn-sequence)). Уровни технологий уже построенных кораблей в дальнейшем можно обновить с помощью команды модернизации.
В целях избежания неточностей в расчетах, технологические уровни кораблей округляются до третьего знака после запятой в момент постройки или модернизации. Поэтому в отчетах у кораблей всегда указываются действительные, а не округлённые уровни технологий.
Все начальные планеты игроков по умолчанию заняты исследованием технологии "Drive".
## Науки {#sciences}
Вы можете комбинировать технологии в науки. Каждая наука состоит из известных технологий, взятых в определяемых Вами пропорциях. Когда Вы переключаете производство на планете на исследования в области определенной Вами науки, производственные единицы расходуются на те технологии, из которых состоит данная наука, причем в соответствии с заданной Вами пропорцией. Доли технологий в науке задаются в долях единицы, и их сумма равна единице (100%).
Например, наука с именем "First Step" задана долями 0.222 для Двигателей, 0.111 для Вооружения, 0.667 для Защиты и 0 для Грузоперевозок (в сумме — единица; это соответствует соотношению 10 : 5 : 30 : 0). Тогда при изучении такой науки 22.2% доступных производственных единиц планеты будут израсходованы на разработки в области Двигателей, 11.1% — на Вооружение и 66.7% — на технологию Защиты. Таким образом за один ход на одной планете Вы имеете возможность повысить сразу несколько технологических уровней.
## Сырьё (Материалы) {#material}
Производство чего-либо, кроме технологий, требует затрат сырья так же, как и производственных затрат. Сырье соответствует таким материалам, как листовая сталь, медная проволока, древесина и нефть и т.п., необходимые для производства. Каждая планета может иметь запас произведённого или привезённого сырья, которое можно использовать при производстве кораблей. Если такой запас отсутствует, часть производственных единиц может быть ориентирована на выпуск сырья.
Как известно, каждая планета имеет неизменную характеристику - Природные Ресурсы, которая показывает, насколько планета богата запасами металлов, угля, нефти и т.п. Планеты с высоким показателем Ресурсов требуют меньших затрат на производство сырья. Показатель зависит от [типа планеты](#planets): у обитаемых планет он строго больше нуля и доходит до 25 у редких богато одарённых планет, и лишь у астероидов равен нулю. Ваши первые планеты имеют показатели ресурсов 10, что означает, что каждая производственная единица может произвести 10 единиц сырья. Планета с показателем сырья 0.1 может произвести только 0.1 единиц сырья на каждую производственную единицу. Произведённое сырьё складируется и может быть транспортировано на другие планеты с помощью грузовых кораблей.
Когда Вы колонизируете планеты с низким показателем природных ресурсов, Вам стоит производить сырье на планетах с высоким показателем и затем перевозить их на другие планеты, чтобы большое количество производственных единиц не тратилось на добычу сырья. Количество сырья на планете можно увеличить и путем демонтажа кораблей, находящихся на планете. В этом случае каждая единица массы демонтируемых кораблей превращается в единицу сырья.
Например, Вы ориентировали производство на постройку космических кораблей. Для постройки требуется количество сырья, эквивалентное массе строящегося корабля. Если Вы начинаете без запаса сырья, оно будет произведено автоматически. Этот процесс полностью невидим для Вас, единственный заметный эффект это то, что кое-где процесс производства будет меньше, чем Вы ожидаете.
Другими словами, на постройку корабля останется столько производственных единиц, сколько может дать данная планета, за минусом количества, которое будет израсходовано на добычу необходимого для постройки количества сырья. По этой причине необходимо учитывать, что при постройке кораблей не все производственные единицы планеты будут непосредственно участвовать в постройке, некоторым из них придется заняться добычей недостающего для постройки сырья. На практике это условие является одним из самых важных при проектировании кораблей, поскольку оно непосредственно определяет время постройки кораблей определённого класса на выбранной планете.
## Производство промышленности {#industry-production}
При развитии планет количество промышленности можно повышать, переключив производство планеты на промышленность. Одна единица промышленности требует 5 производственных единиц и одной единицы [сырья](#material); если запаса сырья нет, недостающее добывается автоматически из тех же производственных единиц (как и при [постройке кораблей](#ship-building)) по «курсу» природных ресурсов планеты — поэтому на бедных ресурсами планетах промышленность растёт медленнее.
Уровень промышленности не может превышать население планеты. Пока промышленность ниже населения, произведённая промышленность сразу повышает [производственный потенциал](#production-capacity). Когда же промышленность достигает населения, излишек не теряется, а откладывается в запас промышленности (в отчёте — «запасы промышленности», `$`). Этот запас можно перевозить между планетами; и как только промышленность планеты снова окажется ниже её населения — потому ли что подросло само население, потому ли что запас привезли в молодую колонию — он автоматически превращается в промышленность и поднимает её уровень. Так быстро развиваются колонизируемые планеты.
## Конструирование кораблей {#ship-design}
Перед тем, как давать команды постройки кораблей на планетах, необходимо сконструировать нужные классы кораблей. Готовых классов кораблей не существует, каждая раса в галактике разрабатывает свои собственные, исходя из поставленных стратегических задач. Чтобы создать класс корабля, нужно дать ему имя и определить следующие характеристики:
| Параметр | Обозначение | Описание |
|---|---|---|
| Двигатель | Drive | мощность гипердвигателя |
| Вооруженность | Armament | количество несомых орудий |
| Оружие | Weapons | мощность орудий |
| Защита | Shields | мощность генератора защитного поля |
| Размер трюма | Cargo | объём грузовых отсеков |
Выбранные Вами характеристики корабля могут быть либо равными 0, либо быть не менее 1. Разумеется, все характеристики корабля не могут быть нулевыми одновременно. "Вооруженность" и "Оружие" должны быть оба либо нулевыми либо оба ненулевыми. "Вооруженность" задаётся целым числом, все остальные могут быть дробными. Например, корабль может иметь "Защиту" 1.5, но не может 0.5.
Конструирование классов кораблей не занимает времени или ресурсов, новый класс корабля становится доступным сразу после отдачи соответствующей команды. Как именно характеристики корабля сказываются на игре, описано в разделах [«Движение»](#movement), [«Грузоподъёмность»](#cargo) и [«Сражения»](#combat).
Приведём несколько примеров классов кораблей. Несмотря на то, что такие классы могут встречаться в галактике у различных рас, Вы не обязаны конструировать корабли с точно такими характеристиками, гораздо важнее исходить их тех задач, которые Ваши корабли будут решать.
| Наименование | D | A | W | S | C |
|---|---|---|---|---|---|
| Drone | 1 | 0 | 0 | 0 | 0 |
| Fighter | 1 | 1 | 1 | 1 | 0 |
| Gunship | 4 | 2 | 2 | 4 | 0 |
| Destroyer | 6 | 1 | 8 | 4 | 0 |
| Cruiser | 15 | 1 | 15 | 15 | 0 |
| Battle_Cruiser | 30 | 3 | 10 | 30 | 0 |
| Battleship | 25 | 1 | 30 | 35 | 0 |
| Battle_Station | 60 | 3 | 30 | 100 | 0 |
| Orbital_Fort | 0 | 3 | 30 | 100 | 0 |
| Space_Gun | 0 | 1 | 1 | 0 | 0 |
| Freighter | 8 | 0 | 0 | 2 | 10 |
| Megafreighter | 80 | 2 | 2 | 30 | 100 |
## Постройка кораблей {#ship-building}
Корабли строятся с уровнями технологий, которые были у расы на начало хода. Иначе говоря, при получения очередного отчёта только что построенные корабли получат технологические уровни предыдущего хода.
Корабль без вооружения имеет массу равную "Двигатели" + "Защита" + "Размер Трюма", указанные при его проектировании. Корабль с одной пушкой имеет массу, равную "Двигатели" + "Оружие" + "Защита" + "Размер Трюма". Для кораблей, несущих несколько орудий, каждое орудие после первого добавляет массу, равную половине "Оружия".
Массы некоторых из приведённых выше кораблей:
- Freighter: 20 единиц массы.
- Cruiser: 45 единиц массы.
- Gunship: 11 единиц массы.
Вы можете установить на планете производство кораблей определенного класса, который был ранее сконструирован Вами. Для постройки корабля необходимо затратить количество сырья, равное массе корабля и еще по 10 производственных единиц на каждую единицу массы корабля.
Например, Ваш HW производит корабли класса "Drone" из списка приведенного выше, и существует достаточный запас сырья, следовательно, Вы можете произвести 100 таких кораблей (без запаса сырья, то будет произведено немногим больше 99 кораблей). Однако, если Вы будете производить Battleship, то сможете произвести только 10/9 корабля за ход. После первого хода один корабль будет полностью построен и 1/9 будет в стадии производства. После второго хода 2 корабля будут находиться на орбите и 2/9 незавершенны. Если Вы затем перенастроите производство на другой тип кораблей или что-нибудь совершенно иное, эти 2/9 будут демонтированы и добавлены к запасу сырья. Запас сырья увеличится ровно на столько, сколько было использовано сырья при постройке этих 2/9 частей корабля. Производственные единицы, которые были использованы для постройки самого корабля будут безвозвратно утеряны как бесполезный труд.
Как видно, невыгодно часто переключать производство при постройке больших кораблей. Кроме того, очевидно, что экономически выгодным является способ постройки, при котором масса корабля является кратной (или приблизительно кратной) массе, которую может произвести за один ход данная планета.
Планета с промышленностью 1000, ресурсами 10 и без запасов сырья может произвести за один ход 99.0099 единиц массы. Разумно производить на такой планете корабли массами: 99.00, 11.00, 198.01 или 297.02. И весьма невыгодно, хотя и возможно, пытаться строить что-либо массой 140, например.
Важно отметить, что сырье тратится в только в самом конце постройки кораблей. Поэтому для кораблей, рассчитанных на длительную постройку (более одного хода), необходимое сырье можно подвозить на планету на протяжении всей постройки.
Правильный расчет производимого корабля - одна из самых важных задач в игре. Необходимо досконально представлять себе весь механизм расчетов. К примеру, если корабли, которые, по Вашим расчетам должны были на определённом ходу долететь до нужной планеты, оказались на ничтожном расстоянии 0.001 св. лет (или меньше) от этой планеты и не долетели, это означает, что Вы неправильно рассчитали скорость/массу и т.д.
## Группы кораблей {#ship-groups}
На поздних стадиях игры Вы можете иметь сотни и даже тысячи кораблей, которыми крайне неудобно было бы управлять отдельно. По этой причине корабли объединяются в группы. Все команды манипулирования ранее построенными кораблями оперируют группами кораблей, даже если в какой-то группе находится всего один корабль. Вы можете загружать группы кораблей грузом, посылать их на другую планету, передавать другой расе и т.п.
Группой является некоторое количество кораблей одного класса, находящихся в одном месте, перевозящих одинаковое количество однотипного груза, имеющих одинаковую принадлежность к флотам и имеющих одни и те же технологические уровни. Корабли с отсутствующим компонентом помечаются как имеющие технологический уровень 0 для этого компонента, т.е. невооруженные корабли, например, всегда имеют технологический уровень 0 для "Оружия".
Когда необходимо выполнить команду с меньшим количеством кораблей, чем находится в группе (например, послать на другую планету 8 из 10 кораблей), необходимо сначала выделить эти корабли в отдельную, новую группу. Программа-клиент упрощает такие действия для игрока, однако, необходимо помнить, что в приказе такое действие будет состоять из двух команд: сначала произойдёт выделение кораблей в новую группу, затем - действие с новой группой. Объединение эквивалентных групп происходит автоматически перед началом каждого хода или по команде игрока.
## Передача кораблей между расами {#ship-transfer}
В процессе игры можно передавать группы кораблей между расами. Если у расы, которой передается группа, уже определен класс кораблей с таким же названием, но другими характеристиками, принимающая раса так же получит новый класс кораблей, к названию которого будет добавлен некоторой случайный суффикс.
## Модернизация кораблей {#ship-upgrade}
В процессе развития технологических уровней расы, ранее построенные корабли могут устареть и перестать соответствовать текущим возможностям расы. Такие корабли можно модернизировать до необходимых уровней технологий.
Важно помнить, что процесс модернизации технологических уровней кораблей завершается лишь к окончанию хода, поэтому те корабли, которым была отдана команда модернизации, сперва примут участие в [сражениях](#combat), если на одной с ними орбите окажутся вражеские корабли.
Для проведения процесса модернизации группа кораблей должна находиться на одной из принадлежащих Вам планет. Корабли из этой группы будут модернизироваться в соответствии с Вашими текущими технологиями (если они уже имеют Ваши последние технологические уровни, то ничего не произойдет). Но можно и ограничить уровень технологий, до которого происходит модернизация, задав конечный уровень модернизации технологий.
Разумеется, модернизация кораблей имеет свою цену. Цена модернизации корабля равна частичной стоимости от постройки нового корабля. Например, если корабль имеет технологии равные 2/3 от необходимых технологий, то цена модернизации будет равна 1/3 от стоимости постройки нового корабля. Экономическая эффективность процесса модернизации будет выше, нежели постройка нового, так как модернизация не требует затрат сырья. Каждый из блоков корабля можно модернизировать отдельно. Конечная формула стоимости модернизации блока одного корабля выглядит так:
$$\left(1 - \frac{\text{текущая технология}}{\text{конечная технология}}\right) \times 10 \times \text{масса блока}$$
> Например, если модернизировать корабль типа "Cruiser" со всеми единичными технологиями до уровня технологий 2.0, то для полной его модернизации потребуется 225 производственных единиц.
Легко заметить, что стоимость модернизации тем выше, чем больше разрыв между текущей технологией корабля и конечной технологией.
Можно модернизировать либо всю группу кораблей, либо только необходимое количество. В случае, если количества производственных единиц планеты не достаточно для полной модернизации даже одного корабля, то указанные технологии одного корабля поднимаются на столько, на сколько это возможно (если делается комплексная модернизация, то все технологии корабля поднимаются пропорционально массе соответствующих компонентов). Таким образом, можно модернизировать корабли в течении нескольких ходов, выполняя каждый ход команду модернизации.
Разумеется, производственные единицы, потраченные на модернизацию кораблей, не будут принимать участие в производственном процессе на данной планете в течении этого хода. Так, если производственных единиц не хватает для выполнения полной модернизации указанной группы, используется весь запас производственных единиц на данной планете и она уже будет не в состоянии что-либо производить в течении этого хода.
На планете можно модернизировать любое количество групп кораблей, пока остаются свободные производственные единицы. Оставшееся после модернизации кораблей количество производственных единиц не будет потрачено на частичную модернизацию, а остаётся свободным для дальнейшего производства.
При модернизации нескольких групп кораблей на планете, сперва будут подвергнуты модернизации группы с наибольшей стоимостью и при условии, что на планете достаточно производственных единиц для модернизации каждой группы. Если производственных единиц окажется недостаточно для конкретной группы (например, случилась бомбардировка вражескими кораблями), она остаётся с изначальным уровнем технологий.
## Демонтаж кораблей {#ship-scrap}
Корабли, находящиеся на орбите планеты, могут быть разобраны на составляющие материалы. Запас сырья на планете, где находились корабли, будет увеличен на массу этих кораблей. Если корабли несли какой-либо груз, он сперва будет выгружен на планету. С колонистами есть особенность: над своей планетой они выгружаются и пополняют население, над необитаемой планетой — выгружаются и заселяют её (планета становится Вашей), а над чужой планетой колонисты не смогут быть выгружены и навсегда останутся в стадии заморозки на просторах Галактики.
## Флоты {#fleets}
Флоты могут быть составлены из групп кораблей разных типов. К флотам применима команда перемещения, точно также, как и к отдельно взятой группе. В отличие от отдельных групп, группы, входящие в состав флота, не перемещаются по установленным для планет грузовым маршрутам. Скорость флота равна скорости самой медленной группы, входящей в флот. Загрузка и разгрузка кораблей, входящих в флот может влиять лишь на скорость флота. При выделении какого-либо количества кораблей из группы, входящей в состав флота, эти выделенные корабли не будут входить в состав флота. Флот существует до тех пор, пока он содержит хоть одну группу. Между флотами может осуществляться передача групп и флоты могут объединяться в один, но только в том случае, если флоты находятся на одной планете.
## Движение {#movement}
Физические законы, которым повинуются корабли, путешествующие в гиперпространстве, говорят, что перемещаться можно только от одного большого центра масс до другого. Это означает, что Вы можете посылать корабли только с одной планеты на другую. Нельзя послать корабль просто в некую точку пространства. Когда корабли находятся в гиперпространстве, они уже не могут изменить курс, вернуться назад, изменить скорость или быть атакованы.
Космические корабли оборудуются гипердвигателем, эффективность которого равна мощности Двигателей умноженной на текущий технологический уровень блока Двигателей. Корабли с мощностью двигателя 0 навсегда останутся на орбите планеты, на которой они были построены. Это не означает, впрочем, что таких кораблей строить нельзя, наоборот, они могут быть прекрасным средством защиты планет от вражеских кораблей.
Корабли перемещаются за один ход на количество световых лет, равное эффективности двигателя, умноженной на 20 и делённой на полную массу корабля.
"Полная масса" означает массу самого корабля плюс массу перевозимого им груза. "Масса перевозимого груза" отличается от просто "массы груза" тем, что "масса груза" это общее количество единиц груза, а "масса перевозимого груза" это общее количество единиц груза деленное на технологический уровень Грузоперевозок, т.е. величина меньшая.
Следовательно, транспорты движутся быстрее, когда они не несут груза. Когда уровень технологии Двигателей низок, крупные корабли должны иметь соответствующие их массе Двигатели, иначе они будут очень медленны. Самые быстрые корабли могут двигаться со скоростью:
$$20 \times \text{технологический уровень Двигателей}$$
Исключением из общих правил расчета скорости кораблей являются корабли, входящие в состав какого-либо сформированного Вами флота. В этом случае, вне зависимости от возможностей кораблей, их скорость будет равна скорости самой медленной группы данного флота.
Чтобы проделать путь от одной планеты к другой, должно быть затрачено столько ходов, за сколько посылаемая группа кораблей может преодолеть расстояние между этими планетами.
От технологии двигателей расы зависит также и зона свободного перелета кораблей (максимальная дальность полета кораблей от своих планет). Любой корабль может улететь на любую планету, удаленную от планет, принадлежащих расе, на расстояние не более чем:
$$40 \times \text{технологический уровень Двигателей}$$
Если у расы не осталось планет, то оставшиеся у неё корабли не смогут покинуть своего места пребывания, поскольку нет возможности вычислить максимальное расстояние для полёта.
Если передаваемый другой расе корабль находится вне пределов досягаемости расы, которой его передали, то он либо продолжит свой полет, либо сможет быть отправлен на планету, находящуюся в зоне досягаемости расы владельца.
В отчётах Вы можете получить направление и скорость движения чужих кораблей, но только в случае, когда они направляются на одну из Ваших планет. В остальных случаях Вы можете получить только координаты центра масс чужих групп, двигающихся в гиперпространстве не далее чем на расстоянии
$$30 \times \text{Ваш технологический уровень Двигателей}$$
от ближайшей планеты, принадлежащей Вам. Корабли, находящиеся за пределами зоны видимости, вообще не будут показываться в Ваших отчетах, даже в том случае, если эти корабли летят на одну из Ваших планет.
Необходимо также помнить, что события, происходящие на планете, не принадлежащей расе, эта раса может наблюдать только в том случае, когда на планете находятся ее корабли. Если все корабли отсылаются с такой планеты, то состояние планеты сразу исчезает из поля зрения.
## Грузоподъемность {#cargo}
Грузоподъемность корабля означает размер его грузового отсека. Количество груза, который может нести корабль, вычисляется по формуле:
$$\text{Тех.ур. Грузоперевозок} \times \left(\text{Размер трюма} + \frac{\text{Размер трюма}^2}{20}\right)$$
где под понятием "Технологический_уровень_Грузоперевозок" подразумевается текущий технологический уровень Грузоперевозок корабля, а Размер трюма указывается при проектировании этого корабля.
Несколько примеров Грузоподъемности кораблей при технологии Грузоперевозок равной 1.0:
| Размер Трюма | Количество груза |
|---|---|
| 1 | 1.05 |
| 5 | 6.25 |
| 10 | 15.00 |
| 50 | 175.00 |
| 100 | 600.00 |
При технологии Грузоперевозок 2.0 эти показатели удваиваются и т.д. Заметим, что большие транспорты могут нести очень большое количество груза, но если они будут полностью загружены, то они будут очень медленно передвигаться (например, полностью загруженный Megafreighter при технологии двигателей 1 будет иметь скорость лишь 1.97 световых года за один ход).
Маленькая скорость тяжело груженых кораблей, вообще говоря, может быть компенсирована более высокой технологией Грузоперевозок. При технологическом уровне 2.0, масса любого груза на борту корабля будет считаться как половина от нормальной массы, используемой для вычислений скорости корабля и мощности защитного поля (см. [«Сражения»](#combat)). При тех. уровне 3.0, масса груза при вычислениях будет делиться на 3 и т.д. Например, корабль типа Freighter при тех. уровне Грузоперевозок 1 может нести 15 единиц груза. При тех. уровне 2.0 количество груза возрастет до 30 единиц, однако они будут замедлять корабль точно также, как 15 единиц груза при технологическом уровне Грузоперевозок 1.0. Таким образом при тех. уровне 2.0 и загруженных 30 единицах груза Freighter будет двигаться также быстро (учитывая тех. уровень Двигателей) как Freighter загруженный 15 ед. груза при технологии Грузоперевозок 1.0.
Иными словами, технология Грузоперевозок определяет, насколько компактно груз размещается на борту. «Масса перевозимого груза» равна количеству груза, делённому на технологический уровень Грузоперевозок, — поэтому чем выше эта технология, тем меньше один и тот же груз весит при расчётах, тем меньше он замедляет корабль и тем слабее снижает эффективность его защиты в [сражении](#combat).
Корабль может нести только один тип груза одновременно. Возможные типы груза - это колонисты, сырье и промышленность. Груз может быть доставлен на борт корабля с Вашей или не занятой планеты, на которой он имеется. Промышленность и Сырье могут быть выгружены на любой планете. Колонисты могут быть высажены только на планеты, принадлежащие Вам или на необитаемые планеты.
## Грузовые маршруты {#routes}
Чтобы перемещать грузы между планетами, Вы можете устанавливать грузовые маршруты, вместо того, чтобы делать это вручную. Грузовой маршрут с планеты A на планету B с грузом определенного типа означает, что сервер будет пытаться доставить этот груз с планеты A на планету B, используя все доступные транспортные корабли. Таким образом, если такой маршрут установлен, любой незагруженный корабль на планете A на каждом ходу будет загружен (если, конечно, груз нужного типа на этой планете есть) и послан на планету B. Любой корабль, прибывший на планету B с грузом нужного типа будет автоматически разгружен (даже если он прибыл не с планеты A).
Вы можете установить до 4-х грузовых маршрутов для каждой планеты, которой владеете: по одному на каждый тип груза и еще один для пустых кораблей, что полезно для возвращения транспортов с планет, потребляющих ресурсы на планеты, их производящие. Вы можете устанавливать грузовые маршруты только с планет, которыми Вы владеете, однако эти маршруты могут вести на любые планеты, так что Вы можете транспортировать таким образом колонистов на необитаемые планеты.
Если с планеты установлено несколько типов маршрутов, корабли загружаются и отправляются в следующем порядке: сначала колонисты, затем промышленность, затем сырье и, наконец, пустые корабли. При избытке количества конкретного типа груза на планете, группы кораблей будут загружаться в порядке убывания размеров их трюмов.
На особом положении находятся корабли входящие в состав какого-либо флота. Подразумевается, что эти корабли предназначены для выполнения некой специальной миссии и на них не распространяется обязанность следовать установленным грузовым маршрутам.
Грузовой маршрут может быть установлен лишь на планету, которая находится в [зоне свободного перелёта](#movement) кораблей.
## Сражения {#combat}
Когда на планете встречаются вооруженные корабли враждующих рас, происходит сражение. В случае агрессии одной из сторон, другая сторона, даже если она находится с агрессором в состоянии мира, также вступит в сражение. Это вовсе не означает, что у нападающих есть право первого выстрела, все сражающиеся стороны находятся в абсолютно равных условиях ведения сражения, и первым выстрелит тот, кто более удачлив. Корабли, которым отдан приказ на отлёт, а также корабли, отправляемые по установленным грузовым маршрутам, к началу хода ещё находятся на планете отправления и принимают участие в сражении у этой планеты — в гиперпространство уходят лишь уцелевшие в нём корабли. Корабль, уже находящийся в гиперпространстве, в сражениях не участвует вплоть до прибытия на планету назначения (подробный порядок — в разделе [«Последовательность действий»](#turn-sequence)).
В каждом раунде сражения все корабли получают шанс выстрелить по противнику, разумеется, если в этом же раунде его противники не были более удачливы и не успели своими выстрелами уничтожить корабль, ожидающий своей очереди атаковать.
В начале раунда случайным образом из участников сражения выбирается один корабль. Он случайным образом выбирает себе в качестве мишени вражеский корабль и стреляет по нему. Цель может быть, а может и не быть уничтожена, что зависит от вооружения, защиты и Фортуны. Атакующий корабль будет продолжать стрелять по случайным целям, пока не выстрелят все его орудия и остаются возможные цели для поражения.
Затем вновь случайным образом выбирается корабль, который в данном раунде ещё не стрелял и имеет шансы поразить вражеский корабль. Так продолжается до тех пор, пока в раунде не отстреляются все корабли. Если после этого остаются корабли, способные поразить друг друга, начинается новый раунд сражения.
Сражение прекращается, когда ни у одного из враждующих кораблей не остаётся цели, которую он способен поразить. Такое может, например, быть при встрече маленького истребителя с огромным, но невооруженным транспортом, защиту которого тот не в состоянии пробить.
Формула вероятности уничтожения корабля:
$$\frac{\log_4\!\left(\dfrac{\text{Оружие} \cdot \text{Т.У.Оружия}}{\text{Защита} \cdot \text{Т.У.Защиты} / \sqrt[3]{\text{масса}} \cdot \sqrt[3]{30}}\right) + 1}{2}$$
> Пояснение: $\log_4(a)$ - это логарифм по основанию 4 от а; $X^Y$ - это Х в степени Y; термин Оружие относится к стреляющему кораблю, а Защита и масса к кораблю-цели.
Эффективность атаки равна Оружию, умноженному его на технологический уровень. Эффективность защиты равна Защите, умноженной на технологический уровень Защиты, и деленной на диаметр корабля-цели, который равен корню кубическому от его массы. И это совершенно естественно, ибо большие корабли должны защищать большую поверхность и, при прочих равных условиях, слабее. Корабль с параметрами D=8 A=1 W=8 S=8 C=0 будет иметь лишь в 4 раза более эффективную защиту, чем корабль с параметрами D=1 A=1 W=1 S=1 C=0, хотя его Защита в 8 раз больше.
Параметры подобраны так, что корабль D=10 A=1 W=10 S=10 C=0, стреляя по такому же кораблю, уничтожит цель с вероятностью 50%. Если посчитать, то очевидно, что защита такого корабля равна ~3.21. Для того, чтобы уравнять вероятности атаки и защиты, защита нормируется - умножается на число ~3.11, таким образом, достигается ситуация, когда единица защиты обеспечивает единицу защищенности. Если эффективность атаки в 4 раза выше, чем у нормированной защиты - цель всегда уничтожается. Если эффективность нормированной защиты в 4 раза выше - атака всегда безуспешна.
Заметим, что любое количество груза на борту корабля увеличивает его [полную массу](#movement) при расчётах мощности защиты корабля, а генератор защитного поля должен защищать груз так же хорошо, как и сам корабль. Иначе говоря, для транспорта, загруженного известным количеством груза, эффективность защиты будет тем слабее, чем ниже уровень технологии [Грузоперевозок](#cargo). И если такой корабль будет уничтожен в бою, везомый им груз гибнет вместе с ним: с каждым потерянным кораблём груз группы уменьшается пропорционально числу уцелевших.
Если вооруженный корабль остался на вражеской планете после завершения сражения, он начинает бомбить планету, уничтожая на ней население и промышленность в зависимости от мощности его орудий.
## Бомбардировка планет {#bombing}
Вражеские корабли, находящиеся на орбите обитаемой планеты, выполняют бомбардировку с целью захвата этой планеты путём последующей колонизации. Механизм бомбардировки состоит в следующем. На планете уничтожается население и колонисты в количестве равном суммарной мощности бомбардировки всех атакующих групп. Такое же количество промышленности превращается в сырье. Мощность бомбардировки одной группы вычисляется так:
$$\left(\frac{\sqrt{\text{Оружие} \cdot \text{Тех.Ур.Оружия}}}{10} + 1\right) \cdot \text{Оружие} \cdot \text{Тех.Ур.Оружия} \cdot \text{Вооруженность} \cdot \text{Количество кораблей в группе}$$
Таким образом, один корабль Battle_Station при технологии Вооружения 1.0 будет иметь мощность бомбардировки, равную 139.30. Это означает, что за один ход, бомбардируя планету, такой корабль может уничтожить 139.30 ед. населения, 139.30 ед. колонистов и превратить 139.30 ед. промышленности в сырье. Два таких корабля будут иметь уже мощность 278.60.
Бомбардировка, в отличие от сражений, не происходит раундами - каждая из атакущих групп кораблей имеет возможность лишь один раз атаковать планету имеющимися силами.
Так же не играет роли очередность бомбардировки, поскольку планета будет атакована одновременно всеми группами, начиная с самой большой мощности бомбардировки, пока на планете остаётся население.
Если после бомбардировки на планете остается население, то она продолжает производство и может к следующему ходу построить, например, корабль. Если на планете остались, также и колонисты, то они превращаются в население, а накопленная промышленность возмещает потери производства.
В том случае, если после бомбардировки на планете не остается населения, планета становится необитаемой и может быть колонизирована заново. Таким образом Вы можете захватывать планеты, принадлежащие другим расам. Всё сырьё и все запасы промышленности, оставшиеся на планете после бомбардировки, сохраняются и перейдут к новому владельцу, который сможет колонизировать планету. Колонисты, находившиеся на планете, так же погибают, т.к. не остаётся ни активного населения, способного вывести их из состояния заморозки, ни промышленности на их поддержание.
## Колонизация планет {#colonization}
Любая необитаемая планета может быть колонизирована, т.е. заселена и таким образом добавления к владениям расы. Это происходит в случае, если колонисты высаживаются на необитаемую планету.
Если корабли нескольких рас, загруженные колонистами, одновременно прибывают на необитаемую планету и у этих рас установлены грузовые маршруты на доставку колонистов на эту планету, либо несколько игроков отдали команду выгрузки колонистов, то преимущество заселения планеты будет определено в следующем порядке:
- Наибольшее количество выгружаемых колонистов;
- Наибольшее количество населения расы;
- Случайный выбор претендента на колонизацию.
Колонисты рас, не получивших планету, при этом не пропадают: выгружается только победитель, а колонисты остальных претендентов остаются в трюмах своих кораблей, и их можно направить на другую планету. Разумеется, если раса, колонизирующая планету, не смогла получить планету во владение, тогда все последующие команды, рассчитанные на то, что именно Ваши колонисты были выгружены на планету, могут быть отменены в процессе производства хода. Если Вы не уверены, что планета будет колонизирована именно Вами, имеет смысл вступать в дипломатическую переписку, заключать договоры, продавать право колонизации за материальные блага и т.п.
Следует помнить, что флоты не подчиняются грузовым маршрутам, следовательно, колонисты на кораблях флотов не участвуют в подсчёте общечего числа колонистов для приоритетной высадки в конце маршрутов.
По умолчанию, на колонизированных планетах устанавливается производство промышленности.
## Война и мир {#war-peace}
В начале игры предполагается, что Вы находитесь в состоянии войны со всеми остальными расами. Вы можете заключить мир с другой расой в любое время. Это означает, что Ваши корабли не будут ни стрелять по кораблям этой расы, ни бомбить ее планеты. Однако любая раса при этом может находиться в состоянии войны с Вами, и до тех пор, пока она, со своей стороны, не заключит мир с Вами, её корабли еще могут атаковать Вас. Находясь в состоянии мира, Вы можете в любой момент снова объявить войну и наоборот.
В Вашем отчете будет указан дипломатический статус по отношению к каждой расе, однако, это ни в коей мере не показывает отношение к Вам остальных рас. Вы не знаете, как они к Вам относятся, до тех пор, пока не встретите один из их боевых кораблей. И только после того, как боевой корабль чужой расы за время хода не произвел выстрелов по Вашим кораблям, можно считать, что эта раса находится с Вами в мире.
Разумеется, удостовериться в том, что у той или иной расы по отношению к Вам только мирные намерения, можно прибегнув к дипломатической переписке, заключив, например, пакт о ненападении до определённого хода, вступив в альянс до конца партии и т.п. Однако, не стоит забывать, что в Галактике в равной степени есть место как для доблести, так и для коварства.
## Выход из игры {#exit}
Раса считается полностью погибшей, если она не владеет ни одной планетой и ни одним кораблем. Если раса приходит в подобное состояние, то она удаляется из списка участвующих рас перед началом просчета очередного хода. Так, если раса лишилась всех своих планет и кораблей, друзья вполне могут оказать ей помощь, передав в её владение корабли до начала производства хода.
Любая раса может воспользоваться командой досрочного выхода из игры. После подачи этой команды, раса будет удалена из игры через 3 хода. Команда выхода из игры должна быть последней в приказе.
Существует возможность принудительного окончания игры. Если раса 10 ходов подряд не присылала приказы на сервер (к дипломатической почте это не относится), то такая раса так же удаляется из игры.
Любая раса также может быть исключена из игры в любое время по решению администрации за грубые нарушения.
При исключении из игры удаляются все группы кораблей, принадлежащие расе, а все её планеты становятся необитаемыми и с них исчезает вся промышленность, материалы при этом остаются. Вышедшие из игры расы невозможно восстановить в списках участников партии.
За 5 ходов до принудительного исключения, раса с каждым новым отчетом начинает получать предупреждение.
За 3 хода до исключения, в каждом из следующих отчетов все участники узнают о грядущем выходе этой расы из игры. Любая команда, пришедшая на сервер от ещё живой расы, выключает механизм выхода расы из игры(это не относится к дипломатической почте).
## Победы и поражения {#victory}
В каждом Вашем отчете после просчета хода в списке состояния рас указано количество голосов, полученных каждой из рас в процессе хода. Суммарное количество голосов также указано в отчёте. Каждая тысяча единиц населения планет, принадлежащих расе, дают один голос. Если в начале игры каждая из рас имеет одну полностью развитую планету размером 1000 и две по 500, значит, каждая из рас имеет по два голоса. В процессе колонизации других планет и их развития каждая из рас может увеличить число своих голосов.
Процесс голосования происходит при производстве хода. В промежутках между ходами каждая из рас может изменить своего избранника. Если несколько рас по цепочке отдали свои голоса друг другу, то такие расы считаются находящимися в альянсе. Количество голосов альянса считается простым суммированием голосов всех членов альянса без учета голосов остальных рас проголосовавших за членов альянса, но не входящих в него.
Победителем считается та раса (или альянс), которая набрала 2/3 от общего числа голосов всех рас. Вообще говоря, есть возможность закончить игру с первых же ходов, если 2/3 всех рас изберут для себя единственную достойную и проголосуют за нее или образуют альянс, но стоит ли ради этого играть? Игра также может быть закончена и по безапелляционному решению администрации.
## Последовательность действий {#turn-sequence}
После того, как получены приказы от всех рас, происходит сам ход, т.е. следующая последовательность действий:
- Корабли передаются новым владельцам.
- Расы, покинувшие игру, освобождаются от своего имущества.
- Выполняются все отданные расами приказы. В частности, корабли разгружаются согласно приказам, а кораблям может быть отдан приказ на отлёт: такие корабли получают готовность к отлёту, но физически остаются на планете отправления.
- Корабли, где это возможно, объединяются в группы.
- Враждующие корабли вступают в схватку у планет отправления. Корабли, которым отдан приказ на отлёт, ещё стоят на планете и участвуют в этой схватке; в гиперпространство уходят только уцелевшие.
- Товары загружаются на корабли, находящиеся в начале грузовых маршрутов (после схватки — маршрутные транспорты сражаются незагруженными).
- Корабли (готовые к отлёту и снаряжённые по маршрутам) входят в гиперпространство и пролетают сквозь него.
- Корабли, где это возможно, объединяются в группы.
- Враждующие корабли вступают в схватку (после выхода из гиперпространства, у планет назначения).
- Корабли бомбят вражеские планеты.
- На планетах модернизируются корабли.
- На планетах строятся корабли (с учётом производственного потенциала, оставшегося от модернизации кораблей).
- Корабли, где это возможно, объединяются в группы.
- На планетах производится промышленность, добывается сырьё, разрабатываются новые технологии.
- Увеличивается население планет.
- Корабли разгружаются в конце грузовых маршрутов.
- Выгруженные колонисты увеличивают население планеты (если население планеты ниже её размера).
- Накопленная и выгруженная промышленность увеличивает производственный уровень планеты (если производственный уровень планеты ниже уровня населения).
- Происходит отмена маршрутов, выходящих за зону полёта кораблей.
- Происходит голосование.
## Отчет о результатах хода {#report}
Отчет, который Вы будете получать после каждого хода, содержит необходимую и достаточную информацию о состоянии Галактики, с учётом доступной видимости действий.
Перечень разделов отчёта приведён ниже.
- Размер галактики, количество планет галактики и количество оставшихся рас.
- Предупреждение о скором исключении Вашей расы из игры за неактивность, если до удаления осталось не более 5 ходов: указывается количество оставшихся ходов (механизм описан в разделе "Выход из игры").
- Расы, покидающие игру в ближайшее время: расы, до принудительного исключения которых за неактивность осталось не более 3 ходов; этот список виден всем участникам.
- Ваше общее количество голосов.
- Имя расы, которой Вы отдаете свои голоса.
- Статус игроков.
| Код | Значение |
|---|---|
| N | Имя |
| D | Уровень технологии Двигателей |
| W | Уровень технологии Вооружений |
| S | Уровень технологии Защиты |
| C | Уровень технологии Грузоперевозок |
| P | Общее население |
| I | Общее производство |
| # | Число планет во владении |
| R | Война или мир (Ваше отношение к указанной расе, но не наоборот) |
| V | Количество голосов, отданных расе |
- Ваши науки.
| Код | Значение |
|---|---|
| N | Название науки |
| D | Доля технологии Двигателей |
| W | Доля технологии Вооружений |
| S | Доля технологии Защиты |
| C | Доля технологии Грузоперевозок |
- Чужие науки.
Список наук чужих рас доступен в том случае, если Ваши корабли находятся на одной из чужих планет, где разрабатываются технологии при использовании этих наук.
- Классы Ваших кораблей.
| Код | Значение |
|---|---|
| N | Имя |
| D | Двигатели |
| A | Вооруженность |
| W | Оружие |
| S | Защита |
| C | Размер Трюма |
| М | Масса одного корабля этого типа |
- Классы чужих кораблей.
Описание каждого класса чужих кораблей, которые были встречены Вами на этом ходу.
- Сражения.
Это описание всех [сражений](#combat), в которых Вы участвовали либо были их свидетелями на этом ходу. Для каждого сражения указан список групп, присутствовавших на месте сражения к началу битвы, за которым следует описание обмена ударами. Дополнительно указываются:
- Количество кораблей группы, не уничтоженных в процессе сражения;
- Статус участника сражения: "In_Battle" или "Out_Battle", причём, последнее состояние указывает на то, что группа не участвовала в сражении, а являлась лишь свидетелем происходящих событий. Такое возможно, если у группы не оказалось врагов.
- Бомбардировки.
Список планет, которые подверглись бомбардировке на этом ходу в пределах Вашей видимости, содержащий информацию об атаке:
| Код | Значение |
|---|---|
| W | Имя расы, производящей бомбардировку |
| O | Имя расы-владельца |
| N | Название планеты |
| P | Население |
| I | Производственный потенциал |
| P | Тип производства |
| $ | Запасы промышленности |
| M | Запасы сырья |
| C | Количество колонистов |
| A | Мощность атаки |
| (пусто) | Состояние на момент после бомбардировки: "Damaged"/"Wiped" |
- Приближающиеся группы.
Список всех групп чужих кораблей, находящихся в гиперпространстве и направляющихся на Ваши планеты.
| Код | Значение |
|---|---|
| O | Откуда (с какой планеты отправлена группа) |
| D | Куда (на какую планету группа направляется) |
| R | Оставшееся расстояние |
| S | Скорость |
| M | Полная масса |
- Ваши планеты.
Это список всех Ваших планет. Приводится следующая информация:
| Код | Значение |
|---|---|
| # | Галактический номер планеты |
| X | X координата |
| Y | Y координата |
| N | Название планеты |
| S | Размер |
| P | Население |
| I | Производственный потенциал |
| R | Природные ресурсы |
| P | Тип производства (промышленность, сырье, исследования или корабли) |
| $ | Запасы промышленности |
| M | Запасы сырья |
| C | Количество колонистов |
| L | Свободный производственный потенциал |
Параметр (L) используется для определения реального промышленного потенциала на данный ход.
- Корабли в производстве.
| Код | Значение |
|---|---|
| # | Галактический номер планеты |
| N | Название планеты |
| S | Наименование типа строящегося корабля |
| C | Стоимость постройки одного такого корабля (в производственных ед.) без учета расходов на добычу сырья |
| P | Сколько производственных единиц уже было затрачено на постройку этого корабля (уже учитывая производство сырья) |
| L | Свободный производственный потенциал |
Необходимо обратить внимание на то, что Стоимость постройки одного корабля не учитывает расходов на добычу сырья, в то время как, в количество затраченных производственных единиц, уже включены расходы на добычу сырья. Поэтому вполне нормальная ситуация, когда (P) немного превышает (C). Это может говорить лишь о том, что необходимое для постройки корабля количество сырья еще не было произведено.
- Грузовые маршруты.
- Чьи-то планеты.
Список планет чужих рас, на которых находятся Ваши наблюдатели.
- Необитаемые планеты.
Список незаселенных планет, за которыми Вы можете наблюдать, т.е. на них присутствуют Ваши корабли.
- Неизвестные планеты.
Список планет в пределах Вашей досягаемости, за которыми Вы не можете наблюдать. Указывается только номер планеты и ее координаты.
- Ваши флоты.
- Группы Ваших кораблей.
- Группы чужих кораблей.
Список групп кораблей, принадлежащих другим игрокам, за которыми Вы можете наблюдать.
- Неопознанные группы кораблей.
Список координат групп чужих кораблей, находящихся в гиперпространстве и не направляющиеся на Ваши планеты.
## Дипломатическая почта {#mail}
Игроки в Galaxy анонимны. Это означает, что никто, кроме администрации сервера, не знает адресов и имен других игроков. Это сделано для того, чтобы не переносить игровые отношения и конфликты на реальную жизнь и тем самым дать игрокам возможность вести себя менее скованно.
В процессе игры каждая из рас имеет возможность общаться с другими расами. Процесс общения происходит посредством пересылки дипломатических писем через сервер. Цель написания писем может быть самой разнообразной. Например для заключения союзов, совместных военных действий, разрыва союзов и т.п.
## Вопросы, на которые необходимо знать ответы {#questions}
1. В чем различие между промышленностью и производственным потенциалом? А между промышленностью и запасами промышленности?
2. Какую массу имеет корабль с параметрами D=0 A=20 W=5 S=0 C=0?
3. С какой скоростью будет лететь корабль с параметрами D=5 A=0 W=0 S=0 C=0, если его технологический уровень Двигателей равен 1.0?
4. На каком ходу корабль, имеющий скорость 18 св. лет за ход, прибудет к месту назначения, если расстояние между планетой отправки и планетой назначения 40 св. лет, а отправлен он был на 15 ходу?
5. Какой технологический уровень Двигателей будет иметь корабль с параметрами D=1 A=0 W=0 S=0 C=0, если до его постройки технологический уровень Двигателей был равен 1.8, а на другой планете одновременно с его постройкой технология Двигателей увеличилась на 0.2?
6. Какую массу будет иметь корабль с параметрами D=10 A=0 W=0 S=0 C=2 и технологическим уровнем Грузоперевозок 1.2, если его полностью загрузить?
7. Произойдет ли сражение, если на планете встретились корабль с параметрами D=1 A=0 W=0 S=0 C=0, принадлежащий расе A, и корабль с параметрами D=1 A=1 W=1 S=0 C=0, принадлежащий расе B, причем у расы B установлен "мир" с расой A, а у расы A - "война" с расой B?
## Некоторые советы {#tips}
На ранних стадиях игры нет необходимости сражаться за планеты. Следует начинать с постройки грузовых кораблей, накопления промышленности и доставки колонистов и промышленности на ближайшие необитаемые планеты. Разумно также установить контакты с другими расами с целью создания альянсов, которые очень пригодятся, когда настанет время сражений. Для этого просто необходимо осуществлять дипломатическую переписку. Не ведут переписки только обреченные расы.
Карта в Вашем отчете показывает только планеты, колонизированные чужими расами, и полную массу групп чужих кораблей, направляющихся к одной из Ваших планет. Чтобы иметь подробную информацию о вражеских флотах, которые могут угрожать Вашей безопасности, нужно посылать на чужие планеты корабли исключительно со шпионскими целями.
В случае приближающейся атаки на ваши планеты самое важное - убедиться, что для обороны достаточно сил. Для каждой такой группы разделите расстояние на скорость, чтобы получить количество ходов оставшихся до того, как группа достигнет планеты. Оцените полную массу: чем она больше, тем больше потенциальная угроза. Вы, конечно, не можете знать, огромный ли это линкор, или флот небольших истребителей, а, может, нечто среднее. Можно еще попытаться прибегнуть к дипломатии: владелец группы хоть и не может повернуть ее назад, но он может объявить себя в мире с Вами, так что группа не будет по прибытии атаковать Вас.
На более поздних стадиях игры, вполне вероятно, что одна из рас достигнет большего развития нежели остальные и займет доминирующую позицию в Галактике. С этого момента для остальных игроков жизненно важно немедленно отбросить в сторону все разногласия между собой и совместно атаковать эту расу. Ибо, если дать возможность этой расе захватывать расы одну за другой, она получит превосходный шанс победить в этой игре.
В силу специфики работы [алгоритма ведения сражений](#combat), обычно флот разделяется на три дополняющих друг друга части:
- много маленьких защитных кораблей прикрытия, для отвлечения пушек противника;
- один или более кораблей с несколькими маленькими орудиями, для скорейшего поражения маленьких кораблей прикрытия;
- один или более кораблей с одной или несколькими большими орудиями, для поражения основных вражеских кораблей.
Это необходимо учитывать при создании обороны планет.
## Пересчет ходов {#turn-recalc}
Как бы хорошо ни работал сервер и интернет, иногда возникает необходимость повторить процесс производства хода. Это исключение из общих правил работы сервера. Вот в каких случаях может произойти пересчет хода:
1. Найдена ошибка в логике сервера, повлекшая необратимые изменения в состоянии рас нескольких игроков.
2. Была нарушена система доставки приказов на сервер или отчётов от сервера на срок, превышающий время ожидания между двумя ходами, т.е. все игроки не имели возможности управления своими расами.
3. Произошло форс-мажорное событие и большинство участников партии однозначно пришли к выводу о необходимости пересчета хода.
4. Принято безапелляционное решение администрации на этот счет.
Каждый из участников может попросить администрацию о пересчете хода, сославшись на любой из этих пунктов и приведя соответствующие доказательства. И если администрация решит, что выполнено одно из указанных условий, ход будет пересчитан.
Ход не будет пересчитан, если условий не достаточно или есть возможность динамического исправления ситуации.
## Этика игры {#ethics}
Учтите прежде Всего, что "Galaxy" - это игра, не стоит отождествлять лидера какой-либо из рас и реального игрока. Не стоит обижаться, если кто-либо обошелся с Вами некорректно. Здесь допустимы обман и коварство, учтите это в Вашей политике. Администрация не рассматривает жалобы подобного рода и не принимает никаких санкций к игрокам, совершивших подобные "проступки".
Дипломатическая почта, циркулирующая между игроками, является личной почтой. Вы можете использовать в ней любые формы и выражения. Однако грубость недопустима в широковещательных сообщениях, даже если эта грубость часть Вашего имиджа. Кроме того, администрация сервера оставляет за собой право цензурировать сообщения, не являющиеся личной дипломатической почтой.
Сервер игры "Galaxy" - это программа, которая, к сожалению, может содержать ошибки. Мы будем благодарны Вам, если Вы сообщите о находках подобного рода. Однако, если Вы попытаетесь использовать найденную ошибку в собственных интересах, администрация сервера может принять решение об исключении Вас из партии.
+666
View File
@@ -0,0 +1,666 @@
# Galaxy Game
Player's guide.
## The turn cycle {#turn-cycle}
The whole game is divided into turns, which in turn alternate between two processes: "turn production" and "waiting for the next turn to be produced". Turn production is considered instantaneous — it takes no in-game time. It is the moment when everything actually happens: planets produce, the fleets of warring races fight [battles](#combat), and ships [move](#movement) from planet to planet. The full order of these steps is described in [Turn sequence](#turn-sequence).
There is also the notion of "game state", which reflects the current situation (who owns what, who is doing what, which ships are flying where, and so on). The game state corresponds to the report a player receives right after each turn is produced.
Turn production happens regularly at a pre-arranged time known to all participants. It ends with reports being sent to everyone, describing the state of their races for that turn. After that the waiting period for the next turn begins — its purpose is to give players time to think over their orders for the upcoming turn and thereby change the game state.
A command only declares an action (for example, the production type on a planet); it does not perform that action immediately. You can order a ship to be built, but it is built only during the next turn production. You can order ships to depart somewhere, but they fly only during turn production.
When an order (a sequence of commands) reaches the server, the player is notified that the commands have been accepted. Each command in the order is checked for validity and acknowledged separately. A player may send as many orders as they like, but each new order replaces the previous one. This lets you correct a faulty order, but you must repeat the commands that were already correct. Fortunately, the client program helps the player keep track of this and ensures the orders stay consistent.
During the waiting period you can also exchange diplomatic mail between races. All turns are numbered, which makes planning your actions easier.
The game begins with the production of turn number zero, during which the galaxy itself is created (planets are placed, participants receive their developed planets, and so on). Reports describing each race's initial state are then sent out. Having received such a report, each participant should study it carefully and, based on it, design (or adjust) a development plan for their race. Note that from the moment the reports for turn N are sent until the reports for turn N+1 are sent, turn N is in progress (the wait for turn N+1 to be produced).
For a fuller understanding, here is an example of how the server works:
- reports for turn N are sent to everyone (turn N has begun);
- orders are accepted, order acknowledgements are sent according to the commands received, diplomatic mail and news are distributed, and so on (turn N continues — the "wait for the next turn" is under way);
- according to the schedule set when the game was assembled, the server produces the new turn N+1 (turn production happens);
- reports for turn N+1 are sent to everyone (turn N+1 has begun).
## The galaxy {#galaxy}
The space of the galaxy where the action unfolds is the surface of a torus — visually just a square whose opposite sides are joined. The galaxy contains a number of star systems. Although any star system could contain several planets and other bodies, that level of detail is irrelevant to the game: resource extraction uses the combined resources of the whole star system, and travel is organised as movement between centres of mass in the galaxy.
For simplicity, then, the game uses the term "planet" where it might really mean "star system".
## Units of measurement {#units}
The game's units correspond to real ones. Distances between planets are measured in light-years. Planet sizes are measured in tens of kilometres of diameter. Each unit of population stands for 10 million people, and each unit of goods, material, and so on stands for 10 million tons. Each turn of the game corresponds to one year in the life of the galaxy. In most cases, instead of quoting real units it is more convenient to use the phrase "unit of measurement" — for example, "5 units of mass" or "10 units of population".
## Numeric precision {#precision}
Numbers used in commands and reports are shown to three decimal places. The server stores numbers and performs calculations at higher precision, rounding the results only for presentation to players in reports. For example, if a report shows 2.000, the real value may range anywhere from 1.9995 to 2.0004.
Ship technology levels are the exception — reports show them without such rounding (see [Technologies](#tech)).
## Names {#names}
Ship classes, planets, fleets, and sciences can have arbitrary names chosen by the player. Names may be at most 30 characters long. Characters may be letters of the alphabet, digits, and the special characters `!@#$%^*-_=+~()[]{}`. Special characters may not appear at the start or end of a name, nor be repeated more than twice in a row.
When choosing a name, the player is advised to use common sense first and foremost and not to abuse the ability to create unreadable names. While this is not outright forbidden, such a way of communicating with the outside world will not look its best in the eyes of other participants, since many of the names a player picks are seen by the whole galaxy. Do not use wording in names that grossly insults any player or incites ethnic or religious hatred (in the real world, of course, not the game one). The game administration reserves the right to take measures, up to removing a player from the server, for abuse of names.
## Planets {#planets}
Each race begins the game owning one or three planets (depending on the game type); all the other planets are uninhabited. During the game you may both settle uninhabited planets and conquer planets settled by other races.
At the start of the game all planets have unique names. After colonising a planet you may rename it. You may also wish to rename your first planets right at the start to give them a more intriguing look.
Every planet has two unchangeable characteristics: size and natural resources. In a standard game each participant starts owning one planet of size 1000 (called a "Home World", HW) and two planets of size 500 each (called "Daughter Worlds", DW). Players' HWs are at least 30 light-years apart from one another; DWs lie 5 to 15 light-years from their HW.
All the other planets have no fixed characteristics and occur in the galaxy in the following proportions:
| planet type | size | resources | approximate share of all planets |
|---|---|---|---|
| Super-large planets | 1500-2500 | 0-3 | 6% |
| Merely large planets | 1000-2000 | 1-10 | 18% |
| Ordinary planets | 0-1000 | 0.1-10 | 50% |
| Small but fabulously rich | 0-500 | 5-25 | 18% |
| Asteroids | 0 | 0 | 8% |
Super-large planets may only occur at least 20 light-years from an HW and from one another; merely large ones, at least 10 light-years. As you can see, the characteristic ranges of super-large and merely large planets overlap. This is not a mistake but reality. On average there are 10 planets per participant in the galaxy (including the three starting ones).
A planet's other characteristics may change over the course of the game, both as a result of players' actions and through the planet's natural development. These characteristics include:
- Population
- Colonists — surplus population.
- Material
- Industry
## Population {#population}
Every planet has a "Size" attribute. It reflects not only the planet's physical size but also the presence of mountains, deserts, and oceans, the climate, and so on. A planet's population cannot exceed its size, but may be lower. Your first planet has size and population both equal to 1000. A planet's population grows by 8% each turn. The part of the population that exceeds the planet's size turns into [colonists](#colonists). Remember that a planet's population limits the growth of its [industry](#industry): the number of industry units cannot exceed the number of population units.
## Colonists {#colonists}
Every 8 units of population exceeding the planet's size automatically turn into one unit of colonists. These inhabitants are kept in containers at low temperature. If colonists are carried to other planets that have room to settle, they are automatically thawed and added to the planet's population. This is how uninhabited planets can be settled. Each unit of colonists yields 8 units of population again. Modern technology lets colonists be frozen and thawed without any production or material cost.
## Industry {#industry}
A planet's industry level (or "amount of industry") corresponds to things like tools, computers, transport, and so on. At the start of the game all your planets have the maximum industry level, equal to their population (1000 for the HW and 500 for the DWs).
## Production on a planet {#production}
Available production units may be spent on extracting material, building ships, producing industry, or researching technologies. A single planet may produce only one kind of product per turn (for example, only one type of ship).
## Production units {#production-capacity}
Each inhabited planet has a certain production capacity, expressed in production units. It cannot exceed the planet's population, but may be lower. Production capacity determines the planet's output and is made up of the productive power of its population and its industry level.
$$\text{Production capacity} = \text{industry} \times 0.75 + \text{population} \times 0.25$$
At the start of the game you have one planet with a production capacity of 1000 and two with a capacity of 500 each. This means that every turn you have 1000, 500, and another 500 production units that you can put to work however you like. If your planet has 500 units of industry and a population of 1000, it can produce only about 625 units of the chosen product per turn. In other words, your planet has a production capacity of 625 production units.
## Technologies {#tech}
You start at technology level 1 in the following fields: Drive, Weapons, Shields, and Cargo. These levels can be raised by switching a planet's production to research. To raise a technology level by one, you must spend 5000 production units. Fractional research spending is certainly useful: if you spend 500 units on Weapons research, your level in that field rises by one tenth, and this takes immediate effect when building ships — there is no need to wait for the level to rise by a whole unit. When a ship is built, it receives the technology levels you had at the moment turn production began (see [Turn sequence](#turn-sequence)). The technology levels of ships already built can later be raised with the upgrade command.
To avoid inaccuracies in calculations, ship technology levels are rounded to three decimal places at the moment of building or upgrading. Reports therefore always show ships' actual, not rounded, technology levels.
By default, all players' starting planets are set to research the "Drive" technology.
## Sciences {#sciences}
You can combine technologies into sciences. Each science consists of known technologies taken in proportions you define. When you switch a planet's production to research in a science you have defined, production units are spent on the technologies that make up that science, in the proportion you set. The shares of technologies in a science are given as fractions of one, and they sum to one (100%).
For example, a science named "First Step" is defined by the shares 0.222 for Drive, 0.111 for Weapons, 0.667 for Shields, and 0 for Cargo (summing to one; this corresponds to the ratio 10 : 5 : 30 : 0). When researching such a science, 22.2% of the planet's available production units go to Drive research, 11.1% to Weapons, and 66.7% to Shields. In this way, in a single turn on a single planet you can raise several technology levels at once.
## Material {#material}
Producing anything other than technologies requires material as well as production. Material corresponds to things like sheet steel, copper wire, timber, and oil needed for production. Every planet may hold a stock of produced or delivered material that can be used when building ships. If no such stock exists, some of the production units can be directed to producing material.
As noted, every planet has an unchangeable characteristic — Natural Resources — that shows how rich the planet is in metals, coal, oil, and so on. Planets with a high Resources rating need less production spent on making material. The rating depends on the [planet type](#planets): for inhabited planets it is strictly above zero, reaching 25 on rare richly endowed planets, and is zero only on asteroids. Your first planets have a resources rating of 10, which means each production unit can produce 10 units of material. A planet with a resources rating of 0.1 can produce only 0.1 units of material per production unit. Produced material is stockpiled and can be transported to other planets by cargo ships.
When you colonise planets with a low natural-resources rating, you should produce material on high-rating planets and then carry it to the others, so that you do not spend a lot of production units extracting material. The amount of material on a planet can also be increased by scrapping ships located there. In that case each unit of mass of the scrapped ships turns into one unit of material.
For example, suppose you have set production to build spaceships. Building requires an amount of material equal to the ship's mass. If you start with no material stock, it is produced automatically. This process is entirely invisible to you; the only noticeable effect is that in places the production output is lower than you expected.
In other words, what remains for building a ship is as much production as the planet can give, minus the amount spent on extracting the material needed for building. For this reason, keep in mind that when building ships not all of a planet's production units go directly into building — some have to extract the material that is missing for it. In practice this condition is one of the most important when designing ships, because it directly determines how long a given class of ship takes to build on a chosen planet.
## Producing industry {#industry-production}
As planets develop, the amount of industry can be raised by switching the planet's production to industry. One unit of industry requires 5 production units and one unit of [material](#material); if there is no material stock, the missing amount is extracted automatically from the same production units (as when [building ships](#ship-building)) at the planet's resources "rate" — so on resource-poor planets industry grows more slowly.
A planet's industry level cannot exceed its population. While industry is below the population, the industry produced immediately raises the [production capacity](#production-capacity). Once industry reaches the population, the surplus is not lost but set aside as an industry reserve (shown in the report as "industry reserve", `$`). This reserve can be carried between planets; and as soon as a planet's industry drops below its population again — whether because the population has grown or because the reserve was brought to a young colony — it is automatically turned into industry and raises the level. This is how colonised planets develop quickly.
## Designing ships {#ship-design}
Before issuing commands to build ships on planets, you must design the ship classes you need. There are no ready-made ship classes; each race in the galaxy designs its own according to its strategic goals. To create a ship class, give it a name and define the following characteristics:
| Parameter | Description |
|---|---|
| Drive | hyperdrive power |
| Armament | number of weapon mounts |
| Weapons | weapon power |
| Shields | shield-generator power |
| Cargo | cargo-hold size |
Each characteristic you choose may be either 0 or at least 1. Of course, not all of a ship's characteristics may be zero at once. Armament and Weapons must both be zero or both non-zero. Armament is an integer; the rest may be fractional. For example, a ship may have Shields of 1.5 but not 0.5.
Designing ship classes takes no time or resources; a new class becomes available immediately after the corresponding command. For how a ship's characteristics affect the game, see [Movement](#movement), [Cargo capacity](#cargo), and [Battles](#combat).
Here are a few example ship classes. Although such classes may be found in the galaxy among various races, you are not required to design ships with exactly these characteristics — what matters far more is the tasks your ships will perform.
| Name | D | A | W | S | C |
|---|---|---|---|---|---|
| Drone | 1 | 0 | 0 | 0 | 0 |
| Fighter | 1 | 1 | 1 | 1 | 0 |
| Gunship | 4 | 2 | 2 | 4 | 0 |
| Destroyer | 6 | 1 | 8 | 4 | 0 |
| Cruiser | 15 | 1 | 15 | 15 | 0 |
| Battle_Cruiser | 30 | 3 | 10 | 30 | 0 |
| Battleship | 25 | 1 | 30 | 35 | 0 |
| Battle_Station | 60 | 3 | 30 | 100 | 0 |
| Orbital_Fort | 0 | 3 | 30 | 100 | 0 |
| Space_Gun | 0 | 1 | 1 | 0 | 0 |
| Freighter | 8 | 0 | 0 | 2 | 10 |
| Megafreighter | 80 | 2 | 2 | 30 | 100 |
## Building ships {#ship-building}
Ships are built with the technology levels the race had at the start of the turn. In other words, in the next report the just-built ships carry the previous turn's technology levels.
An unarmed ship has a mass equal to Drive + Shields + Cargo, as set when designing it. A ship with one gun has a mass equal to Drive + Weapons + Shields + Cargo. For ships carrying several guns, each gun after the first adds mass equal to half of Weapons.
The masses of some of the ships above:
- Freighter: 20 units of mass.
- Cruiser: 45 units of mass.
- Gunship: 11 units of mass.
You can set a planet to build ships of a class you designed earlier. Building a ship requires an amount of material equal to the ship's mass, plus 10 production units for each unit of the ship's mass.
For example, suppose your HW builds "Drone"-class ships from the list above and there is enough material stock; then you can produce 100 of them (with no material stock, a little over 99 ships are produced). However, if you build a Battleship, you can produce only 10/9 of a ship per turn. After the first turn one ship is fully built and 1/9 is in progress. After the second turn 2 ships are in orbit and 2/9 are unfinished. If you then switch production to another ship type or something else entirely, those 2/9 are scrapped and added to the material stock. The material stock grows by exactly as much material as was used in building those 2/9 of a ship. The production units that were spent on building the ship itself are lost for good, as wasted labour.
As you can see, it is inefficient to switch production often when building large ships. It is also clear that the economical approach is to build ships whose mass is a multiple (or an approximate multiple) of the mass a given planet can produce in one turn.
A planet with industry 1000, resources 10, and no material stock can produce 99.0099 units of mass per turn. It is sensible to build ships of mass 99.00, 11.00, 198.01, or 297.02 on such a planet. And it is very inefficient, though possible, to try to build something of mass 140, say.
Note that material is spent only at the very end of building a ship. So for ships meant to take a long time to build (more than one turn), the required material can be delivered to the planet throughout the build.
Correctly computing the ship you build is one of the most important tasks in the game. You must understand the whole calculation thoroughly. For instance, if ships that by your reckoning should have reached the target planet on a certain turn end up a negligible 0.001 light-years (or less) short and fail to arrive, it means you miscalculated the speed/mass/etc.
## Ship groups {#ship-groups}
In the late game you may have hundreds or even thousands of ships, which would be extremely awkward to manage individually. For this reason ships are combined into groups. All commands for manipulating previously built ships operate on ship groups, even if a group contains only one ship. You can load a group with cargo, send it to another planet, transfer it to another race, and so on.
A group is some number of ships of the same class, in the same place, carrying the same amount of the same cargo type, belonging to the same fleet, and having the same technology levels. Ships lacking a component are marked as having technology level 0 for that component; for example, unarmed ships always have technology level 0 for Weapons.
When you need to issue a command for fewer ships than the group contains (for example, send 8 of 10 ships to another planet), you must first split those ships off into a separate, new group. The client program simplifies this for the player, but remember that in the order such an action consists of two commands: first the ships are split into a new group, then the action is applied to the new group. Equivalent groups are merged automatically before each turn, or on the player's command.
## Transferring ships between races {#ship-transfer}
During the game you can transfer ship groups between races. If the receiving race already has a ship class with the same name but different characteristics, it also receives a new ship class whose name has a random suffix appended.
## Upgrading ships {#ship-upgrade}
As a race's technology levels advance, previously built ships may become obsolete and no longer match the race's current capabilities. Such ships can be upgraded to the required technology levels.
Keep in mind that the ship-upgrade process completes only by the end of the turn, so ships ordered to upgrade will first take part in [battles](#combat) if enemy ships happen to be in the same orbit.
To perform an upgrade, the ship group must be on one of your planets. The ships in that group are upgraded to your current technologies (if they already have your latest levels, nothing happens). You can also cap the technology level the upgrade goes up to, by specifying a target upgrade level.
Of course, upgrading ships has its price. The cost of upgrading a ship equals a partial cost of building a new one. For example, if a ship has technologies equal to 2/3 of the required ones, the upgrade cost is 1/3 of the cost of building a new ship. Upgrading is more economical than building anew, since it requires no material. Each of a ship's blocks can be upgraded separately. The final formula for the cost of upgrading one block of one ship is:
$$\left(1 - \frac{\text{current tech}}{\text{target tech}}\right) \times 10 \times \text{block mass}$$
> For example, upgrading a "Cruiser" with all unit technologies up to technology level 2.0 requires 225 production units for a full upgrade.
It is easy to see that the upgrade cost is higher the larger the gap between the ship's current technology and the target technology.
You can upgrade either the whole group or only the number of ships needed. If a planet does not have enough production units for a full upgrade of even one ship, the specified technologies of one ship are raised as far as possible (and for a combined upgrade, all the ship's technologies are raised in proportion to the masses of the corresponding components). This way you can upgrade ships over several turns, issuing the upgrade command each turn.
Of course, the production units spent on upgrading ships do not take part in the planet's production process during that turn. So if there are not enough production units for a full upgrade of the specified group, the planet's entire stock of production units is used and it will be unable to produce anything that turn.
A planet can upgrade any number of ship groups while free production units remain. Production units left over after upgrades are not spent on a partial upgrade but stay free for further production.
When upgrading several groups on a planet, the groups with the highest cost are upgraded first, provided the planet has enough production units to upgrade each one. If there are not enough production units for a particular group (for example, an enemy bombardment occurred), it keeps its original technology levels.
## Scrapping ships {#ship-scrap}
Ships in orbit of a planet can be broken down into their constituent materials. The material stock of the planet where the ships were located is increased by the mass of those ships. If the ships carried any cargo, it is first unloaded onto the planet. Colonists are a special case: over your own planet they are unloaded and add to the population; over an uninhabited planet they are unloaded and settle it (the planet becomes yours); but over a foreign planet colonists cannot be unloaded and remain frozen forever in the reaches of the galaxy.
## Fleets {#fleets}
Fleets may be made up of ship groups of different types. The move command applies to a fleet just as it does to a single group. Unlike standalone groups, the groups within a fleet do not travel along the cargo routes set for planets. A fleet's speed equals the speed of its slowest group. Loading and unloading ships in a fleet can only affect the fleet's speed. If you split some number of ships off a group that belongs to a fleet, those split-off ships do not belong to the fleet. A fleet exists as long as it contains at least one group. Groups can be transferred between fleets, and fleets can merge into one, but only if the fleets are on the same planet.
## Movement {#movement}
The physical laws obeyed by ships travelling through hyperspace state that movement is possible only from one large centre of mass to another. This means you can send ships only from one planet to another. You cannot send a ship to some arbitrary point in space. Once ships are in hyperspace, they can no longer change course, turn back, change speed, or be attacked.
Spaceships are fitted with a hyperdrive whose effectiveness equals the Drive power multiplied by the current technology level of the Drive block. Ships with drive power 0 stay forever in the orbit of the planet where they were built. This does not mean such ships should not be built — on the contrary, they can be an excellent means of defending planets from enemy ships.
In one turn, ships travel a number of light-years equal to the drive effectiveness multiplied by 20 and divided by the ship's full mass.
"Full mass" means the mass of the ship itself plus the mass of the cargo it carries. The "mass of carried cargo" differs from plain "cargo mass" in that "cargo mass" is the total number of cargo units, whereas the "mass of carried cargo" is the total number of cargo units divided by the Cargo technology level — that is, a smaller value.
Consequently, transports move faster when they carry no cargo. When the Drive technology level is low, large ships must have Drives to match their mass, or they will be very slow. The fastest ships can move at a speed of:
$$20 \times \text{Drive tech level}$$
The exception to the general speed rules is ships that belong to a fleet you have formed. In that case, regardless of the ships' own capabilities, their speed equals that of the fleet's slowest group.
To travel from one planet to another, it takes as many turns as the sent group of ships needs to cover the distance between those planets.
A race's drive technology also determines its ships' free-flight zone (the maximum flight range of ships from the race's own planets). Any ship may fly to any planet located no farther from the race's planets than:
$$40 \times \text{Drive tech level}$$
If a race has no planets left, its remaining ships cannot leave their location, because there is no way to compute the maximum flight distance.
If a ship transferred to another race is outside that race's reach, it either continues its flight or can be sent to a planet within the owner race's reach.
In reports you can get the direction and speed of foreign ships, but only when they are heading to one of your planets. Otherwise you can only get the coordinates of the centre of mass of foreign groups moving through hyperspace no farther than
$$30 \times \text{your Drive tech level}$$
from the nearest planet you own. Ships beyond the visibility zone do not appear in your reports at all, even if they are flying to one of your planets.
Remember, too, that a race can observe events on a planet it does not own only when its ships are present on that planet. If all ships are sent away from such a planet, the planet's state immediately leaves your view.
## Cargo capacity {#cargo}
A ship's cargo capacity is the size of its cargo hold. The amount of cargo a ship can carry is computed by the formula:
$$\text{Cargo tech} \times \left(\text{Cargo size} + \frac{\text{Cargo size}^2}{20}\right)$$
where "Cargo technology level" means the ship's current Cargo technology level, and Cargo size is set when designing the ship.
A few examples of ship cargo capacities at Cargo technology 1.0:
| Cargo size | Cargo amount |
|---|---|
| 1 | 1.05 |
| 5 | 6.25 |
| 10 | 15.00 |
| 50 | 175.00 |
| 100 | 600.00 |
At Cargo technology 2.0 these figures double, and so on. Note that large transports can carry a great deal of cargo, but when fully loaded they move very slowly (for example, a fully loaded Megafreighter at Drive technology 1 has a speed of only 1.97 light-years per turn).
The low speed of heavily loaded ships can, generally, be compensated by a higher Cargo technology. At technology level 2.0, the mass of any cargo on board is counted as half the normal mass used to compute the ship's speed and shield power (see [Battles](#combat)). At level 3.0, the cargo mass is divided by 3 in the calculations, and so on. For example, a Freighter at Cargo technology 1 can carry 15 units of cargo. At level 2.0 the cargo amount rises to 30 units, yet they slow the ship exactly like 15 units of cargo at Cargo technology 1.0. So at level 2.0 with 30 units loaded, a Freighter moves just as fast (for a given Drive level) as a Freighter loaded with 15 units at Cargo technology 1.0.
In other words, Cargo technology determines how compactly cargo is stowed on board. The "mass of carried cargo" equals the cargo amount divided by the Cargo technology level — so the higher this technology, the less the same cargo weighs in the calculations, the less it slows the ship, and the less it weakens its defence in [battle](#combat).
A ship can carry only one type of cargo at a time. The possible cargo types are colonists, material, and industry. Cargo can be loaded aboard from your own or an unoccupied planet that has it. Industry and material can be unloaded on any planet. Colonists can be landed only on planets you own or on uninhabited planets.
## Cargo routes {#routes}
To move cargo between planets, you can set up cargo routes instead of doing it by hand. A cargo route from planet A to planet B with a given cargo type means the server will try to deliver that cargo from A to B using all available transport ships. So once such a route is set, any unloaded ship on planet A is loaded each turn (provided cargo of the right type is present there) and sent to planet B. Any ship arriving at planet B with cargo of the right type is automatically unloaded (even if it did not come from A).
You can set up to 4 cargo routes for each planet you own: one per cargo type, plus one for empty ships, which is useful for returning transports from resource-consuming planets to the ones that produce. You can set cargo routes only from planets you own, but those routes may lead to any planet, so you can transport colonists to uninhabited planets this way.
If several route types are set from a planet, ships are loaded and dispatched in this order: colonists first, then industry, then material, and finally empty ships. When there is a surplus of a particular cargo type on a planet, ship groups are loaded in descending order of their hold size.
Ships that belong to a fleet are a special case. Such ships are assumed to be assigned to some special mission and are not subject to following the cargo routes set up.
A cargo route can only be set to a planet that lies within the ships' [free-flight zone](#movement).
## Battles {#combat}
When armed ships of warring races meet on a planet, a battle occurs. If one side is the aggressor, the other side joins the battle too, even if it is at peace with the aggressor. This does not mean the attackers have the right of the first shot: all the fighting sides are on completely equal terms, and the one who is luckier shoots first. Ships ordered to depart, as well as ships dispatched along cargo routes, are still at the planet of departure at the start of the turn and take part in the battle there — only the ships that survive it enter hyperspace. A ship already in hyperspace does not take part in battles until it arrives at its destination planet (for the full order, see [Turn sequence](#turn-sequence)).
In each round of a battle, every ship gets a chance to fire at the enemy — provided, of course, that its opponents were not luckier in the same round and did not manage to destroy the ship awaiting its turn to attack.
At the start of a round one ship is chosen at random from the battle's participants. It picks an enemy ship at random as its target and fires at it. The target may or may not be destroyed, depending on weapons, shields, and Fortune. The attacking ship keeps firing at random targets until all its guns have fired and while targets it can hit remain.
Then another ship is chosen at random — one that has not yet fired this round and has a chance to hit an enemy ship. This continues until every ship has fired in the round. If, after that, ships able to hit one another remain, a new round of the battle begins.
A battle ends when none of the warring ships has a target it is able to hit. This may happen, for example, when a small fighter meets a huge but unarmed transport whose shields it cannot pierce.
The formula for the probability of destroying a ship:
$$\frac{\log_4\!\left(\dfrac{\text{Weapons} \cdot \text{Weapons tech}}{\text{Shields} \cdot \text{Shields tech} / \sqrt[3]{\text{mass}} \cdot \sqrt[3]{30}}\right) + 1}{2}$$
> Note: $\log_4(a)$ is the base-4 logarithm of a; $X^Y$ is X to the power Y; the term Weapons refers to the firing ship, while Shields and mass refer to the target ship.
The attack effectiveness equals Weapons multiplied by its technology level. The defence effectiveness equals Shields multiplied by the Shields technology level and divided by the target ship's diameter, which equals the cube root of its mass. This is only natural, since larger ships must protect a larger surface and, all else being equal, are weaker. A ship with D=8 A=1 W=8 S=8 C=0 will have only 4 times the effective defence of a ship with D=1 A=1 W=1 S=1 C=0, even though its Shields are 8 times larger.
The parameters are chosen so that a ship with D=10 A=1 W=10 S=10 C=0, firing at an identical ship, destroys the target with a probability of 50%. Working it out, that ship's defence is clearly about 3.21. To equalise the attack and defence probabilities, the defence is normalised — multiplied by about 3.11 — so that one unit of shields provides one unit of protection. If the attack effectiveness is 4 times higher than the normalised defence, the target is always destroyed. If the normalised defence is 4 times higher, the attack always fails.
Note that any cargo aboard a ship increases its [full mass](#movement) when computing the ship's shield power, since the shield generator must protect the cargo as well as the ship itself. In other words, for a transport loaded with a known amount of cargo, the lower the [Cargo](#cargo) technology level, the weaker the defence. And if such a ship is destroyed in battle, the cargo it carried perishes with it: with each ship lost, the group's cargo decreases in proportion to the number of survivors.
If an armed ship remains at an enemy planet after the battle ends, it begins to bombard the planet, destroying its population and industry in proportion to its weapon power.
## Planet bombardment {#bombing}
Enemy ships in orbit of an inhabited planet bombard it in order to capture it through subsequent colonisation. The mechanism is as follows. The planet's population and colonists are destroyed in an amount equal to the combined bombardment power of all the attacking groups. The same amount of industry is converted into material. The bombardment power of one group is computed as:
$$\left(\frac{\sqrt{\text{Weapons} \cdot \text{Weapons tech}}}{10} + 1\right) \cdot \text{Weapons} \cdot \text{Weapons tech} \cdot \text{Armament} \cdot \text{ships in group}$$
Thus one Battle_Station at Weapons technology 1.0 has a bombardment power of 139.30. This means that in one turn, bombarding a planet, such a ship can destroy 139.30 units of population, 139.30 units of colonists, and convert 139.30 units of industry into material. Two such ships have a power of 278.60.
Bombardment, unlike battles, does not happen in rounds — each attacking ship group gets to attack the planet only once, with the forces it has.
The order of bombardment also does not matter, because the planet is attacked simultaneously by all groups, starting with the highest bombardment power, while the planet still has population.
If population remains after the bombardment, the planet keeps producing and may, by the next turn, build a ship, for example. If colonists also remain, they turn into population, and the accumulated industry makes up for the lost production.
If no population remains after the bombardment, the planet becomes uninhabited and can be colonised anew. This is how you can capture planets belonging to other races. All material and all industry reserves left on the planet after the bombardment are preserved and pass to the new owner who colonises it. The colonists who were on the planet also die, since there is neither active population to thaw them nor industry to sustain them.
## Colonising planets {#colonization}
Any uninhabited planet can be colonised — that is, settled and thereby added to a race's holdings. This happens when colonists land on an uninhabited planet.
If ships of several races loaded with colonists arrive at an uninhabited planet at the same time, and those races have cargo routes set to deliver colonists to that planet (or several players issued a colonist-unload command), the right to settle the planet is decided in this order:
- the largest amount of colonists being unloaded;
- the largest race population;
- a random choice among the contenders.
The colonists of races that did not win the planet are not lost: only the winner unloads, while the other contenders' colonists stay in their ships' holds and can be sent to another planet. Of course, if a race colonising a planet failed to take it into possession, all subsequent commands that relied on your colonists having been unloaded there may be cancelled during turn production. If you are not sure the planet will be colonised by you, it is worth entering into diplomatic correspondence, making treaties, selling the right to colonise for material gain, and so on.
Remember that fleets do not obey cargo routes; therefore colonists aboard fleet ships do not count toward the total number of colonists for priority landing at the ends of routes.
By default, colonised planets are set to produce industry.
## War and peace {#war-peace}
At the start of the game you are assumed to be at war with all the other races. You may make peace with another race at any time. This means your ships will neither fire at that race's ships nor bombard its planets. However, that race may still be at war with you, and until it makes peace with you in turn, its ships may still attack you. While at peace, you may declare war again at any time, and vice versa.
Your report shows your diplomatic status toward each race, but this in no way shows how the other races regard you. You do not know how they feel about you until you meet one of their warships. Only after a foreign race's warship has not fired at your ships during the turn can you consider that race to be at peace with you.
Of course, you can make sure a given race's intentions toward you are purely peaceful through diplomatic correspondence — concluding, say, a non-aggression pact until a certain turn, joining an alliance until the end of the game, and so on. But do not forget that the galaxy has room in equal measure for valour and for treachery.
## Leaving the game {#exit}
A race is considered completely dead if it owns no planets and no ships. If a race reaches such a state, it is removed from the list of participating races before the next turn is computed. So if a race has lost all its planets and ships, friends may well come to its aid by transferring ships into its possession before turn production.
Any race may use the early-exit command. After issuing it, the race is removed from the game in 3 turns. The exit command must be the last one in the order.
There is also a way to force-end the game for a race. If a race has not sent orders to the server for 10 consecutive turns (diplomatic mail does not count), that race is removed from the game.
Any race may also be removed from the game at any time by a decision of the administration, for gross violations.
On removal from the game, all ship groups belonging to the race are deleted, and all its planets become uninhabited and lose all their industry, while materials remain. Races that have left the game cannot be restored to the participant lists.
5 turns before a forced removal, the race begins to receive a warning with every new report.
3 turns before removal, every following report tells all participants about the coming departure of that race from the game. Any command reaching the server from a still-living race switches off the race's exit mechanism (this does not apply to diplomatic mail).
## Victory and defeat {#victory}
Each of your reports, after the turn is computed, shows in the race-status list the number of votes received by each race during the turn. The total number of votes is also given in the report. Every thousand units of population on a race's planets grants one vote. If at the start of the game each race has one fully developed planet of size 1000 and two of 500, then each race has two votes. Through colonising other planets and developing them, each race can increase its number of votes.
Voting happens during turn production. Between turns, each race may change whom it votes for. If several races give their votes to one another in a chain, those races are considered to be in an alliance. An alliance's vote count is the simple sum of the votes of all alliance members, not counting the votes of the other races that voted for alliance members but are not part of the alliance.
The winner is the race (or alliance) that gathers 2/3 of the total votes of all races. In principle it is possible to end the game in the very first turns if 2/3 of all races pick the one worthy race and vote for it or form an alliance — but is it worth playing for that? The game may also be ended by an unappealable decision of the administration.
## Turn sequence {#turn-sequence}
Once orders have been received from all races, the turn itself happens — that is, the following sequence of actions:
- Ships are handed to their new owners.
- Races that have left the game are freed of their property.
- All commands issued by races are carried out. In particular, ships are unloaded according to the orders, and ships may be ordered to depart: such ships become ready to depart but physically stay at the planet of departure.
- Ships are merged into groups where possible.
- Warring ships join battle at the planets of departure. Ships ordered to depart are still at the planet and take part in this battle; only the survivors enter hyperspace.
- Goods are loaded onto ships at the start of cargo routes (after the battle — route transports fight unloaded).
- Ships (those ready to depart and those equipped along routes) enter hyperspace and fly through it.
- Ships are merged into groups where possible.
- Warring ships join battle (after leaving hyperspace, at the destination planets).
- Ships bombard enemy planets.
- Ships are upgraded on planets.
- Ships are built on planets (using the production capacity left over from ship upgrades).
- Ships are merged into groups where possible.
- Planets produce industry, extract material, and research new technologies.
- Planet populations grow.
- Ships are unloaded at the ends of cargo routes.
- Unloaded colonists increase the planet's population (if the planet's population is below its size).
- Accumulated and unloaded industry raises the planet's production level (if the planet's production level is below its population).
- Routes that go beyond the ships' flight zone are cancelled.
- Voting takes place.
## The turn report {#report}
The report you receive after each turn contains the necessary and sufficient information about the state of the galaxy, subject to the visibility of actions available to you.
The list of report sections is given below.
- Galaxy size, number of planets in the galaxy, and number of remaining races.
- A warning that your race is about to be removed from the game for inactivity, when no more than 5 turns remain until removal: the number of remaining turns is shown (the mechanism is described in [Leaving the game](#exit)).
- Races leaving the game soon: races with no more than 3 turns left until forced removal for inactivity; this list is visible to all participants.
- Your total number of votes.
- The name of the race you give your votes to.
- Player status.
| Code | Meaning |
|---|---|
| N | Name |
| D | Drive technology level |
| W | Weapons technology level |
| S | Shields technology level |
| C | Cargo technology level |
| P | Total population |
| I | Total production |
| # | Number of planets owned |
| R | War or peace (your stance toward the listed race, but not the reverse) |
| V | Number of votes given to the race |
- Your sciences.
| Code | Meaning |
|---|---|
| N | Science name |
| D | Drive technology share |
| W | Weapons technology share |
| S | Shields technology share |
| C | Cargo technology share |
- Foreign sciences.
A foreign race's list of sciences is available when your ships are on one of that race's planets where technologies are being researched using those sciences.
- Your ship classes.
| Code | Meaning |
|---|---|
| N | Name |
| D | Drive |
| A | Armament |
| W | Weapons |
| S | Shields |
| C | Cargo size |
| M | Mass of one ship of this type |
- Foreign ship classes.
A description of each foreign ship class you encountered this turn.
- Battles.
A description of all the [battles](#combat) you took part in or witnessed this turn. For each battle, a list of the groups present at the battle site at its start is given, followed by a description of the exchange of fire. Additionally noted:
- The number of ships in the group not destroyed during the battle;
- The participant's battle status: "In_Battle" or "Out_Battle", where the latter means the group did not take part in the battle but was only a witness to events. This is possible if the group had no enemies.
- Bombardments.
A list of planets that were bombarded this turn within your visibility, with attack information:
| Code | Meaning |
|---|---|
| W | Name of the bombarding race |
| O | Name of the owner race |
| N | Planet name |
| P | Population |
| I | Production capacity |
| P | Production type |
| $ | Industry reserve |
| M | Material reserve |
| C | Number of colonists |
| A | Attack power |
| (blank) | State after the bombardment: "Damaged"/"Wiped" |
- Approaching groups.
A list of all foreign ship groups in hyperspace heading to your planets.
| Code | Meaning |
|---|---|
| O | From (which planet the group was sent from) |
| D | To (which planet the group is heading to) |
| R | Remaining distance |
| S | Speed |
| M | Full mass |
- Your planets.
A list of all your planets. The following information is given:
| Code | Meaning |
|---|---|
| # | Galactic planet number |
| X | X coordinate |
| Y | Y coordinate |
| N | Planet name |
| S | Size |
| P | Population |
| I | Production capacity |
| R | Natural resources |
| P | Production type (industry, material, research, or ships) |
| $ | Industry reserve |
| M | Material reserve |
| C | Number of colonists |
| L | Free production capacity |
The (L) parameter is used to determine the real industrial capacity for the current turn.
- Ships in production.
| Code | Meaning |
|---|---|
| # | Galactic planet number |
| N | Planet name |
| S | Name of the ship type being built |
| C | Cost of building one such ship (in production units), excluding material-extraction costs |
| P | How many production units have already been spent building this ship (already including material production) |
| L | Free production capacity |
Note that the build cost of one ship excludes material-extraction costs, whereas the number of production units spent already includes material-extraction costs. So it is perfectly normal for (P) to slightly exceed (C). This only means that the material needed to build the ship has not yet been produced.
- Cargo routes.
- Someone's planets.
A list of foreign races' planets where your observers are present.
- Uninhabited planets.
A list of unsettled planets you can observe — that is, ones where your ships are present.
- Unknown planets.
A list of planets within your reach that you cannot observe. Only the planet number and its coordinates are given.
- Your fleets.
- Your ship groups.
- Foreign ship groups.
A list of ship groups belonging to other players that you can observe.
- Unidentified ship groups.
A list of coordinates of foreign ship groups in hyperspace that are not heading to your planets.
## Diplomatic mail {#mail}
Players in Galaxy are anonymous. This means no one but the server administration knows the addresses and names of other players. This is done so as not to carry game relationships and conflicts into real life, and thereby to let players behave more freely.
During the game each race can communicate with other races. Communication happens by sending diplomatic letters through the server. The purpose of writing letters can be very varied — for example, forming alliances, joint military action, breaking alliances, and so on.
## Questions you should know the answers to {#questions}
1. What is the difference between industry and production capacity? And between industry and the industry reserve?
2. What mass does a ship with D=0 A=20 W=5 S=0 C=0 have?
3. At what speed will a ship with D=5 A=0 W=0 S=0 C=0 fly if its Drive technology level is 1.0?
4. On which turn will a ship with a speed of 18 light-years per turn arrive at its destination if the distance between the departure and destination planets is 40 light-years and it was dispatched on turn 15?
5. What Drive technology level will a ship with D=1 A=0 W=0 S=0 C=0 have if, before it was built, the Drive technology level was 1.8, and on another planet the Drive technology rose by 0.2 at the same time it was built?
6. What mass will a ship with D=10 A=0 W=0 S=0 C=2 and Cargo technology level 1.2 have if it is fully loaded?
7. Will a battle occur if a ship with D=1 A=0 W=0 S=0 C=0 belonging to race A and a ship with D=1 A=1 W=1 S=0 C=0 belonging to race B meet on a planet, where race B has "peace" with race A and race A has "war" with race B?
## Some tips {#tips}
In the early game there is no need to fight for planets. You should start by building cargo ships, accumulating industry, and delivering colonists and industry to the nearest uninhabited planets. It is also wise to establish contacts with other races to form alliances, which will come in very handy when the time for battles comes. For that you simply must keep up diplomatic correspondence. Only doomed races do not correspond.
The map in your report shows only planets colonised by foreign races and the full mass of foreign ship groups heading to one of your planets. To get detailed information about enemy fleets that may threaten your security, you need to send ships to foreign planets purely for spying.
When an attack on your planets is approaching, the most important thing is to make sure you have enough force to defend. For each such group, divide the distance by the speed to get the number of turns left before the group reaches the planet. Assess the full mass: the larger it is, the greater the potential threat. You cannot, of course, know whether it is a huge battleship, a fleet of small fighters, or something in between. You can also try diplomacy: although the group's owner cannot turn it back, they can declare peace with you, so the group will not attack you on arrival.
In the later game it is quite likely that one race will develop more than the others and take a dominant position in the galaxy. From that moment it is vital for the remaining players to immediately set aside all their differences and attack that race together. For if this race is allowed to capture races one after another, it will get an excellent chance to win the game.
Because of how the [battle algorithm](#combat) works, a fleet is usually split into three complementary parts:
- many small screening ships, to draw the enemy's guns;
- one or more ships with several small guns, to quickly destroy the small screening ships;
- one or more ships with one or several large guns, to destroy the main enemy ships.
This must be taken into account when designing planet defences.
## Turn recomputation {#turn-recalc}
However well the server and the internet work, sometimes a turn must be reproduced. This is an exception to the server's general rules. Here are the cases in which a turn may be recomputed:
1. A bug was found in the server logic that caused irreversible changes to the state of several players' races.
2. The system for delivering orders to the server or reports from it was disrupted for longer than the waiting time between two turns — that is, all players were unable to manage their races.
3. A force-majeure event occurred and most of the game's participants unambiguously concluded that the turn must be recomputed.
4. An unappealable decision by the administration to that effect.
Any participant may ask the administration to recompute a turn, citing any of these points and providing appropriate evidence. If the administration decides that one of the listed conditions is met, the turn will be recomputed.
A turn will not be recomputed if the conditions are insufficient or the situation can be fixed dynamically.
## Game ethics {#ethics}
Bear in mind above all that "Galaxy" is a game; do not identify the leader of one of the races with the real player. Do not take offence if someone has treated you incorrectly. Deceit and treachery are permitted here — take that into account in your policy. The administration does not consider complaints of this kind and takes no sanctions against players who have committed such "misdeeds".
The diplomatic mail circulating between players is private mail. You may use any forms and expressions in it. However, rudeness is unacceptable in broadcast messages, even if that rudeness is part of your image. In addition, the server administration reserves the right to censor messages that are not private diplomatic mail.
The "Galaxy" game server is a program that, unfortunately, may contain bugs. We would be grateful if you reported any such findings. However, if you try to use a bug you found for your own benefit, the server administration may decide to remove you from the game.
-6
View File
@@ -7,12 +7,6 @@
# baked into `docker-compose.yml`, so this file documents the knobs
# rather than driving them.
# Auto-provisioned sandbox bootstrap. Empty disables the bootstrap.
BACKEND_DEV_SANDBOX_EMAIL=dev@galaxy.lan
BACKEND_DEV_SANDBOX_ENGINE_IMAGE=galaxy-engine:dev
BACKEND_DEV_SANDBOX_ENGINE_VERSION=0.1.0
BACKEND_DEV_SANDBOX_PLAYER_COUNT=20
# `123456` short-circuits the email-code path for the dev account.
# This is also the docker-compose default — set the variable to an
# empty string here when the environment must rely on real Mailpit
+30
View File
@@ -29,6 +29,36 @@
reverse_proxy galaxy-api:8080
}
# Operator console + observability behind one Basic Auth gate. The gate
# credential equals the admin-console account (dev: gm / gm-dev-password),
# so Caddy forwards the same Authorization header to the backend `/_gm`
# surface (its own Basic Auth) and to Grafana/Mailpit one prompt covers
# all three. The gateway applies the admin anti-abuse class to the console.
@gm path /_gm /_gm/*
handle @gm {
basic_auth {
gm "$2a$14$xVh1TLaZxh8fazlKrI9Mx.NQMQlMarYWtr3FRELmZIXuac/DeeTRO"
}
# Grafana under /_gm/grafana/ (sub-path mode; anonymous Admin, so the
# /_gm gate is the only barrier GF_AUTH_BASIC_ENABLED=false makes it
# ignore the forwarded Authorization header).
handle /_gm/grafana/* {
reverse_proxy galaxy-grafana:3000
}
# Mailpit captured-mail UI under /_gm/mailpit/ (MP_WEBROOT). Shows
# every message the backend sent, relayed or not.
handle /_gm/mailpit/* {
reverse_proxy galaxy-mailpit:8025
}
# The operator console itself (gateway -> backend /_gm surface).
handle {
reverse_proxy galaxy-api:8080
}
}
# Bare `/game` (no trailing slash) -> `/game/` so the SPA root
# resolves before the site catch-all can claim it.
handle /game {
+3 -159
View File
@@ -1,164 +1,8 @@
# `tools/dev-deploy/` — known issues
Issues that surface in the long-lived dev environment but are not yet
fixed. Each entry lists the observed symptom, the diagnostic evidence,
the working hypothesis, and the open questions that have to be
answered before a fix lands.
## Dev Sandbox game flips to `cancelled` after a `dev-deploy` redispatch
### Symptom
A previously `running` "Dev Sandbox" game (created by
`backend/internal/devsandbox`) transitions to `cancelled` ~15 minutes
after a `dev-deploy.yaml` workflow_dispatch run finishes. The user's
browser session survives (the same `device_session_id` keeps working),
but the lobby shows no game because the only game it had is now
terminal. `purgeTerminalSandboxGames` does pick it up on the **next**
boot and creates a fresh sandbox — but the first redispatch leaves
the user with an empty lobby until backend restarts again.
### Diagnostic evidence
Backend logs from the broken cycle (timestamps abbreviated):
```text
20:24:40 dev_sandbox: purged terminal sandbox game game_id=<prev> status=cancelled
20:24:40 dev_sandbox: memberships ensured count=20 game_id=<new>
20:24:40 dev_sandbox: bootstrap complete user_id=<owner> game_id=<new> status=starting
...
20:25:09 user mail sent failed (diplomail tables missing — unrelated)
...
20:39:40 lobby: game cancelled by runtime reconciler game_id=<new>
op=reconcile status=removed message="container disappeared"
```
Between 20:24:40 (`status=starting`) and 20:39:40 (reconciler cancel)
the backend logs are silent on the runtime / engine paths — no
`engine spawned`, no `engine container started`, no `runtime
transition` lines. The reconciler then fires and reports the engine
container as missing.
`docker ps -a --filter 'label=org.opencontainers.image.title=galaxy-game-engine'`
returns no rows during this window — the engine container is neither
running nor stopped on the host, so it either was never spawned or
was removed before the host snapshot.
### What has been ruled out
A live `docker inspect` on a healthy engine container shows:
```text
Labels: galaxy.backend=1, galaxy.engine_version=0.1.0,
galaxy.game_id=<uuid>,
org.opencontainers.image.title=galaxy-game-engine,
com.galaxy.{cpu_quota,memory,pids_limit}
AutoRemove: false
RestartPolicy: on-failure
NetworkMode: galaxy-dev-internal
```
There are no `com.docker.compose.*` labels and `AutoRemove=false`,
so `--remove-orphans` cannot reap the engine and a `--rm`-style
self-destruct is not in play. Two redispatches captured under
`docker events --filter event=create,start,die,destroy,kill,stop`
also confirmed it: across both runs the only `die` / `destroy`
events were for `galaxy-dev-{backend,api,caddy}`. The live engine
container survived both redispatches, and the reconciler that
fires 60 seconds after the new backend boots correctly matched
it through `byGameID` / `byContainerID`.
`backend/internal/runtime/service.go` only removes engine
containers from the explicit `runStop` / `runRestart` / `runPatch`
paths. There is no `runtime.Service.Shutdown` that proactively
kills containers on backend exit, so a graceful SIGTERM to
`galaxy-dev-backend` will not touch its child engine containers.
### Host-side hypotheses considered and rejected by the owner
The natural follow-up suspects after compose was cleared — host-side
`docker prune` cron jobs, a manual `docker rm`, an out-of-band
`dockerd` restart, and an idle-state engine crash — were all
rejected by the project owner: the dev host runs none of those
periodic cleanups, no one manually removed the container, dockerd
was not restarted in the window, and the engine binary does not
crash while idling on API calls.
### Best remaining suspicion
Something the `dev-deploy.yaml` CI run does between successful
image builds and the final `docker compose up -d --wait
--remove-orphans` clobbers the previously-spawned engine container.
The chain at runtime contains:
1. `docker build -t galaxy-engine:dev -f game/Dockerfile .`
2. `docker compose build galaxy-backend galaxy-api`
3. `docker run --rm` alpine for the UI volume seed
4. `docker compose up -d --wait --remove-orphans`
None of these *should* touch an unmanaged engine container, but
the reproduction window points squarely inside this sequence. A
deliberate next reproduction with `docker events --since 0` armed
*before* the deploy starts and live for the entire job — captured
end-to-end on the dev host, not just the chunk after backend
recreate — would pin which step emits the `destroy` on the engine.
### Update 2026-05-19: integration preclean identified as one cause
A live reproduction during the post-merge auto-deploy cycle (Gitea
run #188 dev-deploy plus parallel run #190 integration) pinned one
clobbering source: `integration/scripts/preclean.sh` was unscoped
and removed *every* container labelled `galaxy.backend=1`, including
the dev-deploy engine. Timeline from the dev host:
```text
23:10:40 backend pre-bootstrap reconciler tick: engine alive
23:10:40 dev_sandbox bootstrap: status=running
23:10:56 preclean: removing 1 backend-managed engine containers ← integration run #190
23:11:40 reconciler: container disappeared → game cancelled
```
Fix landed: `BACKEND_STACK_LABEL=integration` is now passed to
every integration backend (see
`integration/testenv/backend.go`) and `preclean.sh` AND-combines
`galaxy.backend=1` with `galaxy.stack=integration`, so dev-deploy /
local-dev engines stamped with different stack values are no longer
collateral.
This covers **push**-triggered cycles where `dev-deploy.yaml` and
`integration.yaml` run on the same Gitea host. The original
hypothesis (a `workflow_dispatch dev-deploy` solo run also losing
the engine) is *not* explained by the integration fix — manual
dispatches do not trigger `integration.yaml`. Keep this entry open
until a solo-dispatch reproduction confirms whether the symptom
still occurs.
### Status
Partially fixed (push-triggered cycles). Solo `workflow_dispatch`
reproductions still open. If the symptom recurs after the
integration fix lands, capture `docker events --since 0` for the
full dispatch window and attach here.
### Workaround in use today
When the sandbox game flips to `cancelled`, redispatch `dev-deploy`:
```sh
curl -X POST -n -H 'Content-Type: application/json' \
-d '{"ref":"<branch>"}' \
https://gitea.iliadenisov.ru/api/v1/repos/developer/galaxy-game/actions/workflows/dev-deploy.yaml/dispatches
```
The next boot's `purgeTerminalSandboxGames` removes the cancelled
row, `findOrCreateSandboxGame` creates a fresh one, and
`ensureMembershipsAndDrive` puts the new game back to `running`.
### Owner
Unassigned. File an issue once we have the runtime / reconciler
analysis above; reference this section in the issue body so future
redeploys can short-circuit the diagnostic loop.
Issues that surfaced in the long-lived dev environment. Each entry lists
the observed symptom, the diagnostic evidence, and the fix or the open
questions that have to be answered before a fix lands.
## `docker restart galaxy-dev-backend` fails after the CI runner cleans up
+79 -19
View File
@@ -114,17 +114,72 @@ calls `make clean-data`.
The same dev-mode email-code override as `tools/local-dev/` applies,
and the dev-deploy compose ships with it enabled by default:
1. Enter `dev@galaxy.lan` (or whatever `BACKEND_DEV_SANDBOX_EMAIL`
resolves to) in the login form.
1. Enter your email address in the login form.
2. Submit `123456` as the code — the docker-compose default for
`BACKEND_AUTH_DEV_FIXED_CODE` is `123456`, so the bcrypt-hashed
email code stays a fallback. To force real Mailpit codes (e.g. for
mail-flow QA), set `BACKEND_AUTH_DEV_FIXED_CODE=` (empty) in a
local `.env` and `make rebuild`.
email code stays a fallback. To force the real email code (which
Mailpit then relays to your Gmail — see **Mail** below), set
`BACKEND_AUTH_DEV_FIXED_CODE=` (empty) and redeploy.
The fixed-code override is rejected by production env loaders, so it
cannot leak into the prod environment.
## Mail
The backend always submits mail to **Mailpit** (`galaxy-mailpit:1025`),
exactly as it would to a production SMTP server. Mailpit captures every
message in its UI (internal `:8025`) and, when configured, **relays**
the ones whose recipient matches `GALAXY_DEV_MAIL_RELAY_MATCH` up to a
real Gmail account — so an OTP addressed to you lands in your real inbox
while everything else stays captured-only.
Configure the relay through Gitea Actions secrets/vars (never
committed); the `dev-deploy.yaml` workflow renders Mailpit's
`relay.conf` (from `tools/dev-deploy/mailpit/relay.conf.tmpl`) and seeds
it into the `galaxy-dev-mailpit-config` volume:
| Name | Kind | Purpose |
| --- | --- | --- |
| `GALAXY_DEV_MAIL_RELAY_USERNAME` | secret | Gmail address used as the relay login + From. |
| `GALAXY_DEV_MAIL_RELAY_PASSWORD` | secret | Gmail **App Password** (requires 2FA; not the account password). |
| `GALAXY_DEV_MAIL_RELAY_MATCH` | var | Recipient regex to auto-relay (e.g. your Gmail address). Unset → capture-only. |
With none set the stack only captures mail (the compose relay-match
defaults to a non-routable address), so it can never email third
parties.
The capture UI is exposed through the operator console's `/_gm` gate at
[`/_gm/mailpit/`](https://galaxy.lan/_gm/mailpit/) — one Basic Auth for
the console, Grafana and Mailpit (see **Observability**). It shows
**every** message the backend sent, relayed or not, so you can read any
account's OTP regardless of the relay-match. For multi-account testing:
register several `you+tag@gmail.com` aliases and widen the match to a
regex such as `^you(\+[^@]+)?@gmail\.com$` (Gmail folds every `+tag`
into one inbox), or just read the codes in the Mailpit UI, or skip mail
entirely with the `123456` dev-code.
## Observability
A full metrics + logs + traces stack runs alongside the app on the
internal network (no host ports), as a production mirror. **Grafana**
and the **Mailpit** UI are reached only through the operator console's
single `/_gm` Basic Auth gate — one password (the admin-console account)
unlocks the console, [`/_gm/grafana/`](https://galaxy.lan/_gm/grafana/)
and [`/_gm/mailpit/`](https://galaxy.lan/_gm/mailpit/), with links in the
console nav. Grafana runs anonymous-Admin behind the gate (no own
login); Prometheus, Loki and Tempo stay internal-only.
- **Metrics** — Prometheus scrapes backend, gateway, `node-exporter` and
cAdvisor.
- **Logs** — promtail → Loki (Docker SD on the `galaxy.stack=dev-deploy`
label).
- **Traces** — backend + gateway → Tempo over OTLP.
Grafana's admin user is seeded from `GALAXY_DEV_GRAFANA_ADMIN_PASSWORD`
(for provisioning/API; the UI needs no Grafana login). See
[`monitoring/README.md`](monitoring/README.md) for services, configs and
tuning knobs.
## Networking
```
@@ -139,6 +194,8 @@ galaxy-caddy (networks: edge + galaxy-dev-internal)
│ /game/* -> file_server /srv/galaxy-ui (volume galaxy-dev-ui-dist)
│ /api/*, /healthz -> reverse_proxy galaxy-api:8080
│ /rpc/* -> reverse_proxy galaxy-api:9090 (strips /rpc)
│ /_gm, /_gm/* -> reverse_proxy galaxy-api:8080 (Basic Auth gate;
│ /_gm/grafana/ -> grafana, /_gm/mailpit/ -> mailpit)
galaxy-dev-internal
├─ galaxy-api (gateway: :8080 REST, :9090 gRPC)
@@ -146,7 +203,9 @@ galaxy-dev-internal
├─ galaxy-postgres (postgres: :5432)
├─ galaxy-redis (redis: :6379)
├─ galaxy-mailpit (mailpit: :8025 UI, :1025 SMTP)
─ engine containers (spawned by backend on demand)
─ engine containers (spawned by backend on demand)
└─ observability (prometheus, grafana, loki, promtail, tempo,
node-exporter, cadvisor)
```
The compose project deliberately exposes no host ports. Diagnostics
@@ -191,8 +250,10 @@ make clean-data Stop everything and wipe volumes + game-state dir
## Files
- `docker-compose.yml`six services: postgres, redis, mailpit,
galaxy-backend, galaxy-api, galaxy-caddy. `galaxy-caddy` mounts both
- `docker-compose.yml`the application services (postgres, redis,
mailpit, galaxy-backend, galaxy-api, galaxy-caddy) plus the
observability stack (prometheus, grafana, loki, promtail, tempo,
node-exporter, cadvisor). `galaxy-caddy` mounts both
the `galaxy-dev-site-dist` (`/srv/galaxy-site`) and
`galaxy-dev-ui-dist` (`/srv/galaxy-ui`) volumes and reverse-proxies
both gateway tiers (REST/health on `:8080`, Connect/gRPC-web on
@@ -204,6 +265,8 @@ make clean-data Stop everything and wipe volumes + game-state dir
at `/etc/caddy/Caddyfile`.
- `Caddyfile.prod` — placeholder for a future prod deployment; not used
by this compose.
- `monitoring/` — Prometheus / Loki / promtail / Tempo / Grafana
configuration, provisioned as code; see `monitoring/README.md`.
- `Makefile` — wrapper over `docker compose` with helpers for engine,
site/UI seeding, health probes, and full wipe.
- `.env.example` — non-secret defaults for the compose `${VAR:-}`
@@ -212,8 +275,7 @@ make clean-data Stop everything and wipe volumes + game-state dir
## Known issues
See [`KNOWN-ISSUES.md`](KNOWN-ISSUES.md) for symptoms that surface
in the long-lived dev environment but are not yet fixed (currently:
the sandbox game flipping to `cancelled` after a redispatch).
in the long-lived dev environment but are not yet fixed.
## Deployment cadence
@@ -237,12 +299,12 @@ behind. There is no separate state to clean up between the two paths.
### Engine image drift recycle
`backend` spawns one engine container per game (the long-lived "Dev
Sandbox" plus any user-created games) and the reconciler reattaches
to whatever it finds with the `galaxy.stack=dev-deploy` label. That
reattach does not check the running container's image SHA against the
freshly-built `galaxy-engine:dev` tag, so an unchanged container would
otherwise keep serving the previous engine code after a redeploy.
`backend` spawns one engine container per running game and the
reconciler reattaches to whatever it finds with the
`galaxy.stack=dev-deploy` label. That reattach does not check the
running container's image SHA against the freshly-built
`galaxy-engine:dev` tag, so an unchanged container would otherwise
keep serving the previous engine code after a redeploy.
The `dev-deploy.yaml` workflow handles this in the
`Recycle engine containers on image drift` step. When `docker build`
@@ -250,9 +312,7 @@ produces a new `galaxy-engine:dev` SHA, the step compares it against
every running `galaxy-game-*` container and, for each drifted one,
stops the backend, removes the container, wipes its bind-mounted
state directory (Engine.Init() writes turn-0 over any pre-existing
`turn-N` files), and cascade-deletes the lobby `games` row. The
`dev-sandbox` bootstrap on the next backend boot finds no live
sandbox and provisions a fresh one on the new engine image.
`turn-N` files), and cascade-deletes the lobby `games` row.
When the engine sources are unchanged, the BuildKit cache hits and
the SHA stays the same — the recycle step is a no-op and the running
+214 -12
View File
@@ -66,12 +66,26 @@ services:
image: axllent/mailpit:v1.21
container_name: galaxy-dev-mailpit
restart: unless-stopped
# Mailpit is both the SMTP submission point and a relay: it captures
# every message in its UI and auto-relays the ones whose recipient
# matches GALAXY_DEV_MAIL_RELAY_MATCH to the Gmail account in the
# secret-rendered relay config. The default match is non-routable, so
# a stack brought up without the relay secret only captures, never sends.
command:
- "--smtp-relay-config=/etc/mailpit/relay.conf"
- "--smtp-relay-matching=${GALAXY_DEV_MAIL_RELAY_MATCH:-nobody@invalid.example}"
# Serve the capture UI under /_gm/mailpit so the host Caddy can expose
# it at https://galaxy.lan/_gm/mailpit/ behind the shared /_gm gate;
# SMTP is unaffected.
- "--webroot=/_gm/mailpit"
labels:
galaxy.stack: dev-deploy
networks:
- galaxy-internal
volumes:
- galaxy-dev-mailpit-config:/etc/mailpit:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O-", "http://localhost:8025/livez"]
test: ["CMD", "wget", "-q", "-O-", "http://localhost:8025/_gm/mailpit/livez"]
interval: 3s
timeout: 3s
retries: 30
@@ -108,23 +122,31 @@ services:
BACKEND_NOTIFICATION_ADMIN_EMAIL: admin@galaxy.lan
BACKEND_MAIL_WORKER_INTERVAL: 500ms
BACKEND_NOTIFICATION_WORKER_INTERVAL: 500ms
BACKEND_OTEL_TRACES_EXPORTER: none
BACKEND_OTEL_METRICS_EXPORTER: none
BACKEND_OTEL_TRACES_EXPORTER: otlp
BACKEND_OTEL_PROTOCOL: grpc
BACKEND_OTEL_ENDPOINT: "galaxy-tempo:4317"
# Tempo's OTLP receiver is plaintext on the internal network; the
# backend's gRPC exporter defaults to TLS, so disable it via the
# standard SDK env (applied on top of WithEndpoint).
OTEL_EXPORTER_OTLP_INSECURE: "true"
# Prometheus metrics are enabled in dev so the `/metrics` scrape
# endpoint is live and stable ahead of standing up a Prometheus +
# Grafana stack on the internal network. The listener stays internal
# (not mapped to the host); nothing scrapes it yet.
BACKEND_OTEL_METRICS_EXPORTER: prometheus
BACKEND_OTEL_PROMETHEUS_LISTEN_ADDR: ":9100"
# Operator console (`/_gm`): Basic Auth bootstrap account plus the
# stateless CSRF key. Dev-only non-secrets, overridable via `.env`; a
# stable CSRF key keeps console forms valid across redeploys.
BACKEND_ADMIN_BOOTSTRAP_USER: ${BACKEND_ADMIN_BOOTSTRAP_USER:-gm}
BACKEND_ADMIN_BOOTSTRAP_PASSWORD: ${BACKEND_ADMIN_BOOTSTRAP_PASSWORD:-gm-dev-password}
BACKEND_ADMIN_CONSOLE_CSRF_KEY: ${BACKEND_ADMIN_CONSOLE_CSRF_KEY:-dev-admin-console-csrf-key}
# Long-lived dev environment always opts into the fixed-code
# override so a returning developer can sign in with `123456`
# even after the matching browser session was cleared (the real
# bcrypt-hashed code is single-use). Set the var to an empty
# string in `.env` to disable.
BACKEND_AUTH_DEV_FIXED_CODE: ${BACKEND_AUTH_DEV_FIXED_CODE:-123456}
# Long-lived dev environment always bootstraps the "Dev Sandbox"
# game owned by this email so a freshly redeployed stack already
# has one ready-to-play game in the lobby. Set the variable to an
# empty string in `.env` to disable the bootstrap (e.g. for a
# cold-start QA pass).
BACKEND_DEV_SANDBOX_EMAIL: ${BACKEND_DEV_SANDBOX_EMAIL:-dev@galaxy.lan}
BACKEND_DEV_SANDBOX_ENGINE_IMAGE: ${BACKEND_DEV_SANDBOX_ENGINE_IMAGE:-galaxy-engine:dev}
BACKEND_DEV_SANDBOX_ENGINE_VERSION: ${BACKEND_DEV_SANDBOX_ENGINE_VERSION:-0.1.0}
BACKEND_DEV_SANDBOX_PLAYER_COUNT: ${BACKEND_DEV_SANDBOX_PLAYER_COUNT:-20}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Per-game state directories live under the same absolute path
@@ -180,6 +202,16 @@ services:
GATEWAY_LOG_LEVEL: info
GATEWAY_PUBLIC_HTTP_ADDR: ":8080"
GATEWAY_AUTHENTICATED_GRPC_ADDR: ":9090"
# Private admin listener exposes the Prometheus `/metrics` endpoint on
# the internal network — live and stable for a future scrape, not
# mapped to the host.
GATEWAY_ADMIN_HTTP_ADDR: ":9191"
# Traces -> Tempo over OTLP gRPC (plaintext on the internal net).
OTEL_SERVICE_NAME: galaxy-gateway
OTEL_TRACES_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_EXPORTER_OTLP_ENDPOINT: "http://galaxy-tempo:4317"
OTEL_EXPORTER_OTLP_INSECURE: "true"
GATEWAY_BACKEND_HTTP_URL: "http://galaxy-backend:8080"
GATEWAY_BACKEND_GRPC_PUSH_URL: "galaxy-backend:8081"
GATEWAY_BACKEND_GATEWAY_CLIENT_ID: dev-gateway-1
@@ -208,6 +240,9 @@ services:
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_RATE_LIMIT_BURST: "1000"
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_BOOTSTRAP_MAX_BODY_BYTES: "65536"
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_ASSET_MAX_BODY_BYTES: "65536"
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_MAX_BODY_BYTES: "131072"
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_REQUESTS: "10000"
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_BURST: "1000"
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_REQUESTS: "10000"
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_BURST: "1000"
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_SESSION_RATE_LIMIT_REQUESTS: "10000"
@@ -245,6 +280,163 @@ services:
- galaxy-internal
- edge
galaxy-prometheus:
image: prom/prometheus:v2.55.1
container_name: galaxy-dev-prometheus
restart: unless-stopped
labels:
galaxy.stack: dev-deploy
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=15d
- --web.enable-lifecycle
volumes:
- ${GALAXY_DEV_MONITORING_DIR:-./monitoring}/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- galaxy-dev-prometheus-data:/prometheus
networks:
- galaxy-internal
deploy:
resources:
limits:
memory: 384m
galaxy-loki:
image: grafana/loki:3.3.2
container_name: galaxy-dev-loki
restart: unless-stopped
labels:
galaxy.stack: dev-deploy
command: ["-config.file=/etc/loki/loki.yml"]
volumes:
- ${GALAXY_DEV_MONITORING_DIR:-./monitoring}/loki/loki.yml:/etc/loki/loki.yml:ro
- galaxy-dev-loki-data:/loki
networks:
- galaxy-internal
deploy:
resources:
limits:
memory: 384m
galaxy-promtail:
image: grafana/promtail:3.3.2
container_name: galaxy-dev-promtail
restart: unless-stopped
labels:
galaxy.stack: dev-deploy
command: ["-config.file=/etc/promtail/promtail.yml"]
volumes:
- ${GALAXY_DEV_MONITORING_DIR:-./monitoring}/promtail/promtail.yml:/etc/promtail/promtail.yml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- galaxy-internal
deploy:
resources:
limits:
memory: 128m
galaxy-tempo:
image: grafana/tempo:2.7.1
container_name: galaxy-dev-tempo
restart: unless-stopped
labels:
galaxy.stack: dev-deploy
command: ["-config.file=/etc/tempo/tempo.yml"]
volumes:
- ${GALAXY_DEV_MONITORING_DIR:-./monitoring}/tempo/tempo.yml:/etc/tempo/tempo.yml:ro
- galaxy-dev-tempo-data:/var/tempo
networks:
- galaxy-internal
deploy:
resources:
limits:
memory: 384m
galaxy-node-exporter:
image: prom/node-exporter:v1.8.2
container_name: galaxy-dev-node-exporter
restart: unless-stopped
labels:
galaxy.stack: dev-deploy
command:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/rootfs
- --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
pid: host
networks:
- galaxy-internal
deploy:
resources:
limits:
memory: 64m
galaxy-cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.49.1
container_name: galaxy-dev-cadvisor
restart: unless-stopped
labels:
galaxy.stack: dev-deploy
command:
- --housekeeping_interval=30s
- --docker_only=true
- --store_container_labels=false
privileged: true
devices:
- /dev/kmsg
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
networks:
- galaxy-internal
deploy:
resources:
limits:
memory: 256m
galaxy-grafana:
image: grafana/grafana:11.4.0
container_name: galaxy-dev-grafana
restart: unless-stopped
labels:
galaxy.stack: dev-deploy
depends_on:
- galaxy-prometheus
- galaxy-loki
- galaxy-tempo
environment:
GF_SECURITY_ADMIN_PASSWORD: ${GALAXY_DEV_GRAFANA_ADMIN_PASSWORD:-admin}
GF_SERVER_ROOT_URL: https://galaxy.lan/_gm/grafana/
GF_SERVER_SERVE_FROM_SUB_PATH: "true"
# No own login: the /_gm Basic Auth gate is the only barrier, so
# serve everyone as anonymous Admin and ignore the forwarded
# Authorization header (basic auth off, login form off).
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
GF_AUTH_DISABLE_LOGIN_FORM: "true"
GF_AUTH_BASIC_ENABLED: "false"
GF_USERS_ALLOW_SIGN_UP: "false"
GF_ANALYTICS_REPORTING_ENABLED: "false"
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
GF_NEWS_NEWS_FEED_ENABLED: "false"
volumes:
- ${GALAXY_DEV_MONITORING_DIR:-./monitoring}/grafana/provisioning:/etc/grafana/provisioning:ro
- ${GALAXY_DEV_MONITORING_DIR:-./monitoring}/grafana/dashboards:/var/lib/grafana/dashboards:ro
- galaxy-dev-grafana-data:/var/lib/grafana
networks:
- galaxy-internal
deploy:
resources:
limits:
memory: 256m
networks:
galaxy-internal:
name: galaxy-dev-internal
@@ -274,3 +466,13 @@ volumes:
name: galaxy-dev-site-dist
galaxy-dev-geoip-data:
name: galaxy-dev-geoip-data
galaxy-dev-mailpit-config:
name: galaxy-dev-mailpit-config
galaxy-dev-prometheus-data:
name: galaxy-dev-prometheus-data
galaxy-dev-grafana-data:
name: galaxy-dev-grafana-data
galaxy-dev-loki-data:
name: galaxy-dev-loki-data
galaxy-dev-tempo-data:
name: galaxy-dev-tempo-data
+18
View File
@@ -0,0 +1,18 @@
# Mailpit SMTP relay upstream — RENDERED AT DEPLOY TIME by
# .gitea/workflows/dev-deploy.yaml from Gitea Actions secrets, then
# seeded into the `galaxy-dev-mailpit-config` volume. The Gmail App
# Password is a secret and MUST NOT be committed: this template only
# carries ${PLACEHOLDER}s that the workflow substitutes. See
# tools/dev-deploy/README.md ("Mail").
#
# Mailpit captures every message; the `--smtp-relay-matching` flag (set
# from GALAXY_DEV_MAIL_RELAY_MATCH in the compose) decides which
# recipients are actually relayed up to this Gmail account.
host: smtp.gmail.com
port: 587
starttls: true
allow-insecure: false
auth: login
username: ${GALAXY_DEV_MAIL_RELAY_USERNAME}
password: ${GALAXY_DEV_MAIL_RELAY_PASSWORD}
return-path: ${GALAXY_DEV_MAIL_RELAY_USERNAME}
+77
View File
@@ -0,0 +1,77 @@
# `tools/dev-deploy/monitoring/` — observability stack
The long-lived dev environment runs a full metrics + logs + traces stack
alongside the application as a **production mirror**: the same compose
fragment and collector configs are meant to back production later. Every
collector lives on the internal `galaxy-dev-internal` network and
publishes **no host port**. The browser-reachable pieces (Grafana and
the Mailpit UI) sit behind the operator console's single `/_gm` Basic
Auth gate — see [`../README.md`](../README.md) and `ARCHITECTURE.md §14`.
## Services
| Service | Image | Role | Reachable |
| --- | --- | --- | --- |
| `galaxy-prometheus` | `prom/prometheus` | Scrape + store metrics (15d) | internal `:9090` |
| `galaxy-loki` | `grafana/loki` | Log store (7d) | internal `:3100` |
| `galaxy-promtail` | `grafana/promtail` | Ship container logs to Loki | — |
| `galaxy-tempo` | `grafana/tempo` | Trace store (3d), OTLP receiver | internal `:3200`, OTLP `:4317`/`:4318` |
| `galaxy-node-exporter` | `prom/node-exporter` | Host metrics | internal `:9100` |
| `galaxy-cadvisor` | `cadvisor` | Per-container CPU/memory/IO | internal `:8080` |
| `galaxy-grafana` | `grafana/grafana` | Dashboards + Explore | Caddy `/_gm/grafana/` |
## What is collected
- **Metrics.** Prometheus (30s interval) scrapes the backend Prometheus
endpoint (`galaxy-backend:9100`), the gateway admin endpoint
(`galaxy-api:9191`), `node-exporter` (host) and cAdvisor (per
container). Engine containers expose no `/metrics`; cAdvisor covers
their resource use.
- **Logs.** promtail discovers containers through the Docker API,
filtered to the `galaxy.stack=dev-deploy` label, and ships their
stdout/stderr to Loki labelled by `container`.
- **Traces.** backend and gateway export OTLP traces over gRPC to Tempo
(`galaxy-tempo:4317`), plaintext on the internal network
(`OTEL_EXPORTER_OTLP_INSECURE=true`, since Tempo's receiver is not
TLS-wrapped inside the contour).
## Grafana access (behind the `/_gm` gate)
Grafana is served under `/_gm/grafana/` (`GF_SERVER_ROOT_URL` +
`GF_SERVER_SERVE_FROM_SUB_PATH=true`) **behind the shared operator gate**:
the Caddy `/_gm/*` Basic Auth (the admin-console account) is the only
barrier. Grafana itself runs as **anonymous Admin** with its login form
and basic auth disabled (`GF_AUTH_ANONYMOUS_ENABLED=true`,
`GF_AUTH_ANONYMOUS_ORG_ROLE=Admin`, `GF_AUTH_DISABLE_LOGIN_FORM=true`,
`GF_AUTH_BASIC_ENABLED=false`), so it ignores the forwarded credentials
and asks for no second password. `GALAXY_DEV_GRAFANA_ADMIN_PASSWORD`
still seeds the admin user for provisioning/API use.
Datasources (Prometheus, Loki, Tempo) and a starter dashboard
(`grafana/dashboards/galaxy-overview.json`) are provisioned as code under
`grafana/provisioning/`.
## Config delivery
`dev-deploy.yaml` copies this directory to a stable host path
(`$HOME/.galaxy-dev/monitoring`, exported as `GALAXY_DEV_MONITORING_DIR`)
before `compose up`, and the compose binds it read-only into the
collectors. A stable path — not the ephemeral CI workspace — keeps the
mounts valid across container restarts and host reboots (the same lesson
as the geoip volume; see `../KNOWN-ISSUES.md`).
## Tuning (cost knobs)
Defaults favour the smallest workable footprint; all are config/compose
values:
- Prometheus `scrape_interval=30s`, `--storage.tsdb.retention.time=15d`.
- Loki `retention_period=168h` (7d); Tempo `block_retention=72h` (3d).
- cAdvisor `--housekeeping_interval=30s`.
- Per-service `deploy.resources.limits.memory` caps (~1.5 GB total cap;
steady-state well under that).
Seven always-on containers cost roughly ~1.1 GB steady RAM and
~1.52.5 GB disk at these retention windows. cAdvisor is the main CPU
cost; on a constrained host it can be dropped (host + app metrics still
cover most needs).
@@ -0,0 +1,46 @@
{
"annotations": { "list": [] },
"editable": true,
"graphTooltip": 0,
"panels": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"id": 1,
"title": "Backend HTTP request rate",
"type": "timeseries",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum by (group) (rate(http_requests_total[5m]))",
"legendFormat": "{{group}}"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"id": 2,
"title": "Container memory (cadvisor)",
"type": "timeseries",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum by (name) (container_memory_usage_bytes{name=~\"galaxy-dev-.*|galaxy-game-.*\"})",
"legendFormat": "{{name}}"
}
]
}
],
"schemaVersion": 39,
"tags": ["galaxy"],
"templating": { "list": [] },
"time": { "from": "now-6h", "to": "now" },
"timepicker": {},
"title": "Galaxy — overview",
"uid": "galaxy-overview",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,12 @@
# Grafana dashboard provider: load every JSON under the mounted
# dashboards directory at startup (provisioned as code).
apiVersion: 1
providers:
- name: galaxy
type: file
disableDeletion: false
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false
@@ -0,0 +1,22 @@
# Grafana datasources provisioned as code (dev↔prod parity). All reach
# the collectors by Docker DNS (compose service names) on
# galaxy-dev-internal.
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
uid: prometheus
url: http://galaxy-prometheus:9090
isDefault: true
- name: Loki
type: loki
access: proxy
uid: loki
url: http://galaxy-loki:3100
- name: Tempo
type: tempo
access: proxy
uid: tempo
url: http://galaxy-tempo:3200
+47
View File
@@ -0,0 +1,47 @@
# Single-binary Loki for the dev stack: filesystem storage, in-memory
# ring, 7-day retention. Internal-only (no host port).
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 9095
log_level: warn
common:
instance_addr: 127.0.0.1
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
retention_period: 168h
reject_old_samples: true
reject_old_samples_max_age: 168h
compactor:
working_directory: /loki/compactor
retention_enabled: true
delete_request_store: filesystem
query_range:
results_cache:
cache:
embedded_cache:
enabled: true
max_size_mb: 64
@@ -0,0 +1,24 @@
# Prometheus scrape config for the dev observability stack. Retention is
# a CLI flag in the compose command, not here. Targets are reached by
# Docker DNS (compose service names) on galaxy-dev-internal; nothing is
# published to the host.
global:
scrape_interval: 30s
evaluation_interval: 30s
scrape_configs:
- job_name: backend
static_configs:
- targets: ["galaxy-backend:9100"]
- job_name: gateway
static_configs:
- targets: ["galaxy-api:9191"]
- job_name: node
static_configs:
- targets: ["galaxy-node-exporter:9100"]
- job_name: cadvisor
static_configs:
- targets: ["galaxy-cadvisor:8080"]
- job_name: prometheus
static_configs:
- targets: ["localhost:9090"]
@@ -0,0 +1,30 @@
# Promtail tails the dev stack's container logs via the Docker API
# (service discovery filtered to the galaxy.stack=dev-deploy label) and
# ships them to Loki. Requires the Docker socket mounted read-only.
server:
http_listen_port: 9080
grpc_listen_port: 0
log_level: warn
positions:
filename: /tmp/positions.yaml
clients:
- url: http://galaxy-loki:3100/loki/api/v1/push
scrape_configs:
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 15s
filters:
- name: label
values: ["galaxy.stack=dev-deploy"]
relabel_configs:
- source_labels: ["__meta_docker_container_name"]
regex: "/?(.*)"
target_label: container
- source_labels: ["__meta_docker_container_label_galaxy_game_id"]
target_label: game_id
- source_labels: ["__meta_docker_container_log_stream"]
target_label: stream
@@ -0,0 +1,30 @@
# Single-binary Tempo for the dev stack: OTLP receivers, local block
# storage, 3-day retention. Internal-only (no host port). Backend and
# gateway push traces here over OTLP gRPC (4317).
server:
http_listen_port: 3200
log_level: warn
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
ingester:
max_block_duration: 5m
compactor:
compaction:
block_retention: 72h
storage:
trace:
backend: local
local:
path: /var/tempo/blocks
wal:
path: /var/tempo/wal
+7 -9
View File
@@ -22,7 +22,7 @@ help:
@echo " make up Build (if needed) and bring up the stack, wait until healthy"
@echo " make down Stop compose containers, leave engines + volumes intact"
@echo " make rebuild Force rebuild of backend / gateway images and bring up"
@echo " make build-engine Build the engine image $(ENGINE_IMAGE) used by the dev sandbox"
@echo " make build-engine Build the engine image $(ENGINE_IMAGE) used by running games"
@echo " make stop-engines Stop and remove only the per-game engine containers"
@echo " make prune-broken-engines Remove non-running engine containers Docker can't heal (run inside 'up')"
@echo " make clean Stop everything (incl. engines) and wipe volumes + game state"
@@ -37,8 +37,9 @@ help:
@echo " pnpm -C ui/frontend dev"
@echo "and open http://localhost:5173 (UI) plus http://localhost:8025 (Mailpit)."
@echo ""
@echo "Default login for the auto-provisioned dev sandbox: dev@local.test"
@echo "(see BACKEND_DEV_SANDBOX_EMAIL in .env). Login code: 123456."
@echo "Sign in with email-OTP; the fixed login code 123456 works when"
@echo "BACKEND_AUTH_DEV_FIXED_CODE is set in .env. No game is auto-provisioned —"
@echo "load a legacy report via the UI's DEV report loader to exercise the map."
up: build-engine prune-broken-engines
$(COMPOSE) up -d --wait
@@ -88,12 +89,9 @@ stop-engines:
# bind-mount source and leaves it stuck in `exited` / `created`
# state. This target prunes the husks before `compose up`; the
# backend's pre-bootstrap reconciler tick (`backend/cmd/backend/main.go`)
# then cascades the orphan runtime row to `removed`, the lobby
# cancels the game, and the dev-sandbox bootstrap purges the
# cancelled tile and provisions a fresh sandbox in the same
# `make up` cycle. Healthy `running` / `restarting` containers are
# left intact so a long-lived sandbox survives normal up/down
# cycles.
# then cascades the orphan runtime row to `removed` and the lobby
# cancels the game. Healthy `running` / `restarting` containers are
# left intact so a long-lived game survives normal up/down cycles.
prune-broken-engines:
@ids=""; \
for cid in $$(docker ps -aq \
+16 -50
View File
@@ -78,49 +78,24 @@ To force the second path (no fast-bypass), edit
`make rebuild` (or simply `docker compose up -d backend` to recreate
the backend with the new env).
## Auto-provisioned dev sandbox
## No auto-provisioned game
`make up` provisions a private game called **Dev Sandbox** owned by
the dev user (default `dev@local.test`). The flow is implemented in
`backend/internal/devsandbox` and runs on every backend boot when
`BACKEND_DEV_SANDBOX_EMAIL` is non-empty in `tools/local-dev/.env`.
Bootstrap is idempotent — re-running `make up` after a `make down`
finds the existing user, dummy participants, game, and memberships
without creating duplicates. If a previous boot crashed mid-way
(game stuck in `enrollment_open` or `ready_to_start`), the next boot
resumes the lifecycle.
To log in straight into the sandbox:
`make up` brings up the stack with an empty lobby — there is no
auto-provisioned game. Sign in with email-OTP (the fixed dev code
`123456` works when `BACKEND_AUTH_DEV_FIXED_CODE` is set in
`tools/local-dev/.env`):
1. `make -C tools/local-dev up`
2. `pnpm -C ui/frontend dev` (in another terminal)
3. Open <http://localhost:5173/login>, enter `dev@local.test`, then
the dev code `123456`.
4. The lobby shows **Dev Sandbox** in *My Games*; click in.
3. Open <http://localhost:5173/login>, enter your email, then the dev
code `123456`.
To disable the bootstrap, clear `BACKEND_DEV_SANDBOX_EMAIL` in
`tools/local-dev/.env` and `docker compose up -d backend` (or
`make rebuild`). Existing users / games are not removed.
Terminal sandbox games — anything in `cancelled`, `finished`, or
`start_failed` — are deleted on every boot before find-or-create
runs. The cascade declared in `00001_init.sql` removes the
matching memberships, applications, invites, runtime records,
and player mappings in the same write, so the dev user's lobby
shows exactly one running tile at all times. Cancelling the
sandbox manually and running `docker compose restart backend`
(or `make rebuild`) yields a fresh game without leaving dead
tiles behind.
The bootstrap requires:
- `galaxy-engine:local-dev` Docker image (`make build-engine`).
- `BACKEND_DEV_SANDBOX_ENGINE_VERSION` parses as plain semver
(`MAJOR.MINOR.PATCH`); the default `0.1.0` is what the bootstrap
registers in the `engine_versions` row that points at the image.
- `BACKEND_DEV_SANDBOX_PLAYER_COUNT` ≥ 20 (the engine's minimum;
19 deterministic dummies fill the slots so the single real user
can start the game).
To exercise the map and report views without running a full game, use
the UI's DEV **synthetic report loader**: convert a legacy `.REP` with
`tools/local-dev/legacy-report/` and load the resulting JSON through the
loader (see that tool's README). To play a real game, create one in the
lobby and let the engine (`galaxy-engine:local-dev`, built by
`make build-engine`) run it.
- A frozen turn schedule (`0 0 1 1 *` — once a year) so the visible
game state stays at turn 1 until you explicitly progress it.
@@ -239,24 +214,15 @@ make status docker compose ps
this in one cycle: `prune-broken-engines` (runs as part of `up`)
removes every engine container that is not in `running` /
`restarting` state, the backend's pre-bootstrap reconciler tick
cascades the orphan runtime row to `removed`, the lobby cancels
the matching sandbox game, and the dev-sandbox bootstrap purges
the cancelled tile and provisions a fresh sandbox with a brand
new state directory. To run the cleanup by hand without restarting
the rest of the stack, `make prune-broken-engines`.
cascades the orphan runtime row to `removed`, and the lobby cancels
the matching game. To run the cleanup by hand without restarting the
rest of the stack, `make prune-broken-engines`.
The cycle relies on the backend image carrying the pre-bootstrap
reconciler tick (`backend/cmd/backend/main.go`). `make up` reuses
the cached image, so after pulling this commit the first time you
must `make rebuild` once to bake the fix in. Future `make up`
cycles will heal in one shot.
If after the heal cycle the lobby still shows only a `cancelled`
sandbox tile and no running game, the running backend image
predates the pre-bootstrap reconciler tick — the periodic ticker
cancels the orphan after bootstrap has already returned, leaving
the lobby in the half-baked state. `make rebuild` recreates the
image and then `make up` lands a fresh sandbox.
- **`make up` reports a build error mentioning `pkg/cronutil`** —
upstream module list drifted; copy any new `pkg/<name>/` line into
the local-dev `backend.Dockerfile` / `gateway.Dockerfile` to match
-4
View File
@@ -122,10 +122,6 @@ services:
BACKEND_OTEL_TRACES_EXPORTER: none
BACKEND_OTEL_METRICS_EXPORTER: none
BACKEND_AUTH_DEV_FIXED_CODE: ${BACKEND_AUTH_DEV_FIXED_CODE:-}
BACKEND_DEV_SANDBOX_EMAIL: ${BACKEND_DEV_SANDBOX_EMAIL:-}
BACKEND_DEV_SANDBOX_ENGINE_IMAGE: ${BACKEND_DEV_SANDBOX_ENGINE_IMAGE:-}
BACKEND_DEV_SANDBOX_ENGINE_VERSION: ${BACKEND_DEV_SANDBOX_ENGINE_VERSION:-}
BACKEND_DEV_SANDBOX_PLAYER_COUNT: ${BACKEND_DEV_SANDBOX_PLAYER_COUNT:-}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Per-game state directories live under the same absolute path
+1 -1
View File
@@ -175,7 +175,7 @@ schema breaks the tool's compilation before the change ships.
When extending:
1. Identify the legacy section in `tools/local-dev/reports/dg/*.REP`
(and `gplus/*.REP`) that carries the field, using `game/rules.txt`
(and `gplus/*.REP`) that carries the field, using `site/ru/rules.md`
section "Отчет о результатах хода" as the column-layout reference.
2. Add a section to the state machine in `parser.go`
(`classifySection`, the `section` constants, the `parse*` methods).
+5 -5
View File
@@ -1982,7 +1982,7 @@ Artifacts (Pass B — feature):
preserves camera centre + zoom across route-driven remounts
inside the same game id.
- Topic doc `ui/docs/cargo-routes-ux.md` quotes
[`rules.txt`](../game/rules.txt) (lines 808843) and maps
[`site/ru/rules.md`](../site/ru/rules.md) (lines 808843) and maps
semantics to UI; `ui/docs/renderer.md` documents the pick-mode
contract; `ui/docs/calc-bridge.md` records the Phase 16 reach
waiver (inline TS rather than a calc bridge for one
@@ -2075,7 +2075,7 @@ Artifacts:
rewritten from the Phase 10 stub: empty form for the Create flow
(name plus the five fields Drive, Armament, Weapons, Shields,
Cargo) and read-only view + Delete affordance for an existing
class. Validation rules from [`rules.txt`](../game/rules.txt) live
class. Validation rules from [`site/ru/rules.md`](../site/ru/rules.md) live
in `lib/util/ship-class-validation.ts` (TS port of
`pkg/calc/validator.go.ValidateShipTypeValues`): each of drive /
weapons / shields / cargo is 0 or ≥ 1; armament is a non-negative
@@ -2098,7 +2098,7 @@ Dependencies: Phase 14.
Acceptance criteria:
- the user can create, list, view, and delete ship classes;
- field validation matches [`rules.txt`](../game/rules.txt)
- field validation matches [`site/ru/rules.md`](../site/ru/rules.md)
constraints with disabled Submit + tooltip when invalid;
- double-tapping a row in the ship-classes table opens its
designer (read-only view of the existing class);
@@ -2463,7 +2463,7 @@ Acceptance criteria:
- the planet production picker (Phase 15) lists the user's sciences
in the Research sub-row and lets the user select one for research
production;
- name validation matches [`rules.txt`](../game/rules.txt)
- name validation matches [`site/ru/rules.md`](../site/ru/rules.md)
constraints (length, allowed characters, special characters not
at start/end, no triple repeats).
@@ -2563,7 +2563,7 @@ Targeted tests:
Status: done (local-ci run 2).
Goal: present every section of the current turn's report as readable
panels, mirroring the structure documented in [`rules.txt`](../game/rules.txt) and
panels, mirroring the structure documented in [`site/ru/rules.md`](../site/ru/rules.md) and
`docs/FUNCTIONAL.md` §6.4.
Artifacts:
+1 -1
View File
@@ -136,7 +136,7 @@ new one); Create is disabled while the name is invalid or duplicate
(reusing `lib/util/ship-class-validation.ts`). Create reuses the existing
`createShipClass` order-draft flow, so the optimistic overlay reflects
the change immediately. Ship classes are immutable after creation (per
`game/rules.txt`), so there is no edit — only Create-new. Delete-class
`site/ru/rules.md`), so there is no edit — only Create-new. Delete-class
lives in the ship-classes table (`lib/active-view/table-ship-classes.svelte`),
not the calculator.
+1 -2
View File
@@ -5,8 +5,7 @@ subsection (a single-row dropdown + contextual actions after
F8-05), the map-driven destination pick, and the optimistic
overlay that keeps the inspector and the map in lock-step with
the local order draft. The engine semantics are quoted from
[`game/rules.txt`](../../game/rules.txt) section "Грузовые маршруты"
(lines 808843); this file is the source of truth for how the UI
[`site/ru/rules.md`](../../site/ru/rules.md#routes) section "Грузовые маршруты"; this file is the source of truth for how the UI
surfaces those rules.
## Engine semantics in one paragraph
+4 -4
View File
@@ -85,16 +85,16 @@ report to fetch. Two alternatives were rejected:
- a brand-new `user.games.state` message — adds a full wire-flow
(fbs schema, transcoder, gateway routing, backend handler) for a
one-field response;
- hard-coding `turn=0` for all games — works for the dev sandbox
(which never advances past turn zero) but renders the initial
state for any real game past turn zero.
- hard-coding `turn=0` for all games — works for a synthetic report
loaded at turn zero but mis-renders the initial state for any real
game past turn zero.
Extending `GameSummary` reuses the existing lobby pipeline; the
backend already tracks `current_turn` in its runtime projection
(`backend/internal/server/handlers_user_lobby_helpers.go`
`gameSummaryToWire` reads it from `g.RuntimeSnapshot.CurrentTurn`).
The `current_turn` field defaults to zero on the FB side, so existing
tests and the dev sandbox flow continue to work unchanged.
tests and the synthetic-report flow continue to work unchanged.
## State binding
+2 -2
View File
@@ -29,8 +29,8 @@ sequence.
The designer presents the four proportions as percentages
(`step="0.1"`, range `[0, 100]`) so the player can type and reason
about whole-number splits — closer to how `game/rules.txt` describes
sciences (`game/rules.txt:345-362`: "10 parts Drive, 5 parts
about whole-number splits — closer to how `site/ru/rules.md` describes
sciences (`site/ru/rules.md` #sciences: "10 parts Drive, 5 parts
Weapons, 30 parts Shields, 0 parts Cargo, …"). The wire shape is
still fractions; conversion happens inside `validateScience` only on
Save (`value / 100` for each of the four).
+2 -2
View File
@@ -268,7 +268,7 @@ export interface ReportLocalFleet {
* table uses.
*
* `relation` reflects the local player's stance TOWARD this race,
* not the other way around (`rules.txt` line 1162). Per the engine
* not the other way around (`site/ru/rules.md` #report). Per the engine
* (`controller/race.go.UpdateRelation`) the relation is stored
* unilaterally race A can be at war with race B while race B is
* at peace with race A.
@@ -508,7 +508,7 @@ export interface GameReport {
/**
* myVotes is the local player's total vote weight in the current
* report, read from `Report.votes` (the engine assigns one vote
* per 1000 population, see `rules.txt:1060`). Zero when the
* per 1000 population, see `site/ru/rules.md` #victory). Zero when the
* report has not been produced yet.
*/
myVotes: number;
+4 -2
View File
@@ -1,6 +1,8 @@
// DEV-only synthetic-report loader. Backs the "Load synthetic report"
// affordance on the lobby (visible behind `import.meta.env.DEV`) and
// the in-game shell layout's bypass for the synthetic game id range.
// affordance on the lobby (visible when the build-time flag
// `VITE_GALAXY_DEV_AFFORDANCES === "true"` — the dev and dev-deploy
// bundles; stripped from prod) and the in-game shell layout's bypass
// for the synthetic game id range.
//
// The accepted JSON shape mirrors `pkg/model/report.Report` as
// emitted by `tools/local-dev/legacy-report/cmd/legacy-report-to-json`.
@@ -8,7 +8,7 @@ immediately and the auto-sync pipeline drives the server in the
background.
The alliance graph and the 2/3 victory check are NOT computed
here: `rules.txt` keeps each race's outgoing vote target private
here: `site/ru/rules.md` keeps each race's outgoing vote target private
(only the votes a race RECEIVED in the last tally and the local
player's own pick are observable), and the acceptance criterion
"vote counts match server state byte-for-byte" rules out
@@ -10,7 +10,7 @@
// `OrderCommand` payload always carries the canonical `[0, 1]`
// summing to `1.0` shape the FBS encoder ships on the wire.
//
// Engine rules (from `pkg/calc/validator.go` and `game/rules.txt`):
// Engine rules (from `pkg/calc/validator.go` and `site/ru/rules.md`):
//
// - drive, weapons, shields, cargo: float in `[0, 1]`;
// - the four values sum to `1.0` (the engine accepts a small
@@ -6,7 +6,7 @@
// to gate auto-sync, so the local invariants match the engine's
// (`game/internal/controller/ship_class.go.ShipClassCreate`).
//
// Engine rules (from `pkg/calc/validator.go` and `game/rules.txt`):
// Engine rules (from `pkg/calc/validator.go` and `site/ru/rules.md`):
//
// - drive, weapons, shields, cargo: float, equal to 0 or >= 1;
// - armament: integer, >= 0;
+1 -1
View File
@@ -330,7 +330,7 @@ export class OrderDraftStore {
* tracks a single war/peace stance per opponent, so a newer
* entry supersedes any prior `setDiplomaticStance` for the
* same other race.
* - `setVoteRecipient` collapses singleton: per `rules.txt`
* - `setVoteRecipient` collapses singleton: per `site/ru/rules.md`
* each race controls a single vote slot, so a newer entry
* supersedes any prior `setVoteRecipient` regardless of the
* acceptor.

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