Compare commits

..

1 Commits

Author SHA1 Message Date
Ilia Denisov 5271f2b1ec feat(ui): lobby site-style sidebar + profile screen (#47)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m30s
- Wrap lobby and profile in a shared `lobby-shell.svelte` chrome:
  page-list sidebar (Overview/Profile) and a top "Player-xxxx"
  identity strip mirroring the project site's monospace look.
- Strip the legacy `lobby.title`, device-session-id `<code>`, and
  `lobby.greeting` paragraph; the identity strip both names the user
  and opens the profile editor.
- Add a top-level `profile` AppScreen with a three-field form
  (`display_name`, `preferred_language`, `time_zone`) backed by a new
  `src/api/account.ts` wrapper around `user.account.get`,
  `user.profile.update`, and `user.settings.update`. Saving switches
  the active i18n locale in-place when the new preferred language is
  one the UI ships translations for.
- Update e2e fixture + auth-flow / lobby-flow specs to use the new
  `lobby-account-name` testid and wait for the loaded identity before
  releasing pending `SubscribeEvents` (webkit revocation race). New
  `profile-screen.spec.ts` covers navigation, edit-save, and cancel.
- Sync `ui/docs/lobby.md` and `ui/docs/navigation.md` to the new
  layout.

Closes #47
2026-05-26 13:42:10 +02:00
422 changed files with 18409 additions and 104435 deletions
+3 -104
View File
@@ -148,102 +148,14 @@ 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 game would
# otherwise keep serving the previous engine code until the
# container is recycled by hand. This step makes the recycle
# automatic but only when it is actually needed:
#
# * BuildKit cache hit on the `Build galaxy-engine image`
# step → `galaxy-engine:dev` keeps its previous SHA →
# no drift → no-op (no engine source change to deploy).
# * engine source change → fresh SHA → for each drifted
# container we stop the backend, remove the container,
# wipe its bind-mounted state directory (Engine.Init()
# writes turn-0 over any pre-existing `turn-N` files —
# silent state corruption otherwise), and cascade-delete
# the lobby `games` row (the FKs in `00001_init.sql`
# drop the matching `runtime_records`, `memberships`,
# `player_mappings`, etc. in the same write).
#
# Backend is stopped first to keep the reconciler from
# racing the recycle (mid-stream adoption / restart). The
# subsequent `Bring up the stack` step restarts it.
set -u
new_sha=$(docker image inspect galaxy-engine:dev --format '{{.Id}}')
echo "fresh galaxy-engine:dev = $new_sha"
drift=()
for c in $(docker ps --filter "name=galaxy-game-" --format '{{.Names}}'); do
cur=$(docker inspect "$c" --format '{{.Image}}')
if [ "$cur" != "$new_sha" ]; then
drift+=("${c#galaxy-game-}")
echo " drift: $c was on $cur"
else
echo " match: $c"
fi
done
if [ ${#drift[@]} -eq 0 ]; then
echo "no drift detected — recycle skipped"
else
docker stop -t 30 galaxy-dev-backend >/dev/null 2>&1 || true
state_root="$HOME/.galaxy-dev/game-state"
for gid in "${drift[@]}"; do
echo "recycling $gid"
docker rm -f "galaxy-game-$gid" >/dev/null 2>&1 || true
# Wipe the per-game state dir as root inside a throwaway
# container so we can remove files left behind by the
# engine container even when its uid differs from the
# runner's.
docker run --rm -v "$state_root:/state" alpine \
sh -c "rm -rf -- /state/$gid"
done
ids_csv=$(printf "'%s'," "${drift[@]}")
ids_csv=${ids_csv%,}
docker exec galaxy-dev-postgres psql -v ON_ERROR_STOP=1 \
-U galaxy -d galaxy_backend \
-c "DELETE FROM backend.games WHERE game_id IN (${ids_csv});"
fi
- name: Reap stray dev-deploy containers
run: |
# Remove any non-running compose-managed containers from
# earlier deploys before `compose up`. Filter by the stack
# label so we never touch unrelated workloads on the same
# daemon. Running engine containers spawned by backend with
# the same label are left intact when their image SHA still
# matches the freshly-built `galaxy-engine:dev` (handled by
# the preceding `Recycle engine containers on image drift`
# step); the reconciler reattaches them on backend boot.
# daemon. Running containers (incl. engine instances backend
# spawned itself with the same label) are left intact
# those are reattached by the backend reconciler on boot.
ids=$(docker ps -aq \
--filter "label=galaxy.stack=dev-deploy" \
--filter "status=exited" \
@@ -256,24 +168,11 @@ 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
-80
View File
@@ -1,80 +0,0 @@
name: Tests · FBS codegen
# Guards that the committed FlatBuffers bindings (Go under
# pkg/schema/fbs/<schema>/ and TS under ui/frontend/src/proto/galaxy/fbs/)
# are exactly what the pinned flatc produces from the .fbs schemas.
# Catches both "changed a schema but forgot to regenerate" and
# "regenerated with the wrong flatc version" (e.g. a distro's older
# flatbuffers-compiler), which silently churns output and can flip
# nullable-scalar wire defaults. Path-filtered so it only runs when the
# schemas, the generated trees, the fbs Makefiles, or this workflow change.
on:
push:
paths:
- 'pkg/schema/fbs/**'
- 'ui/frontend/src/proto/galaxy/fbs/**'
- 'ui/Makefile'
- '.gitea/workflows/fbs-codegen.yaml'
pull_request:
paths:
- 'pkg/schema/fbs/**'
- 'ui/frontend/src/proto/galaxy/fbs/**'
- 'ui/Makefile'
- '.gitea/workflows/fbs-codegen.yaml'
concurrency:
group: fbs-codegen-${{ github.ref }}
cancel-in-progress: true
env:
FLATC_VERSION: 25.9.23
jobs:
codegen:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Cache flatc
id: cache-flatc
uses: actions/cache@v4
with:
path: ${{ runner.temp }}/flatc-bin
key: flatc-${{ env.FLATC_VERSION }}-linux-g++13
- name: Install pinned flatc
if: steps.cache-flatc.outputs.cache-hit != 'true'
run: |
mkdir -p "${{ runner.temp }}/flatc-bin"
curl -sSL -o /tmp/flatc.zip \
"https://github.com/google/flatbuffers/releases/download/v${FLATC_VERSION}/Linux.flatc.binary.g++-13.zip"
python3 -m zipfile -e /tmp/flatc.zip "${{ runner.temp }}/flatc-bin"
chmod +x "${{ runner.temp }}/flatc-bin/flatc"
- name: Add flatc to PATH
run: echo "${{ runner.temp }}/flatc-bin" >> "$GITHUB_PATH"
- name: Verify flatc version
run: flatc --version
- name: Regenerate Go + TS bindings
run: |
make -C pkg/schema/fbs fbs-go
make -C ui fbs-ts
- name: Assert no drift
run: |
if ! git diff --exit-code || [ -n "$(git status --porcelain)" ]; then
echo "::error::Committed FlatBuffers bindings differ from a flatc ${FLATC_VERSION} regeneration."
echo "Run 'make -C pkg/schema/fbs fbs-go' and 'make -C ui fbs-ts' with flatc ${FLATC_VERSION} and commit the result."
git status --porcelain
git --no-pager diff
exit 1
fi
-13
View File
@@ -119,16 +119,3 @@ jobs:
name: playwright-traces
path: ui/frontend/test-results/
retention-days: 14
- name: Remove root-owned build artifacts
if: always()
# In host-mode the job runs as root, so vite (test:pwa),
# svelte-kit and Playwright write these outputs root-owned into
# the shared host workspace. The act_runner (non-root) then
# cannot remove them at teardown ("unlinkat ... permission
# denied"), which spuriously fails this or a sibling job that
# inherits the dirty workspace (observed on go-unit). Clean them
# here while the step still has root, after the uploads above.
run: |
rm -rf ui/frontend/build ui/frontend/.svelte-kit \
ui/frontend/test-results ui/frontend/playwright-report
-6
View File
@@ -16,12 +16,6 @@ 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
+3 -12
View File
@@ -27,16 +27,10 @@ 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 (JSON) |
| `/_gm`, `/_gm/*` | HTTP Basic Auth against `admin_accounts` | Operator console (server-rendered HTML)|
| `/api/v1/admin/*` | HTTP Basic Auth against `admin_accounts` | Platform administrators |
| `/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/`.
@@ -106,7 +100,6 @@ 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`. |
@@ -264,13 +257,11 @@ introduce its own request/response types.
Endpoints used:
- `POST /api/v1/admin/init` — the runtime worker passes the canonical
`game_id` (the same UUID that names the engine container and the
host bind-mount directory) in the request body so the engine's
`state.json` shares identity with the backend's `games.game_id`.
- `POST /api/v1/admin/init`
- `GET /api/v1/admin/status`
- `PUT /api/v1/admin/turn`
- `POST /api/v1/admin/race/banish`
- `PUT /api/v1/command`
- `PUT /api/v1/order`
- `GET /api/v1/report`
- `GET /healthz`
+18 -46
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,7 +37,6 @@ 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"
@@ -273,18 +272,29 @@ func run(ctx context.Context) (err error) {
)
runtimeGateway.svc = runtimeSvc
// 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
// 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
// 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,
@@ -350,32 +360,6 @@ 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,
@@ -404,7 +388,6 @@ 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)
@@ -502,17 +485,6 @@ func (a *userEntitlementAdapter) GetMaxRegisteredRaceNames(ctx context.Context,
return snap.MaxRegisteredRaceNames, nil
}
func (a *userEntitlementAdapter) IsPaid(ctx context.Context, userID uuid.UUID) (bool, error) {
if a == nil || a.svc == nil {
return false, nil
}
snap, err := a.svc.GetEntitlementSnapshot(ctx, userID)
if err != nil {
return false, err
}
return snap.IsPaid, nil
}
// runtimeGatewayAdapter implements `lobby.RuntimeGateway` by
// delegating to `*runtime.Service`. The svc pointer is patched after
// the services are constructed — runtime depends on lobby
-128
View File
@@ -1,128 +0,0 @@
# 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. |
+2 -2
View File
@@ -234,8 +234,8 @@ sequenceDiagram
Workers->>Docker: pull / create / start engine container
Docker-->>Workers: container id
Workers->>Engine: POST /api/v1/admin/init {gameId, races}
Engine-->>Workers: StateResponse{id == gameId} / error
Workers->>Engine: POST /api/v1/admin/init
Engine-->>Workers: ok / error
Workers->>Runtime: write runtime_records (running or start_failed)
Workers->>Lobby: OnRuntimeJobResult
+1 -4
View File
@@ -141,10 +141,7 @@ boot).
polls the engine `/healthz` until the listener is bound (Docker
marks a container running as soon as the entrypoint starts; the
Go binary inside takes a moment to bind its TCP port). Only after
`/healthz` succeeds does the worker call `/admin/init`, passing the
same `game_id` the backend uses to mount the engine's storage
directory; the engine echoes it back in `StateResponse.id`. The
engine rejects a mismatched gameId with `409 Conflict`.
`/healthz` succeeds does the worker call `/admin/init`.
- **Runtime scheduler** (`internal/runtime.SchedulerComponent`) —
`pkg/cronutil` schedule per running game; each tick invokes the
engine `admin/turn`. Force-next-turn flips a one-shot skip flag in
@@ -1,103 +0,0 @@
/* 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
@@ -1,54 +0,0 @@
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))
}
@@ -1,42 +0,0 @@
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")
}
}
@@ -1,24 +0,0 @@
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
@@ -1,18 +0,0 @@
// 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
@@ -1,67 +0,0 @@
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
@@ -1,86 +0,0 @@
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
@@ -1,11 +0,0 @@
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
}
@@ -1,14 +0,0 @@
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
@@ -1,107 +0,0 @@
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")
}
@@ -1,67 +0,0 @@
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)
}
}
@@ -1,30 +0,0 @@
{{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}}
@@ -1,21 +0,0 @@
{{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}}
@@ -1,69 +0,0 @@
{{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}}
@@ -1,30 +0,0 @@
{{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}}
@@ -1,65 +0,0 @@
{{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}}
@@ -1,43 +0,0 @@
{{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}}
@@ -1,32 +0,0 @@
{{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}}
@@ -1,33 +0,0 @@
{{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}}
@@ -1,7 +0,0 @@
{{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}}
@@ -1,27 +0,0 @@
{{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}}
@@ -1,38 +0,0 @@
{{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}}
@@ -1,68 +0,0 @@
{{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}}
@@ -1,27 +0,0 @@
{{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
@@ -1,61 +0,0 @@
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
}
+51 -14
View File
@@ -55,8 +55,6 @@ 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"
@@ -105,6 +103,11 @@ 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.
@@ -173,6 +176,9 @@ 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.
@@ -202,7 +208,6 @@ type Config struct {
Docker DockerConfig
Game GameConfig
Admin AdminBootstrapConfig
AdminConsole AdminConsoleConfig
GeoIP GeoIPConfig
Telemetry TelemetryConfig
Auth AuthConfig
@@ -211,12 +216,29 @@ 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").
@@ -286,15 +308,6 @@ 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
@@ -547,6 +560,10 @@ func DefaultConfig() Config {
TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts,
WorkerInterval: defaultDiplomailWorkerInterval,
},
DevSandbox: DevSandboxConfig{
EngineVersion: defaultDevSandboxEngineVersion,
PlayerCount: defaultDevSandboxPlayerCount,
},
Runtime: RuntimeConfig{
WorkerPoolSize: defaultRuntimeWorkerPoolSize,
JobQueueSize: defaultRuntimeJobQueueSize,
@@ -627,8 +644,6 @@ 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))
@@ -726,6 +741,13 @@ 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
}
@@ -937,6 +959,21 @@ 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
@@ -0,0 +1,287 @@
// 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
}
@@ -0,0 +1,106 @@
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
}
+10 -3
View File
@@ -23,6 +23,7 @@ const (
pathAdminStatus = "/api/v1/admin/status"
pathAdminTurn = "/api/v1/admin/turn"
pathAdminRaceBanish = "/api/v1/admin/race/banish"
pathPlayerCommand = "/api/v1/command"
pathPlayerOrder = "/api/v1/order"
pathPlayerReport = "/api/v1/report"
pathPlayerBattle = "/api/v1/battle"
@@ -182,10 +183,16 @@ func (c *Client) BanishRace(ctx context.Context, baseURL, raceName string) error
}
}
// PutOrders calls `PUT /api/v1/order` with the payload forwarded
// ExecuteCommands calls `PUT /api/v1/command` with payload forwarded
// verbatim. The engine response body is returned verbatim; on 4xx the
// body is returned alongside ErrEngineValidation so callers can forward
// the per-command error.
// body is returned alongside ErrEngineValidation so callers can
// forward the per-command error.
func (c *Client) ExecuteCommands(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
return c.forwardPlayerWrite(ctx, baseURL, pathPlayerCommand, payload, "engine command")
}
// PutOrders calls `PUT /api/v1/order` with the same forwarding
// semantics as ExecuteCommands.
func (c *Client) PutOrders(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
return c.forwardPlayerWrite(ctx, baseURL, pathPlayerOrder, payload, "engine order")
}
+22 -8
View File
@@ -26,7 +26,6 @@ func newTestClient(t *testing.T, srv *httptest.Server) *Client {
func TestClientInitSuccess(t *testing.T) {
wantID := uuid.New()
var gotReq rest.InitRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathAdminInit {
t.Fatalf("unexpected path: %s", r.URL.Path)
@@ -34,16 +33,13 @@ func TestClientInitSuccess(t *testing.T) {
if r.Method != http.MethodPost {
t.Fatalf("unexpected method: %s", r.Method)
}
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatalf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: wantID, Turn: 1, Players: []rest.PlayerState{{ID: uuid.New(), RaceName: "alpha"}}})
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
got, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{GameID: wantID, Races: []rest.InitRace{{RaceName: "alpha"}}})
got, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{Races: []rest.InitRace{{RaceName: "alpha"}}})
if err != nil {
t.Fatalf("Init returned error: %v", err)
}
@@ -53,9 +49,6 @@ func TestClientInitSuccess(t *testing.T) {
if got.Turn != 1 {
t.Fatalf("Turn = %d, want 1", got.Turn)
}
if gotReq.GameID != wantID {
t.Fatalf("request gameId = %s, want %s", gotReq.GameID, wantID)
}
}
func TestClientInitValidationError(t *testing.T) {
@@ -156,6 +149,27 @@ func TestClientBanishRace(t *testing.T) {
}
}
func TestClientCommandsForwardsBody(t *testing.T) {
want := json.RawMessage(`{"actor":"alpha","cmd":[{"@type":"raceQuit"}]}`)
gotResp := json.RawMessage(`{"applied":true}`)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathPlayerCommand || r.Method != http.MethodPut {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
_, _ = w.Write(gotResp)
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
resp, err := cli.ExecuteCommands(context.Background(), srv.URL, want)
if err != nil {
t.Fatalf("ExecuteCommands: %v", err)
}
if string(resp) != string(gotResp) {
t.Fatalf("response = %s, want %s", string(resp), string(gotResp))
}
}
func TestClientReportsForwardsQuery(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathPlayerReport {
+5 -12
View File
@@ -9,21 +9,14 @@ import (
// EntitlementProvider is the read-only view the lobby needs over the
// user-domain entitlement snapshot. The canonical implementation is
// `*user.Service` exposing `GetEntitlementSnapshot(ctx, userID)`; tests
// substitute a fake.
// `*user.Service` exposing `GetEntitlement(ctx, userID)`; tests substitute
// a fake.
//
// `GetMaxRegisteredRaceNames` is consumed at race-name registration time
// — when the caller attempts to register a `pending_registration` row the
// lobby counts already-`registered` rows for that user against this limit.
//
// `IsPaid` is consumed by the user-facing private-game creation gate at
// the HTTP handler level (`POST /api/v1/user/lobby/games`): free-tier
// callers are rejected with `403 forbidden` before the lobby Service is
// invoked. Admin-driven public-game creation
// (`POST /api/v1/admin/games`) bypasses the gate.
// `MaxRegisteredRaceNames` is the only field consumed by when
// the caller attempts to register a `pending_registration` row the lobby
// counts already-`registered` rows for that user against this limit.
type EntitlementProvider interface {
GetMaxRegisteredRaceNames(ctx context.Context, userID uuid.UUID) (int32, error)
IsPaid(ctx context.Context, userID uuid.UUID) (bool, error)
}
// RuntimeGateway is the outbound surface the lobby uses to ask the runtime
+5 -4
View File
@@ -274,10 +274,11 @@ 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.
//
// `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.
// 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.
func (s *Service) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
if err := s.deps.Store.DeleteGame(ctx, gameID); err != nil {
return err
-13
View File
@@ -20,7 +20,6 @@
package lobby
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
@@ -29,7 +28,6 @@ import (
"galaxy/backend/internal/config"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"go.uber.org/zap"
)
@@ -209,17 +207,6 @@ func (s *Service) Config() config.LobbyConfig {
return s.deps.Config
}
// IsPaid reports whether userID currently sits on a paid tier. Thin
// pass-through over EntitlementProvider used by the HTTP handler that
// fronts user-driven private-game creation; admin-driven public-game
// creation does not consult this gate.
func (s *Service) IsPaid(ctx context.Context, userID uuid.UUID) (bool, error) {
if s == nil || s.deps.Entitlement == nil {
return false, fmt.Errorf("lobby: entitlement provider not configured")
}
return s.deps.Entitlement.IsPaid(ctx, userID)
}
// generateInviteCode produces an `inviteCodeBytes`-byte hex code used
// for code-based invites. The function uses `crypto/rand`; a failure to
// read entropy is propagated to the caller.
+2 -6
View File
@@ -103,10 +103,6 @@ func (s stubEntitlement) GetMaxRegisteredRaceNames(_ context.Context, _ uuid.UUI
return s.max, nil
}
func (s stubEntitlement) IsPaid(_ context.Context, _ uuid.UUID) (bool, error) {
return true, nil
}
func newServiceForTest(t *testing.T, db *sql.DB, now func() time.Time, max int32) *lobby.Service {
t.Helper()
store := lobby.NewStore(db)
@@ -248,8 +244,8 @@ func TestEndToEndPrivateGameFlow(t *testing.T) {
}
}
// TestDeleteGameCascadesEverything pins the DeleteGame contract:
// removing a game wipes every referencing row
// TestDeleteGameCascadesEverything pins the contract the dev-sandbox
// bootstrap relies on: 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
+6 -5
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 trusted boot-time provisioning and
// integration tests; it is not exposed through any HTTP handler. The
// caller must guarantee
// 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
// game.Status == GameStatusEnrollmentOpen — the function returns
// ErrConflict otherwise — and that the race-name policy and
// canonical-key invariants are honoured (the implementation reuses
@@ -30,8 +30,9 @@ 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, so
// the helper is safe to call repeatedly.
// the function returns the existing row without modifying state.
// This makes the helper safe to call on every backend boot from
// devsandbox.Bootstrap.
func (s *Service) InsertMembershipDirect(ctx context.Context, in InsertMembershipDirectInput) (Membership, error) {
displayName, err := ValidateDisplayName(in.RaceName)
if err != nil {
+3 -2
View File
@@ -236,8 +236,9 @@ 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. A hard delete for trusted callers and integration tests;
// production lifecycle uses cancel / finish.
// 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.
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
@@ -1,139 +0,0 @@
// 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
}
@@ -1,155 +0,0 @@
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
}
+7 -76
View File
@@ -10,10 +10,7 @@ package postgres
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"strings"
"time"
"galaxy/backend/internal/config"
@@ -70,84 +67,18 @@ func Open(ctx context.Context, cfg config.PostgresConfig, runtime *telemetry.Run
// backend table lives here.
const schemaName = "backend"
// migrationRetryAttempts and migrationRetryBackoff bound the transient-error
// retry around ApplyMigrations. A freshly started Postgres — notably a test
// container — can reset a pooled connection moments after it reports ready,
// which surfaces as `driver: bad connection` mid-migration; a handful of quick
// retries rides over that without masking real failures.
const (
migrationRetryAttempts = 5
migrationRetryBackoff = 250 * time.Millisecond
)
// ApplyMigrations runs every pending Up migration embedded in the backend
// binary against db. The schema is created upfront so goose's bookkeeping
// table (`goose_db_version`, scoped to the DSN `search_path = backend`)
// has somewhere to land before the first migration runs; migration
// `00001_init.sql` re-asserts the schema with `IF NOT EXISTS`, so the
// double-create is idempotent.
//
// The apply is retried on transient connection errors (see retryOnTransient).
// Both steps are idempotent — `CREATE SCHEMA IF NOT EXISTS` and goose's
// version tracking — so a retry after a dropped connection re-runs cleanly and
// resumes from the last committed migration.
func ApplyMigrations(ctx context.Context, db *sql.DB) error {
return retryOnTransient(ctx, migrationRetryAttempts, migrationRetryBackoff, func() error {
if _, err := db.ExecContext(ctx, "CREATE SCHEMA IF NOT EXISTS "+schemaName); err != nil {
return fmt.Errorf("ensure backend schema: %w", err)
}
if err := pgshared.RunMigrations(ctx, db, migrations.Migrations(), "."); err != nil {
return fmt.Errorf("apply backend migrations: %w", err)
}
return nil
})
}
// retryOnTransient runs op up to attempts times, retrying only when op fails
// with a transient connection error (see isTransientConnError) — a dropped,
// reset, or refused connection, as opposed to a deterministic SQL error. It
// waits backoff between attempts and stops early if ctx is cancelled. A
// non-transient error, or the error from the final attempt, is returned as-is.
func retryOnTransient(ctx context.Context, attempts int, backoff time.Duration, op func() error) error {
var err error
for attempt := 1; attempt <= attempts; attempt++ {
if err = op(); err == nil {
return nil
}
if attempt == attempts || !isTransientConnError(err) {
return err
}
select {
case <-ctx.Done():
return errors.Join(err, ctx.Err())
case <-time.After(backoff):
}
}
return err
}
// isTransientConnError reports whether err is a transient connection-level
// failure worth retrying. It matches database/sql's driver.ErrBadConn and the
// connection-failure messages Postgres drivers surface, while leaving
// deterministic SQL errors (syntax, constraint violations) to fail fast.
func isTransientConnError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, driver.ErrBadConn) {
return true
}
msg := strings.ToLower(err.Error())
for _, s := range []string{
"bad connection",
"connection refused",
"connection reset",
"broken pipe",
"server closed the connection",
} {
if strings.Contains(msg, s) {
return true
}
}
return false
if _, err := db.ExecContext(ctx, "CREATE SCHEMA IF NOT EXISTS "+schemaName); err != nil {
return fmt.Errorf("ensure backend schema: %w", err)
}
if err := pgshared.RunMigrations(ctx, db, migrations.Migrations(), "."); err != nil {
return fmt.Errorf("apply backend migrations: %w", err)
}
return nil
}
-103
View File
@@ -1,103 +0,0 @@
package postgres
import (
"context"
"database/sql/driver"
"errors"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIsTransientConnError(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"driver.ErrBadConn", driver.ErrBadConn, true},
{"wrapped ErrBadConn", fmt.Errorf("run migrations: %w", driver.ErrBadConn), true},
// The exact shape observed flaking CI: goose surfaces the driver
// error as a plain string, so errors.Is can't see ErrBadConn.
{"bad connection string", errors.New(`apply backend migrations: run migrations: ERROR 00001_init.sql: CREATE TABLE race_names: driver: bad connection`), true},
{"connection refused", errors.New("dial tcp 127.0.0.1:5432: connect: connection refused"), true},
{"connection reset", errors.New("read tcp: connection reset by peer"), true},
{"broken pipe", errors.New("write tcp: broken pipe"), true},
{"server closed", errors.New("pq: server closed the connection unexpectedly"), true},
{"syntax error is not transient", errors.New(`pq: syntax error at or near "TABL"`), false},
{"constraint violation is not transient", errors.New("pq: duplicate key value violates unique constraint"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, isTransientConnError(tt.err))
})
}
}
func TestRetryOnTransientSucceedsAfterTransientFailures(t *testing.T) {
t.Parallel()
calls := 0
err := retryOnTransient(context.Background(), 5, time.Millisecond, func() error {
calls++
if calls < 3 {
return fmt.Errorf("attempt %d: %w", calls, driver.ErrBadConn)
}
return nil
})
require.NoError(t, err)
assert.Equal(t, 3, calls, "should retry until the transient error clears")
}
func TestRetryOnTransientStopsOnNonTransient(t *testing.T) {
t.Parallel()
sentinel := errors.New(`pq: syntax error at or near "TABL"`)
calls := 0
err := retryOnTransient(context.Background(), 5, time.Millisecond, func() error {
calls++
return sentinel
})
require.ErrorIs(t, err, sentinel)
assert.Equal(t, 1, calls, "a deterministic SQL error must not be retried")
}
func TestRetryOnTransientExhaustsAttempts(t *testing.T) {
t.Parallel()
calls := 0
err := retryOnTransient(context.Background(), 3, time.Millisecond, func() error {
calls++
return driver.ErrBadConn
})
require.ErrorIs(t, err, driver.ErrBadConn)
assert.Equal(t, 3, calls, "must stop after the attempt budget is spent")
}
func TestRetryOnTransientRespectsContextCancellation(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
cancel()
calls := 0
err := retryOnTransient(ctx, 5, time.Hour, func() error {
calls++
return driver.ErrBadConn
})
require.ErrorIs(t, err, context.Canceled)
require.ErrorIs(t, err, driver.ErrBadConn, "the underlying transient error is preserved")
assert.Equal(t, 1, calls, "cancellation during backoff stops further attempts")
}
+1 -1
View File
@@ -52,7 +52,7 @@ var (
ErrTurnAlreadyClosed = errors.New("runtime: turn already closed")
// ErrGamePaused reports that the game is not in a state that
// accepts user-games orders: the runtime row
// accepts user-games commands or orders: the runtime row
// carries `paused = true`, or the runtime status lands on any
// terminal value (`engine_unreachable`, `generation_failed`,
// `stopped`, `finished`, `removed`), or the game has not yet
+6 -6
View File
@@ -258,10 +258,10 @@ func (s *Service) ResolvePlayerMapping(ctx context.Context, gameID, userID uuid.
}
// CheckOrdersAccept verifies that the runtime is in a state that
// accepts user-games orders. It is called by the user game-proxy
// handler (`Orders`) before forwarding to engine, so the backend's
// turn-cutoff and pause guards run before network traffic leaves the
// host. The decision itself lives in the
// accepts user-games commands and orders. It is called by the user
// game-proxy handlers (`Commands`, `Orders`) before forwarding to
// engine, so the backend's turn-cutoff and pause guards run before
// network traffic leaves the host. The decision itself lives in the
// pure helper `OrdersAcceptStatus` so it can be unit-tested without
// constructing a full Service.
//
@@ -276,7 +276,7 @@ func (s *Service) CheckOrdersAccept(ctx context.Context, gameID uuid.UUID) error
}
// OrdersAcceptStatus inspects a runtime record and returns the
// matching sentinel for the user-games order pre-check:
// matching sentinel for the user-games order/command pre-check:
//
// - `runtime_status = generation_in_progress` → `ErrTurnAlreadyClosed`.
// The cron-driven `Scheduler.tick` has flipped the row before
@@ -607,7 +607,7 @@ func (s *Service) runStart(ctx context.Context, op OperationLog) error {
return err
}
initResp, err := s.deps.Engine.Init(ctx, runResult.EngineEndpoint, rest.InitRequest{GameID: gameID, Races: races})
initResp, err := s.deps.Engine.Init(ctx, runResult.EngineEndpoint, rest.InitRequest{Races: races})
if err != nil {
s.deps.Logger.Warn("engine init failed",
zap.String("game_id", gameID.String()),
@@ -203,13 +203,6 @@ func TestServiceStartGameEndToEnd(t *testing.T) {
case "/healthz":
w.WriteHeader(http.StatusOK)
case "/api/v1/admin/init":
var got rest.InitRequest
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode init request: %v", err)
}
if got.GameID != gameID {
t.Errorf("init request gameId = %s, want %s", got.GameID, gameID)
}
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 0, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 3, Population: 10}}})
case "/api/v1/admin/status":
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 1, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 5, Population: 12}}})
@@ -1,235 +0,0 @@
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)
}
@@ -1,423 +0,0 @@
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()
}
@@ -1,353 +0,0 @@
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)
}
}
@@ -1,327 +0,0 @@
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()
}
@@ -1,242 +0,0 @@
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)
}
}
@@ -1,149 +0,0 @@
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
}
@@ -1,166 +0,0 @@
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)
}
}
@@ -1,214 +0,0 @@
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())
}
})
}
}
@@ -1,252 +0,0 @@
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)
}
@@ -1,288 +0,0 @@
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)
}
}
@@ -39,6 +39,55 @@ func NewUserGamesHandlers(rt *runtime.Service, engine *engineclient.Client, logg
return &UserGamesHandlers{runtime: rt, engine: engine, logger: logger.Named("http.user.games")}
}
// Commands handles POST /api/v1/user/games/{game_id}/commands.
func (h *UserGamesHandlers) Commands() gin.HandlerFunc {
if h == nil || h.runtime == nil || h.engine == nil {
return handlers.NotImplemented("userGamesCommands")
}
return func(c *gin.Context) {
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user id missing")
return
}
body, err := io.ReadAll(c.Request.Body)
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body could not be read")
return
}
ctx := c.Request.Context()
if err := h.runtime.CheckOrdersAccept(ctx, gameID); err != nil {
respondGameProxyError(c, h.logger, "user games commands", ctx, err)
return
}
mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID)
if err != nil {
respondGameProxyError(c, h.logger, "user games commands", ctx, err)
return
}
endpoint, err := h.runtime.EngineEndpoint(ctx, gameID)
if err != nil {
respondGameProxyError(c, h.logger, "user games commands", ctx, err)
return
}
payload, err := rebindActor(body, mapping.RaceName)
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be a JSON object")
return
}
resp, err := h.engine.ExecuteCommands(ctx, endpoint, payload)
if err != nil {
respondEngineProxyError(c, h.logger, "user games commands", ctx, resp, err)
return
}
c.Data(http.StatusOK, "application/json", resp)
}
}
// Orders handles POST /api/v1/user/games/{game_id}/orders.
func (h *UserGamesHandlers) Orders() gin.HandlerFunc {
if h == nil || h.runtime == nil || h.engine == nil {
@@ -86,15 +86,6 @@ func (h *UserLobbyGamesHandlers) Create() gin.HandlerFunc {
return
}
ctx := c.Request.Context()
paid, err := h.svc.IsPaid(ctx, userID)
if err != nil {
respondLobbyError(c, h.logger, "user lobby games create", ctx, err)
return
}
if !paid {
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "creating private games requires a paid subscription")
return
}
owner := userID
game, err := h.svc.CreateGame(ctx, lobby.CreateGameInput{
OwnerUserID: &owner,
+1 -59
View File
@@ -81,13 +81,6 @@ 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
@@ -130,7 +123,6 @@ 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 != "" {
@@ -278,6 +270,7 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
raceNames.POST("/register", deps.UserLobbyRaceNames.Register())
userGames := group.Group("/games")
userGames.POST("/:game_id/commands", deps.UserGames.Commands())
userGames.POST("/:game_id/orders", deps.UserGames.Orders())
userGames.GET("/:game_id/orders", deps.UserGames.GetOrders())
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
@@ -372,57 +365,6 @@ 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
+40 -8
View File
@@ -265,12 +265,7 @@ paths:
summary: Create a new private lobby game owned by the caller
description: |
Always emits a `private` game owned by `X-User-ID`. Public games
are created via `POST /api/v1/admin/games`. The endpoint is
gated by the caller's paid tier: free-tier accounts receive
`403 forbidden` (code `forbidden`) and no `draft` row is
created. The tier is read through
`EntitlementProvider.IsPaid(userID)` from the user-domain
service.
are created via `POST /api/v1/admin/games`.
security:
- UserHeader: []
parameters:
@@ -290,8 +285,6 @@ paths:
$ref: "#/components/schemas/LobbyGameDetail"
"400":
$ref: "#/components/responses/InvalidRequestError"
"403":
$ref: "#/components/responses/ForbiddenError"
"501":
$ref: "#/components/responses/NotImplementedError"
"500":
@@ -981,6 +974,37 @@ paths:
$ref: "#/components/responses/NotImplementedError"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/user/games/{game_id}/commands:
post:
tags: [User]
operationId: userGamesCommands
summary: Forward an engine command batch
security:
- UserHeader: []
parameters:
- $ref: "#/components/parameters/XUserID"
- $ref: "#/components/parameters/GameID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/EngineCommand"
responses:
"200":
description: Engine command result passed through.
content:
application/json:
schema:
$ref: "#/components/schemas/PassthroughObject"
"400":
$ref: "#/components/responses/InvalidRequestError"
"404":
$ref: "#/components/responses/NotFoundError"
"501":
$ref: "#/components/responses/NotImplementedError"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/user/games/{game_id}/orders:
post:
tags: [User]
@@ -3507,6 +3531,14 @@ components:
properties:
name:
type: string
EngineCommand:
type: object
additionalProperties: true
description: |
Engine command request body. The schema is permissive because the
engine proxy passes the body through verbatim; the typed shape
lives in `pkg/model/rest.Command` and is enforced by
`internal/engineclient` before the engine call leaves backend.
EngineOrder:
type: object
additionalProperties: true
+5 -59
View File
@@ -142,9 +142,7 @@ because they cross domain boundaries:
- **Public lobby games are admin-created** through
`POST /api/v1/admin/games`. The user-facing
`POST /api/v1/user/lobby/games` always emits `private` games owned by
`X-User-ID`, and is gated by `EntitlementProvider.IsPaid` — free-tier
callers receive `403 forbidden` before the lobby service is invoked.
Public games carry `owner_user_id IS NULL`; the partial
`X-User-ID`. Public games carry `owner_user_id IS NULL`; the partial
index on `(owner_user_id) WHERE visibility = 'private'` keeps the
private-owner lookup efficient.
- **Authenticated lobby commands** flow through the gateway envelope
@@ -375,9 +373,9 @@ Authenticated client traffic for in-game operations crosses three
serialisation boundaries: signed-gRPC FlatBuffers (client ↔ gateway),
JSON over REST (gateway ↔ backend), and JSON over REST again
(backend ↔ engine). Gateway owns the FB ↔ JSON transcoding for the
three message types `user.games.order`, `user.games.order.get`,
`user.games.report` (FB schemas in `pkg/schema/fbs/{order,report}`,
encoders in `pkg/transcoder`).
four message types `user.games.command`, `user.games.order`,
`user.games.order.get`, `user.games.report` (FB schemas in
`pkg/schema/fbs/{order,report}`, encoders in `pkg/transcoder`).
`user.games.order.get` reads back the player's stored order for a
given turn — paired with the POST `user.games.order` so the client
can hydrate its local draft after a cache loss without re-deriving
@@ -402,14 +400,6 @@ Container state is owned by `backend/internal/runtime`:
always `http://galaxy-game-{game_id}:8080`.
- Engine probes (`/healthz`) feed `runtime` health observations and turn
generation status.
- Canonical game identity is owned by backend. The `game_id` allocated
at game-create time is reused everywhere downstream: it names the
container, the host bind-mount directory, and is passed verbatim to
the engine in `POST /api/v1/admin/init`'s `gameId` field. The engine
persists this value into `state.json` and echoes it in every
`StateResponse`; the engine never mints its own game UUID. A zero
UUID or a conflict with an existing `state.json` is rejected by the
engine (`400` / `409` respectively).
## 10. Geo Profile (reduced)
@@ -581,36 +571,6 @@ 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
@@ -853,8 +813,7 @@ 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`; 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. |
| Admin authentication | backend | Basic Auth against `admin_accounts`. |
| Engine API authentication | network | Engine listens only on the trusted network; backend is the only caller. |
### Backend ↔ Gateway trust
@@ -888,19 +847,6 @@ 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
+15 -82
View File
@@ -363,18 +363,6 @@ records the new game with `owner_user_id` set to the caller and
visibility `private`, in state `draft`, with the request body's
configuration as initial values.
The user surface is gated by the caller's paid tier. Backend reads
`EntitlementProvider.IsPaid(userID)` before invoking the lobby
service; free-tier callers are rejected with HTTP
`403 forbidden` (canonical error code `forbidden`) and no `draft`
row is created. The matching UI affordances — the `private games`
sidebar sub-panel and its `create new game` button — are hidden from
free-tier sessions in the lobby shell; the
`VITE_GALAXY_DEV_AFFORDANCES` build flag overrides the UI gate so the
owner can exercise both branches from a single test account in DEV
bundles. Admin-driven public-game creation
([Section 10](#10-administration)) bypasses the tier gate.
Public games are created exclusively through the admin surface
([Section 10](#10-administration)). The user surface never produces a public game; this
asymmetry is enforced in backend, not at the route level.
@@ -619,10 +607,10 @@ not duplicated here.
### 6.2 Backend's role: pass-through with authorisation
The signed authenticated-edge pipeline for in-game traffic uses three
message types on the authenticated surface — `user.games.order`,
`user.games.order.get`, `user.games.report` each with a typed
FlatBuffers payload. Gateway transcodes the FB
The signed authenticated-edge pipeline for in-game traffic uses four
message types on the authenticated surface — `user.games.command`,
`user.games.order`, `user.games.order.get`, `user.games.report`
each with a typed FlatBuffers payload. Gateway transcodes the FB
request into the JSON shape backend expects, forwards over plain
REST to the corresponding `/api/v1/user/games/{game_id}/*` endpoint,
then transcodes the JSON response back into FB before signing the
@@ -648,20 +636,6 @@ validity and ordering of in-game decisions. Gateway needs to know
the typed FB shape only to transcode the wire format; the per-command
semantics live in the engine.
For `user.games.order` specifically, the engine validates every
command in the submitted order against a transient view of the
current game state and reports the outcome per command on each
command's meta (`cmdApplied`, `cmdErrorCode`) inside the same
`UserGamesOrder` body. The order is persisted with these per-command
verdicts even when some commands are rejected — for example, deleting
the "create ship class X" command from an order that still contains
"produce ship X" makes the second command fail with a per-command
`cmdErrorCode` for "entity does not exist", while the rest of the
order remains stored and the response is still a `202 Accepted`. A
`400` is returned only for order-level structural rejections
(`quit` not the last command, unrecognized command type, malformed
input); `500` only for genuine engine-internal failures.
### 6.3 Turn cutoff and auto-pause
A running game continuously alternates between a command-accepting
@@ -671,7 +645,7 @@ in `runtime_records.turn_schedule`. The backend scheduler
`/admin/turn` call between two `runtime_status` flips:
- Before the engine call: `running → generation_in_progress`.
The user-games order handlers
The user-games command/order handlers
(`backend/internal/server/handlers_user_games.go`) consult the
per-game runtime record on every request and reject with
HTTP 409 + `code = turn_already_closed` while the runtime sits in
@@ -704,26 +678,14 @@ demand. Backend authorises the caller and forwards the request;
there is no caching or denormalisation in this path.
The web client renders the report as one section per FBS array
(galaxy summary, races leaving soon, votes, player status, my /
foreign sciences, my / foreign ship classes, battles, bombings,
approaching groups, my / foreign / uninhabited / unknown planets,
ships in production, cargo routes, my fleets, my / foreign /
unidentified ship groups). Empty sections render explicit
empty-state copy; "races leaving soon" is the exception and hides
entirely when no race is near removal. When the local race is
itself within five turns of being auto-removed for inactivity, a
danger-styled personal warning banner above the section list
carries its own turns-remaining countdown; the public "races
leaving soon" section lists every other race within three turns
of removal. Section
navigation is exposed through a sticky icon-popup menu pinned to
the top-right of the report column (an anchored popover on desktop
and a fixed bottom-sheet on mobile); the trigger label tracks the
section currently in view, and picking a menuitem scrolls the
matching section into view. Re-entering the report active view
remounts the component and resets the scroll position; the active
highlight is re-derived from the IntersectionObserver as the user
scrolls.
(galaxy summary, votes, player status, my / foreign sciences, my /
foreign ship classes, battles, bombings, approaching groups, my /
foreign / uninhabited / unknown planets, ships in production,
cargo routes, my fleets, my / foreign / unidentified ship groups).
Empty sections render explicit empty-state copy. Section anchors
are exposed in a sticky table of contents (a `<select>` on mobile)
and the scroll position is preserved across active-view switches
via SvelteKit's `Snapshot` API.
The Bombings section is a flat read-only table — one row per
bombing event, columns for `attacker`, `attack_power`, `wiped`
@@ -846,12 +808,8 @@ every change applies within one frame (no Pixi remount):
`VisibilityDistance(localPlayerDrive)` circles around LOCAL
planets; LOCAL planets are always exempt — the toggle is
named after the visible part of the map rather than the
obscured one). The renderer always runs in torus mode; the
earlier torus / no-wrap radio was removed in F8 polish
(issue #48 п.8) because the topology is a server-side concept
rather than a per-session UI affordance. The renderer-side
no-wrap path is retained for the day the engine surfaces a
bounded-plane mode.
obscured one) plus the torus / no-wrap radio that switches
the renderer mode while preserving the camera centre.
LOCAL planets are always rendered — they have no toggle. Every
other toggle defaults to ON. Hiding a planet cascades onto every
@@ -1162,31 +1120,6 @@ 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
+14 -80
View File
@@ -377,18 +377,6 @@ cancelled достижим из любого pre-finished-состояния.
visibility `private`, в состоянии `draft`, с конфигурацией из
тела запроса в качестве начальных значений.
User-surface гейтится платным тарифом вызывающего. Backend читает
`EntitlementProvider.IsPaid(userID)` перед вызовом lobby-сервиса;
free-tier-вызовы отклоняются с HTTP `403 forbidden`
(канонический код ошибки `forbidden`), и `draft`-запись не
создаётся. Соответствующие UI-аффордансы — подраздел
`private games` в сайдбаре и кнопка `create new game` внутри него —
скрыты в lobby-shell для free-tier-сессий; build-флаг
`VITE_GALAXY_DEV_AFFORDANCES` переопределяет UI-гейт, чтобы owner
мог в DEV-сборке проверять обе ветки с одного тестового аккаунта.
Admin-создание public-игр ([Раздел 10](#10-администрирование))
обходит тир-гейт.
Public-игры создаются исключительно через admin-surface
([Раздел 10](#10-администрирование)). User-surface никогда не
производит public-игру; асимметрия enforced в backend, не на
@@ -637,9 +625,9 @@ Wire-формат команд, приказов и отчётов — собс
### 6.2 Роль backend: pass-through с авторизацией
Подписанный конвейер аутентифицированного edge для in-game-трафика
использует три message types на аутентифицированной поверхности —
`user.games.order`, `user.games.order.get`, `user.games.report`
у каждого типизированный FlatBuffers-payload.
использует четыре message types на аутентифицированной поверхности —
`user.games.command`, `user.games.order`, `user.games.order.get`,
`user.games.report`у каждого типизированный FlatBuffers-payload.
Gateway транскодирует FB-запрос в JSON-форму, которую ждёт backend,
форвардит её REST'ом в соответствующий
`/api/v1/user/games/{game_id}/*` endpoint, после чего транскодирует
@@ -666,20 +654,6 @@ Backend не парсит содержимое payload команд или пр
FB-форму только чтобы транскодировать wire-формат; per-command-
семантика живёт в движке.
Специально для `user.games.order` движок валидирует каждую команду
приказа на транзиентном слепке текущего состояния игры и записывает
итог по каждой команде в её мету (`cmdApplied`, `cmdErrorCode`) в
том же ответе `UserGamesOrder`. Приказ сохраняется с этими
per-command-вердиктами даже если часть команд была отклонена —
например, удаление команды «создать класс корабля X» из приказа,
в котором остаётся «строить X», приводит к тому, что вторая команда
возвращается с `cmdErrorCode` «сущность не существует», а остальные
команды приказа остаются сохранёнными, и ответ остаётся
`202 Accepted`. `400` возвращается только для структурных отказов
на уровне приказа (`quit` не последняя команда, неизвестный
command type, малформированный вход); `500` — только для реальных
внутренних сбоев движка.
### 6.3 Окно хода и auto-pause
Запущенная игра постоянно чередуется между окном приёма команд
@@ -722,26 +696,14 @@ Backend авторизует вызывающего и форвардит зап
нет ни кэширования, ни денормализации.
Web-клиент рендерит отчёт как одну секцию на каждый FBS-массив
(общие сведения, скоро покидающие игру расы, голоса, статус
игроков, мои / чужие науки, мои / чужие классы кораблей, сражения,
бомбардировки, приближающиеся группы, мои / чужие / необитаемые /
неопознанные планеты, корабли в производстве, грузовые маршруты,
мои флоты, мои / чужие / неопознанные группы кораблей). Пустые
секции получают явную копию empty-state; исключение — секция
«скоро покидающие игру расы»: она полностью скрывается, когда ни
одна раса не близка к исключению. Если же близка к исключению за
неактивность сама локальная раса (осталось не более пяти ходов),
над списком секций показывается персональный
баннер-предупреждение (стиль danger) с числом оставшихся ходов;
публичная секция «скоро покидающие игру расы» перечисляет все
прочие расы, до исключения которых осталось не более трёх ходов.
Навигация по секциям — sticky icon-popup в правом
верхнем углу колонки отчёта (анкорный popover на десктопе и фикс.
bottom-sheet на мобильном); подпись на кнопке отслеживает раздел,
который сейчас в зоне видимости, выбор пункта меню — скролл к
нужной секции. При возврате в активный вью отчёт перемонтируется,
позиция скролла сбрасывается к началу, а IntersectionObserver
заново рассчитывает подсветку при прокрутке.
(общие сведения, голоса, статус игроков, мои / чужие науки, мои /
чужие классы кораблей, сражения, бомбардировки, приближающиеся
группы, мои / чужие / необитаемые / неопознанные планеты, корабли в
производстве, грузовые маршруты, мои флоты, мои / чужие /
неопознанные группы кораблей). Пустые секции получают явную копию
empty-state. Якоря секций отображены в sticky-TOC (на мобильном —
`<select>`); позиция скролла сохраняется при переключении активного
представления через SvelteKit `Snapshot` API.
Секция бомбардировок — это плоская read-only-таблица: одна строка на
событие, колонки `attacker`, `attack_power`, признак `wiped` и
@@ -866,12 +828,9 @@ Directory-промоушен ([Раздел 5](#5-реестр-названий-
объединения окружностей
`VisibilityDistance(localPlayerDrive)` вокруг LOCAL-планет;
LOCAL-планеты всегда вне фильтра — тоггл назван по видимой
области карты, а не по затемнённой). Рендерер всегда работает
в торическом режиме; прежняя радиогруппа «торус / без
переноса» была удалена в полишинге F8 (issue #48 п.8),
поскольку топология карты — серверная сущность, а не
per-session UI-настройка. Код-путь без переноса в рендерере
оставлен на день, когда движок выставит режим bounded plane.
области карты, а не по затемнённой) плюс радиогруппа
«торус / без переноса», переключающая режим рендерера с
сохранением центра камеры.
LOCAL-планеты отрисовываются всегда — для них тоггла нет.
Остальные тогглы по умолчанию включены. Скрытие планеты
@@ -1197,31 +1156,6 @@ 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-аккаунтами
Существующие админы могут перечислять других админов, создавать
+17 -36
View File
@@ -43,10 +43,11 @@ described below. Endpoints split into two route classes:
| Class | Path | Caller | Purpose |
| --- | --- | --- | --- |
| Admin (GM-only) | `POST /api/v1/admin/init` | `Game Master` | Initialise the engine with a canonical `gameId` and the race roster. |
| Admin (GM-only) | `POST /api/v1/admin/init` | `Game Master` | Initialise the engine with the race roster. |
| Admin (GM-only) | `GET /api/v1/admin/status` | `Game Master` | Read the full game state. |
| Admin (GM-only) | `PUT /api/v1/admin/turn` | `Game Master` | Generate the next turn. |
| Admin (GM-only) | `POST /api/v1/admin/race/banish` | `Game Master` | Deactivate a race after a permanent platform removal. |
| Player | `PUT /api/v1/command` | `Game Master` (forwarded from `Edge Gateway`) | Execute a batch of player commands. |
| Player | `PUT /api/v1/order` | `Game Master` | Validate and store a batch of player orders. |
| Player | `GET /api/v1/order` | `Game Master` | Fetch the previously stored player order for a turn. |
| Player | `GET /api/v1/report` | `Game Master` | Fetch the per-player turn report. |
@@ -64,24 +65,6 @@ Documented in [`openapi.yaml`](openapi.yaml). When the engine has not been
initialised through `POST /api/v1/admin/init`, game endpoints respond
`501 Not Implemented` to make the uninitialised state unambiguous.
### `POST /api/v1/admin/init`
The canonical game identity is owned by the orchestrator (`Game Master`),
not by the engine. The request body is `{ "gameId": "<uuid>", "races": [...] }`
where:
- `gameId` is a non-zero UUID generated by the orchestrator before the
engine container is launched. The same value names the engine's host
storage directory and is persisted into `state.json`. The engine
rejects the zero UUID with `400 Bad Request` and any value that
conflicts with an existing `state.json` on disk with
`409 Conflict`. A second `init` on the same `gameId` is also
rejected with `409`; idempotency is not part of the contract.
- `races` is the race roster; minimum 10 entries.
On success the engine responds `201 Created` with a `StateResponse`
whose `id` echoes the supplied `gameId`.
### `StateResponse.finished`
`StateResponse` (returned by `GET /api/v1/admin/status` and
@@ -102,13 +85,9 @@ remove-and-banish flow.
non-empty and must match an existing race in the engine's roster.
- Successful response: `204 No Content` with an empty body.
- Error responses follow the same `400` / `500` envelope shape as the
other admin endpoints. `banish` only flags the race extinct, so it can
no longer submit or have orders applied; its assets are released at the
start of the next turn generation (`TurnWipeExtinctRaces`), the same way
an idle/quit timeout is handled but without the wait — ship groups and
fleets are removed, its planets become uninhabited (the working industry
and the capital stockpile are cleared, raw material is retained), and
votes cast for it are reset.
other admin endpoints. The engine-side mechanics of `banish` (what
exactly happens to the race's planets, fleets, and pending orders) are
owned by the engine maintainers.
### `GET /healthz`
@@ -169,17 +148,19 @@ Alternatives considered and rejected:
`game/internal/router/handler/handler.go` exports `ResolveStoragePath`,
which returns the engine storage path from the env-var pair above and
an error when neither is set. `cmd/http/main.go` calls it once at
startup, prints the error to stderr and exits non-zero on failure, then
builds the engine service (`controller.NewService(path)`) and hands it
to `router.NewRouter`.
an error when neither is set. `cmd/http/main.go` calls it before
constructing the router, prints the error to stderr, and exits non-zero.
The existing `initConfig` closure also calls `ResolveStoragePath` to
populate `controller.Param.StoragePath` at request time; the error there
is dropped because `main` already validated the environment at startup.
Storage is resolved exactly once, at construction, rather than per
request: the `Service` holds the file-backed repo for the process
lifetime and `router.NewRouter` takes the `handler.Engine` it routes
to (in production, the `Service`). This keeps the env binding in one
place — a startup helper plus the `main` check — and leaves the
handlers free of configuration concerns.
This keeps the public router surface (`router.NewRouter`) unchanged —
the env binding is satisfied by one helper plus a startup check, with
no API ripple. Moving env reading entirely into `main` and changing
`NewRouter` / `NewDefaultExecutor` to accept an explicit path was
rejected: it churns multiple call sites for no functional gain. The
current shape leaves the configurer closure ready for future
config-injection refactors without forcing one now.
## Build
+2 -10
View File
@@ -4,25 +4,17 @@ import (
"fmt"
"os"
"galaxy/game/internal/controller"
"galaxy/game/internal/router"
"galaxy/game/internal/router/handler"
)
func main() {
path, err := handler.ResolveStoragePath()
if err != nil {
if _, err := handler.ResolveStoragePath(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
svc, err := controller.NewService(path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
r := router.NewRouter(svc)
r := router.NewRouter()
if err := r.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
+69 -128
View File
@@ -1,6 +1,7 @@
package controller
import (
"iter"
"maps"
"math/rand/v2"
"slices"
@@ -18,9 +19,8 @@ type Battle struct {
InitialNumbers map[int]uint // Initial number of ships in the group
Protocol []BattleAction
// attacker maps an attacking group to every opponent group it is able to
// destroy, with that pair's per-ship destruction probability (> 0).
attacker map[int]map[int]float64
shipAmmo map[int]uint
attacker map[int]map[int]float64 // a group able to attack and destroy an opponent with some probability > 0
}
type BattleAction struct {
@@ -32,23 +32,14 @@ type BattleAction struct {
func CollectPlanetGroups(c *Cache) map[uint]map[int]bool {
planetGroup := make(map[uint]map[int]bool)
for groupIndex := range c.ShipGroupsIndex() {
sg := c.ShipGroup(groupIndex)
var planetNumber uint
switch sg.State() {
case game.StateInOrbit, game.StateUpgrade:
planetNumber = sg.Destination
case game.StateLaunched:
// Ordered to depart but still physically at the origin planet, so
// it joins the pre-departure battle there; only survivors then
// enter hyperspace.
planetNumber = sg.StateInSpace.Origin
default:
continue
state := c.ShipGroup(groupIndex).State()
if state == game.StateInOrbit || state == game.StateUpgrade {
planetNumber := c.ShipGroup(groupIndex).Destination
if _, ok := planetGroup[planetNumber]; !ok {
planetGroup[planetNumber] = make(map[int]bool)
}
planetGroup[planetNumber][groupIndex] = false
}
if _, ok := planetGroup[planetNumber]; !ok {
planetGroup[planetNumber] = make(map[int]bool)
}
planetGroup[planetNumber][groupIndex] = false
}
for pl := range planetGroup {
if len(planetGroup[pl]) < 2 {
@@ -59,18 +50,7 @@ func CollectPlanetGroups(c *Cache) map[uint]map[int]bool {
}
func FilterBattleGroups(c *Cache, groups map[int]bool) []int {
return slices.DeleteFunc(slices.Collect(maps.Keys(groups)), func(groupIndex int) bool {
// Everything physically present at the planet fights: ships in orbit,
// ships being upgraded, and ships ordered to depart that have not yet
// entered hyperspace (Launched). Only ships already in hyperspace are
// out of reach.
switch c.ShipGroup(groupIndex).State() {
case game.StateInOrbit, game.StateUpgrade, game.StateLaunched:
return false
default:
return true
}
})
return slices.DeleteFunc(slices.Collect(maps.Keys(groups)), func(groupIndex int) bool { return c.ShipGroup(groupIndex).State() != game.StateInOrbit })
}
func FilterBattleOpponents(c *Cache, attIdx, defIdx int, cacheProbability map[int]map[int]float64) bool {
@@ -85,20 +65,12 @@ func FilterBattleOpponents(c *Cache, attIdx, defIdx int, cacheProbability map[in
return true
}
defSg := c.ShipGroup(defIdx)
if defSg.Number == 0 {
return true
}
defSt := c.ShipGroupShipClass(defIdx)
// The shot targets a single enemy ship, so the defending mass is the
// per-ship full mass: a group's full mass spreads evenly across its
// ships, hence FullMass / Number, not the whole group's mass.
p := calc.DestructionProbability(
c.ShipGroupShipClass(attIdx).Weapons.F(),
c.ShipGroup(attIdx).TechLevel(game.TechWeapons).F(),
defSt.Shields.F(),
defSg.TechLevel(game.TechShields).F(),
defSg.FullMass(defSt)/float64(defSg.Number),
c.ShipGroupShipClass(defIdx).Shields.F(),
c.ShipGroup(defIdx).TechLevel(game.TechShields).F(),
c.ShipGroup(defIdx).FullMass(c.ShipGroupShipClass(defIdx)),
)
// Exclude opponent's group which cannot be probably destroyed
if p <= 0 {
@@ -136,6 +108,7 @@ func ProduceBattles(c *Cache) []*Battle {
ObserverGroups: observerGroups,
InitialNumbers: make(map[int]uint),
attacker: make(map[int]map[int]float64),
shipAmmo: make(map[int]uint),
}
for sgi := range observerGroups {
b.InitialNumbers[sgi] = c.ShipGroup(sgi).Number
@@ -153,11 +126,12 @@ func ProduceBattles(c *Cache) []*Battle {
return FilterBattleOpponents(c, attIdx, defIdx, cacheProbability)
})
if len(opponents) > 0 {
b.shipAmmo[attIdx] = c.ShipGroupShipClass(attIdx).Armament
b.ObserverGroups[attIdx] = true
if b.attacker[attIdx] == nil {
b.attacker[attIdx] = make(map[int]float64)
}
for _, defIdx := range opponents {
if _, ok := b.attacker[attIdx][defIdx]; !ok {
b.attacker[attIdx] = make(map[int]float64)
}
b.attacker[attIdx][defIdx] = cacheProbability[attIdx][defIdx]
b.ObserverGroups[defIdx] = true
}
@@ -171,111 +145,78 @@ func ProduceBattles(c *Cache) []*Battle {
}
clear(b.attacker)
clear(b.shipAmmo)
}
return result
}
// SingleBattle resolves one battle ship by ship. In every round each still
// living ship gets to fire, chosen in random order across all groups; a ship
// fires all of its guns (Armament), each at a randomly chosen enemy ship it is
// able to destroy. A ship destroyed before its turn comes does not fire. The
// battle ends once no ship can damage any remaining enemy.
func SingleBattle(c *Cache, b *Battle) {
for {
// Snapshot this round's shooters: one entry per living ship of every
// group that still has destroyable opponents.
shooters := make([]int, 0)
for attIdx := range b.attacker {
for range c.ShipGroup(attIdx).Number {
shooters = append(shooters, attIdx)
}
roundShooters := make(map[int]bool)
for len(b.attacker) > 0 {
// список участников раунда
clear(roundShooters)
for sgi := range b.attacker {
roundShooters[sgi] = true
}
if len(shooters) == 0 {
return
}
rand.Shuffle(len(shooters), func(i, j int) { shooters[i], shooters[j] = shooters[j], shooters[i] })
// fired counts, per group, how many of its ships have already shot
// this round; a token beyond the group's current (post-casualty) ship
// count belongs to a ship destroyed earlier in the round and is skipped.
fired := make(map[int]uint)
progressed := false
for _, attIdx := range shooters {
if fired[attIdx] >= c.ShipGroup(attIdx).Number {
continue
}
fired[attIdx]++
for len(roundShooters) > 0 {
// attacke group id among round participants
attIdx := randomValue(maps.Keys(roundShooters))
delete(roundShooters, attIdx)
for range c.ShipGroupShipClass(attIdx).Armament {
defIdx, ok := c.pickTargetShip(b, attIdx)
if !ok {
break
for range b.shipAmmo[attIdx] {
// defender group id among all attacker's opponents
defIdx := randomValue(maps.Keys(b.attacker[attIdx]))
destroyed := false
probability := b.attacker[attIdx][defIdx]
switch {
case probability >= 1:
destroyed = true
case probability > 0:
destroyed = rand.Float64() >= probability
default:
panic("SingleBattle: probability unexpected: value <= 0")
}
progressed = true
destroyed := destructionRoll(b.attacker[attIdx][defIdx])
b.Protocol = append(b.Protocol, BattleAction{
Attacker: attIdx,
Defender: defIdx,
Destroyed: destroyed,
})
if destroyed {
c.ShipGroupDestroyItem(defIdx)
if c.ShipGroup(defIdx).Number == 0 {
b.removeFromBattle(defIdx)
}
if c.ShipGroup(defIdx).Number == 0 {
// Eliminated group cant attack anyone
delete(b.attacker, defIdx)
delete(roundShooters, defIdx)
for attIdx := range b.attacker {
// Other attackers can't attack eliminated group anymore
delete(b.attacker[attIdx], defIdx)
if len(b.attacker[attIdx]) == 0 {
// Remove attacker if he lost all opponents
delete(b.attacker, attIdx)
delete(roundShooters, attIdx)
}
}
}
// When attacker has no more targets to shoot - break its ammo cycle
if len(b.attacker[attIdx]) == 0 {
break
}
}
}
// No shooter found a target this round: every remaining opponent is
// out of reach, the battle is over.
if !progressed {
return
}
}
}
// pickTargetShip selects a random enemy ship for attacker group attIdx among
// the groups it is able to destroy, weighted by each group's current ship
// count so that every living enemy ship is equally likely to be hit.
func (c *Cache) pickTargetShip(b *Battle, attIdx int) (int, bool) {
opponents := b.attacker[attIdx]
var total uint
for defIdx := range opponents {
total += c.ShipGroup(defIdx).Number
}
if total == 0 {
return 0, false
}
r := rand.IntN(int(total))
for defIdx := range opponents {
r -= int(c.ShipGroup(defIdx).Number)
if r < 0 {
return defIdx, true
}
}
return 0, false
}
// removeFromBattle drops an eliminated group: it can no longer attack, and no
// one can target it. Attackers left without any opponent are removed as well.
func (b *Battle) removeFromBattle(groupIdx int) {
delete(b.attacker, groupIdx)
for attIdx := range b.attacker {
delete(b.attacker[attIdx], groupIdx)
if len(b.attacker[attIdx]) == 0 {
delete(b.attacker, attIdx)
}
}
}
func destructionRoll(probability float64) bool {
switch {
case probability >= 1:
return true
case probability > 0:
return rand.Float64() < probability
default:
panic("SingleBattle: probability unexpected: value <= 0")
}
func randomValue(v iter.Seq[int]) int {
ids := slices.Collect(v)
return ids[rand.IntN(len(ids))]
}
+1 -109
View File
@@ -108,11 +108,7 @@ func TestFilterBattleOpponents(t *testing.T) {
assert.False(t, controller.FilterBattleOpponents(c, 0, 2, cacheProbability))
assert.Contains(t, cacheProbability, 0)
assert.Contains(t, cacheProbability[0], 2)
// Group 2 holds 15 ships, but a shot targets a single ship, so the
// defending mass is the per-ship full mass (group mass / 15), which
// yields a far lower destruction probability than the pre-fix group-mass
// calculation (which read ~0.396).
assert.InDelta(t, 0.07064783, cacheProbability[0][2], 0.0000001)
assert.InDelta(t, 0.396222, cacheProbability[0][2], 0.0000001)
assert.False(t, controller.FilterBattleOpponents(c, 2, 0, cacheProbability))
assert.Contains(t, cacheProbability, 2)
assert.Contains(t, cacheProbability[2], 0)
@@ -275,107 +271,3 @@ func TestTransformBattleAggregatesSameShipClass(t *testing.T) {
gunship1.Number, gunship1.NumberLeft)
}
}
// TestDestructionRollDirection guards the corrected probability application:
// a shot destroys its target with probability p, not 1-p. The pre-fix code
// compared rand >= p, which inverted the rate (a near-certain hit became a
// near-certain miss).
func TestDestructionRollDirection(t *testing.T) {
const trials = 100000
for _, p := range []float64{0.1, 0.5, 0.9} {
hits := 0
for range trials {
if controller.DestructionRoll(p) {
hits++
}
}
rate := float64(hits) / float64(trials)
assert.InDelta(t, p, rate, 0.02, "destruction rate must track p=%.2f, not 1-p", p)
}
assert.True(t, controller.DestructionRoll(1.0))
assert.True(t, controller.DestructionRoll(1.5))
assert.Panics(t, func() { controller.DestructionRoll(0) })
assert.Panics(t, func() { controller.DestructionRoll(-0.1) })
}
// TestSingleBattleOneSidedWipe checks the per-ship model end to end: a group
// of armed ships that always destroys its target wipes a larger group of
// unarmed transports that cannot fire back, while every shot in the protocol
// is accounted for and the attacker takes no losses.
func TestSingleBattleOneSidedWipe(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.RaceRelation(Race_0.Name, Race_1.Name, game.RelationWar.String()))
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
// Killer's effective attack overwhelms the freighter's shields, so every
// shot destroys (probability >= 1).
assert.NoError(t, c.ShipClassCreate(Race_0_idx, "Killer", 10, 1, 40, 10, 0))
assert.NoError(t, c.CreateShips(Race_0_idx, "Killer", R0_Planet_0_num, 3)) // group 0
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Freighter).ID, R0_Planet_0_num, 5) // group 1
battles := controller.ProduceBattles(c)
assert.Len(t, battles, 1)
assert.Zero(t, c.ShipGroup(1).Number, "all unarmed transports must be destroyed")
assert.Equal(t, uint(3), c.ShipGroup(0).Number, "unarmed transports cannot retaliate")
kills := 0
for _, a := range battles[0].Protocol {
if a.Destroyed {
kills++
}
}
assert.Equal(t, 5, kills, "exactly the five transports are destroyed")
}
// TestCollectPlanetGroupsIncludesLaunchedAndUpgrade checks that every group
// physically at a planet — in orbit, being upgraded, or ordered to depart but
// not yet flown (Launched) — is collected for, and kept in, the battle.
func TestCollectPlanetGroupsIncludesLaunchedAndUpgrade(t *testing.T) {
c, _ := newCache()
// group 0: in orbit at Planet_0
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 1))
// group 1: ordered to depart Planet_0 (Launched), still physically there
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1))
c.ShipGroup(1).StateInSpace = &game.InSpace{Origin: R0_Planet_0_num}
c.ShipGroup(1).Destination = R0_Planet_2_num
assert.Equal(t, game.StateLaunched, c.ShipGroup(1).State())
// group 2: being upgraded at Planet_0
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1))
c.ShipGroup(2).StateUpgrade = &game.InUpgrade{UpgradeTech: []game.UpgradePreference{{Tech: game.TechDrive, Level: 2.0, Cost: 100}}}
assert.Equal(t, game.StateUpgrade, c.ShipGroup(2).State())
pg := controller.CollectPlanetGroups(c)
assert.Contains(t, pg, R0_Planet_0_num)
assert.Len(t, pg[R0_Planet_0_num], 3)
for _, idx := range []int{0, 1, 2} {
assert.Contains(t, pg[R0_Planet_0_num], idx)
}
battleGroups := controller.FilterBattleGroups(c, pg[R0_Planet_0_num])
assert.Len(t, battleGroups, 3)
}
// TestProduceBattlesLaunchedFightsAtOrigin checks that a group ordered to
// depart (Launched) still fights the pre-departure battle at its origin
// planet, rather than escaping into hyperspace before the fight.
func TestProduceBattlesLaunchedFightsAtOrigin(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.RaceRelation(Race_0.Name, Race_1.Name, game.RelationWar.String()))
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
// Race_0: armed group in orbit at Planet_0.
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10))
// Race_1: armed group ordered to depart Planet_0 (Launched), still there.
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R0_Planet_0_num, 10)
c.ShipGroup(1).StateInSpace = &game.InSpace{Origin: R0_Planet_0_num}
c.ShipGroup(1).Destination = R0_Planet_2_num
assert.Equal(t, game.StateLaunched, c.ShipGroup(1).State())
battles := controller.ProduceBattles(c)
assert.Len(t, battles, 1)
assert.True(t, battles[0].ObserverGroups[1], "launched group must be marked in-battle")
if c.ShipGroup(0).Number == 0 {
assert.Greater(t, c.ShipGroup(1).Number, uint(0))
} else {
assert.Zero(t, c.ShipGroup(1).Number)
}
}
+8 -25
View File
@@ -1,10 +1,6 @@
package controller
import (
"cmp"
"maps"
"slices"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
@@ -17,14 +13,8 @@ func (c *Cache) ProduceBombings() []*game.Bombing {
if !p.Owned() {
continue
}
// The planet is hit by all attacking races at once, accounted from the
// strongest bombing power downwards, until no population remains.
attackers := slices.Collect(maps.Keys(enemies))
slices.SortFunc(attackers, func(a, b int) int {
return cmp.Compare(c.bombingPower(enemies[b]), c.bombingPower(enemies[a]))
})
for _, ri := range attackers {
br := c.bombingReport(p, ri, enemies[ri])
for ri, groups := range enemies {
br := c.bombingReport(p, ri, groups)
report = append(report, br)
if br.Wiped {
break
@@ -32,11 +22,7 @@ func (c *Cache) ProduceBombings() []*game.Bombing {
}
if p.Population == 0 {
// Wiped out: the planet turns uninhabited and its industry
// collapses, but the material and capital stockpiles survive for
// whoever colonises it next (rules "Бомбардировка планет").
p.Free()
p.Ind(0)
} else {
// Если на планете остались также и колонисты, то они превращаются в население,
// а накопленная промышленность возмещает потери производства.
@@ -47,16 +33,13 @@ func (c *Cache) ProduceBombings() []*game.Bombing {
return report
}
func (c *Cache) bombingPower(groups []int) float64 {
var power float64
for _, i := range groups {
power += c.ShipGroup(i).BombingPower(c.ShipGroupShipClass(i))
}
return power
}
func (c *Cache) bombingReport(p *game.Planet, ri int, groups []int) *game.Bombing {
attackPower := c.bombingPower(groups)
attackPower := 0.
for _, i := range groups {
sg := c.ShipGroup(i)
st := c.ShipGroupShipClass(i)
attackPower += sg.BombingPower(st)
}
r := &game.Bombing{
ID: uuid.New(),
PlanetOwnedID: *p.Owner,
-56
View File
@@ -141,59 +141,3 @@ func TestProduceBombings(t *testing.T) {
}
}
}
// TestBombingOrderByPower checks that attacking races are accounted from the
// strongest bombing power downwards (the report order), not in the random map
// iteration order the engine used before.
func TestBombingOrderByPower(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
weakIdx, _ := c.AddRace("Weakling")
assert.NoError(t, c.UpdateRelation(weakIdx, Race_0_idx, game.RelationWar))
assert.NoError(t, c.ShipClassCreate(weakIdx, "Pebble", 1, 1, 1, 1, 0))
// Planet_0 (Race_0) survives both attacks.
c.MustPlanet(R0_Planet_0_num).Population = 1000
// Strong: one Race_1 gunship (~358.9 power); weak: one Pebble (~1.1 power).
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R0_Planet_0_num, 1)
c.CreateShipsUnsafe_T(weakIdx, c.MustShipClass(weakIdx, "Pebble").ID, R0_Planet_0_num, 1)
reports := c.ProduceBombings()
assert.Len(t, reports, 2)
assert.Equal(t, Race_1.Name, reports[0].Attacker, "strongest attacker comes first")
assert.Equal(t, "Weakling", reports[1].Attacker)
assert.Greater(t, reports[0].AttackPower.F(), reports[1].AttackPower.F())
}
// TestBombingWipeZeroesIndustry checks that a planet bombed to extinction loses
// its industry but keeps its material and capital stockpiles for the next
// colonist (rules "Бомбардировка планет").
func TestBombingWipeZeroesIndustry(t *testing.T) {
c, _ := newCache()
bomberIdx, _ := c.AddRace("Bomber")
assert.NoError(t, c.UpdateRelation(bomberIdx, Race_0_idx, game.RelationWar))
// Bombing power ~106.5 (W=60, A=1, weapons tech 1.0): wipes pop 50 while
// only partly converting industry, so the leftover industry is observable.
assert.NoError(t, c.ShipClassCreate(bomberIdx, "Reaper", 1, 1, 60, 1, 0))
p := c.MustPlanet(R0_Planet_0_num)
p.Population = 50
p.Industry = 200
p.Capital = 30
p.Material = 20
p.Colonists = 0
c.CreateShipsUnsafe_T(bomberIdx, c.MustShipClass(bomberIdx, "Reaper").ID, R0_Planet_0_num, 1)
reports := c.ProduceBombings()
assert.Len(t, reports, 1)
assert.True(t, reports[0].Wiped)
pl := c.MustPlanet(R0_Planet_0_num)
assert.False(t, pl.Owned())
assert.Equal(t, 0., pl.Population.F())
assert.Equal(t, 0., pl.Industry.F(), "industry collapses on wipe")
assert.Equal(t, 30., pl.Capital.F(), "capital stockpile survives")
assert.InDelta(t, 126.476, pl.Material.F(), 0.01, "material keeps the converted industry")
}
+270 -134
View File
@@ -2,10 +2,8 @@ package controller
import (
"errors"
"fmt"
"time"
e "galaxy/error"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
@@ -16,147 +14,162 @@ import (
"galaxy/game/internal/repo"
)
// Service is the engine's application service: it owns persistence and exposes
// the operations the HTTP handlers invoke. It is safe for concurrent use —
// reads are lock-free and the writers that mutate the canonical state file
// (init/turn/banish) are serialised at the router by a shared LimitMiddleware.
type Service struct {
repo *repo.Repo
type Configurer func(*Param)
type Repo interface {
// Lock must be called before any repository operations
Lock() error
// Release must be called after first and only repository operation
Release() error
// SaveTurn stores just generated new turn
SaveNewTurn(uint, *game.Game) error
// SaveState stores current game state updated between turns
SaveLastState(*game.Game) error
// LoadState retrieves game current state with required lock acquisition
LoadState() (*game.Game, error)
// LoadStateSafe retrieves game current state without preliminary locking
LoadStateSafe() (*game.Game, error)
// SaveBattle stores a new battle protocol and battle meta data for turn t
SaveBattle(uint, *report.BattleReport, *game.BattleMeta) error
// LoadBattle reads battle's protocol for turn t and battle id.
// Returns false if battle with such id was never stored at turn t
LoadBattle(t uint, id uuid.UUID) (*report.BattleReport, bool, error)
// SaveBombing stores all prodused bombings for turn t
SaveBombings(uint, []*game.Bombing) error
// SaveReport stores latest report for a race
SaveReport(uint, *report.Report) error
// LoadReport loads report for specific turn and player id
LoadReport(uint, uuid.UUID) (*report.Report, error)
// SaveOrder stores order for given turn
SaveOrder(uint, uuid.UUID, *order.UserGamesOrder) error
// LoadOrder loads order for specific turn and player id
LoadOrder(uint, uuid.UUID) (*order.UserGamesOrder, bool, error)
}
// NewService opens the file-backed storage at storagePath and returns a ready
// Service. The directory must already exist and be writable.
func NewService(storagePath string) (*Service, error) {
r, err := repo.NewFileRepo(storagePath)
type Ctrl interface {
ValidateOrder(actor string, cmd ...order.DecodableCommand) error
// remove below funcs if /command api will be deleted
RaceID(actor string) (uuid.UUID, error)
RaceQuit(actor string) error
RaceVote(actor, acceptor string) error
RaceRelation(actor, acceptor string, rel string) error
ShipClassCreate(actor, typeName string, drive float64, ammo int, weapons, shileds, cargo float64) error
ShipClassMerge(actor, name, targetName string) error
ShipClassRemove(actor, typeName string) error
ShipGroupLoad(actor string, groupID uuid.UUID, cargoType string, quantity float64) error
ShipGroupUnload(actor string, groupID uuid.UUID, quantity float64) error
ShipGroupSend(actor string, groupID uuid.UUID, planetNumber uint) error
ShipGroupUpgrade(actor string, groupID uuid.UUID, techInput string, limitLevel float64) error
ShipGroupBreak(actor string, groupID, newID uuid.UUID, quantity uint) error
ShipGroupMerge(actor string) error
ShipGroupDismantle(actor string, groupID uuid.UUID) error
ShipGroupTransfer(actor, acceptor string, groupID uuid.UUID) error
ShipGroupJoinFleet(actor, fleetName string, groupID uuid.UUID) error
FleetMerge(actor, fleetSourceName, fleetTargetName string) error
FleetSend(actor, fleetName string, planetNumber uint) error
ScienceCreate(actor, typeName string, drive, weapons, shields, cargo float64) error
ScienceRemove(actor, typeName string) error
PlanetRename(actor string, planetNumber int, typeName string) error
PlanetProduce(actor string, planetNumber int, prodType, subject string) error
PlanetRouteSet(actor, loadType string, origin, destination uint) error
PlanetRouteRemove(actor, loadType string, origin uint) error
}
func GenerateGame(configure func(*Param), races []string) (s game.State, err error) {
ec, err := NewRepoController(configure)
if err != nil {
return game.State{}, err
}
if err = ec.Repo.Lock(); err != nil {
return
}
defer func() {
err = errors.Join(err, ec.Repo.Release())
if err == nil {
s, err = GameState(configure)
}
}()
_, err = NewGame(ec.Repo, races)
return
}
func GenerateTurn(configure func(*Param)) (err error) {
ec, err := NewRepoController(configure)
if err != nil {
return err
}
err = ec.executeLocked(func(c *Controller) error { return c.MakeTurn() })
return
}
func LoadReport(configure func(*Param), actor string, turn uint) (*report.Report, error) {
ec, err := NewRepoController(configure)
if err != nil {
return nil, err
}
return &Service{repo: r}, nil
return ec.loadReport(actor, turn)
}
// GenerateGame initialises a fresh game in storage under the supplied
// canonical gameID. The orchestrator must allocate gameID before the engine
// container is started and pass it here as the request body of
// POST /api/v1/admin/init. A zero UUID is rejected with ErrGameInitNilUUID; an
// attempt to init on top of an existing state.json is rejected with
// ErrGameAlreadyInit.
func (s *Service) GenerateGame(gameID uuid.UUID, races []string) (game.State, error) {
if gameID == uuid.Nil {
return game.State{}, ErrGameInitNilUUID
func ExecuteCommand(configure func(*Param), consumer func(c Ctrl) error) (err error) {
ec, err := NewRepoController(configure)
if err != nil {
return err
}
if existing, loadErr := s.repo.LoadState(); loadErr == nil {
return game.State{}, fmt.Errorf("%w: stored gameId=%s, requested=%s", ErrGameAlreadyInit, existing.ID, gameID)
} else if !isGameNotInitialized(loadErr) {
return game.State{}, fmt.Errorf("check existing state: %w", loadErr)
}
if _, err := NewGame(s.repo, gameID, races); err != nil {
return game.State{}, err
}
return s.GameState()
return ec.executeCommand(func(c *Controller) error { return consumer(c) })
}
// GenerateTurn advances the game by one turn (applying every stored order) and
// returns the resulting game state.
func (s *Service) GenerateTurn() (game.State, error) {
if err := s.execute(func(_ uint, c *Controller) error { return c.MakeTurn() }); err != nil {
return game.State{}, err
}
return s.GameState()
}
// isGameNotInitialized reports whether err is the engine's canonical
// "no state.json on disk" signal returned by Repo.LoadState on a fresh
// storage directory.
func isGameNotInitialized(err error) bool {
var ge *e.GenericError
return errors.As(err, &ge) && ge.Code == e.ErrGameNotInitialized
}
// LoadReport returns the stored turn report for actor at the given turn.
func (s *Service) LoadReport(actor string, turn uint) (r *report.Report, err error) {
execErr := s.execute(func(_ uint, c *Controller) (exErr error) {
id, exErr := c.RaceID(actor)
if exErr == nil {
r, exErr = s.repo.LoadReport(turn, id)
}
return
})
err = errors.Join(err, execErr)
return
}
// ValidateOrder validates cmd against a transient view of the current state,
// records the per-command outcome on each command's meta, and stores the
// resulting order for the current turn. Game-state rejections are reported per
// command, not as a returned error.
func (s *Service) ValidateOrder(actor string, cmd ...order.DecodableCommand) (o *order.UserGamesOrder, err error) {
err = s.execute(func(t uint, c *Controller) error {
id, err := c.RaceID(actor)
if err != nil {
return err
}
if err := c.ValidateOrder(actor, cmd...); err != nil {
return err
}
o = &order.UserGamesOrder{
GameID: c.Cache.g.ID,
UpdatedAt: time.Now().UTC().UnixMilli(),
Commands: make([]order.DecodableCommand, len(cmd)),
}
copy(o.Commands, cmd)
return s.repo.SaveOrder(t, id, o)
})
func ValidateOrder(configure func(*Param), actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) {
ec, err := NewRepoController(configure)
if err != nil {
return nil, err
}
return
return ec.validateOrder(actor, cmd...)
}
// FetchOrder returns the order actor stored for the given turn. ok is false
// when no order was ever stored.
func (s *Service) FetchOrder(actor string, turn uint) (o *order.UserGamesOrder, ok bool, err error) {
err = s.execute(func(_ uint, c *Controller) error {
id, err := c.RaceID(actor)
if err != nil {
return err
}
o, ok, err = s.repo.LoadOrder(turn, id)
return err
})
func FetchOrder(configure func(*Param), actor string, turn uint) (order *order.UserGamesOrder, ok bool, err error) {
ec, err := NewRepoController(configure)
if err != nil {
return
return nil, false, err
}
return
return ec.fetchOrder(actor, turn)
}
// FetchBattle returns the battle report stored at turn under ID. exists is
// false when no such battle was recorded.
func (s *Service) FetchBattle(turn uint, ID uuid.UUID) (b *report.BattleReport, exists bool, err error) {
err = s.execute(func(_ uint, c *Controller) error {
b, exists, err = s.repo.LoadBattle(turn, ID)
func FetchBattle(configure func(*Param), turn uint, ID uuid.UUID) (b *report.BattleReport, exists bool, err error) {
ec, err := NewRepoController(configure)
if err != nil {
return nil, false, err
}
return ec.fetchBattle(turn, ID)
}
func BanishRace(configure func(*Param), actor string) error {
ec, err := NewRepoController(configure)
if err != nil {
return err
})
return
}
return ec.banishRace(actor)
}
// BanishRace deactivates actor's race after a permanent platform removal and
// persists the updated state.
func (s *Service) BanishRace(actor string) error {
return s.execute(func(_ uint, c *Controller) error {
if err := c.RaceBanish(actor); err != nil {
return err
}
return c.saveState()
})
}
func GameState(configure func(*Param)) (s game.State, err error) {
ec, err := NewRepoController(configure)
if err != nil {
return game.State{}, err
}
// GameState loads the current state and projects it into the transport-facing
// game.State summary (player roster with planet counts and population).
func (s *Service) GameState() (game.State, error) {
g, err := s.repo.LoadState()
g, err := ec.Repo.LoadStateSafe()
if err != nil {
return game.State{}, err
}
@@ -194,26 +207,149 @@ func (s *Service) GameState() (game.State, error) {
return *result, nil
}
// execute loads the current game state, wraps it in a Controller and runs
// consumer against it. Reads and writes are lock-free; concurrent writers to
// the state file (init/turn/banish) are serialised at the router by a shared
// LimitMiddleware, so this helper holds no lock of its own.
func (s *Service) execute(consumer func(uint, *Controller) error) error {
g, err := s.repo.LoadState()
type RepoController struct {
Repo Repo
}
func NewRepoController(config Configurer) (*RepoController, error) {
c := &Param{
StoragePath: ".",
}
if config != nil {
config(c)
}
r, err := repo.NewFileRepo(c.StoragePath)
if err != nil {
return nil, err
}
return &RepoController{
Repo: r,
}, nil
}
func (ec *RepoController) NewGameController(g *game.Game) *Controller {
return &Controller{
RepoController: ec,
Cache: NewCache(g),
}
}
func (ec *RepoController) validateOrder(actor string, cmd ...order.DecodableCommand) (o *order.UserGamesOrder, err error) {
err = ec.executeSafe(func(t uint, c *Controller) error {
id, err := c.RaceID(actor)
if err != nil {
return err
}
err = c.ValidateOrder(actor, cmd...)
if err != nil {
return err
}
o = &order.UserGamesOrder{
GameID: c.Cache.g.ID,
UpdatedAt: time.Now().UTC().UnixMilli(),
Commands: make([]order.DecodableCommand, len(cmd)),
}
copy(o.Commands, cmd)
return ec.Repo.SaveOrder(t, id, o)
})
if err != nil {
return nil, err
}
return
}
func (ec *RepoController) fetchOrder(actor string, turn uint) (order *order.UserGamesOrder, ok bool, err error) {
err = ec.executeSafe(func(t uint, c *Controller) error {
id, err := c.RaceID(actor)
if err != nil {
return err
}
order, ok, err = ec.Repo.LoadOrder(turn, id)
return err
})
if err != nil {
return
}
return
}
func (ec *RepoController) fetchBattle(turn uint, ID uuid.UUID) (order *report.BattleReport, exists bool, err error) {
err = ec.executeSafe(func(t uint, c *Controller) error {
order, exists, err = ec.Repo.LoadBattle(turn, ID)
return err
})
return
}
func (ec *RepoController) loadReport(actor string, turn uint) (r *report.Report, err error) {
execErr := ec.executeSafe(func(t uint, c *Controller) (exErr error) {
id, exErr := c.RaceID(actor)
if exErr == nil {
r, exErr = ec.Repo.LoadReport(turn, id)
}
return
})
err = errors.Join(err, execErr)
return
}
func (ec *RepoController) executeCommand(consumer func(*Controller) error) (err error) {
return ec.executeLocked(func(c *Controller) error {
err = consumer(c)
if err == nil {
c.Cache.StageCommand()
err = c.saveState()
}
return err
})
}
func (ec *RepoController) banishRace(actor string) (err error) {
return ec.executeLocked(func(c *Controller) error {
err = c.RaceBanish(actor)
if err != nil {
return err
}
return c.saveState()
})
}
func (ec *RepoController) executeSafe(consumer func(uint, *Controller) error) (err error) {
g, err := ec.Repo.LoadStateSafe()
if err != nil {
return err
}
return consumer(g.Turn, &Controller{repo: s.repo, Cache: NewCache(g)})
err = consumer(g.Turn, ec.NewGameController(g))
return
}
// Controller is the per-turn execution context: a loaded game state (Cache)
// plus the repo it persists through. It carries the engine's game-logic
// methods (in command.go, order.go, generate_turn.go, …).
type Controller struct {
repo *repo.Repo
Cache *Cache
func (ec *RepoController) executeLocked(consumer func(*Controller) error) (err error) {
if err := ec.Repo.Lock(); err != nil {
return err
}
defer func() {
err = errors.Join(err, ec.Repo.Release())
}()
g, err := ec.Repo.LoadState()
if err != nil {
return err
}
err = consumer(ec.NewGameController(g))
return
}
func (c *Controller) saveState() error {
return c.repo.SaveLastState(c.Cache.g)
return c.Repo.SaveLastState(c.Cache.g)
}
type Controller struct {
*RepoController
Cache *Cache
}
type Param struct {
StoragePath string
}
@@ -149,7 +149,3 @@ func (c *Cache) WipeRace(ri int) {
func (c *Cache) UnsafeDeleteShipGroup(sgi int) {
c.unsafeDeleteShipGroup(sgi)
}
func DestructionRoll(probability float64) bool {
return destructionRoll(probability)
}
+2 -1
View File
@@ -131,7 +131,8 @@ func newGame() *game.Game {
func newCache() (*controller.Cache, *controller.Controller) {
ctl := &controller.Controller{
Cache: controller.NewCache(newGame()),
RepoController: nil,
Cache: controller.NewCache(newGame()),
}
assertNoError(ctl.Cache.ShipClassCreate(Race_0_idx, Race_0_Gunship, 60, 3, 30, 100, 0))
assertNoError(ctl.Cache.ShipClassCreate(Race_0_idx, Race_0_Freighter, 8, 0, 0, 2, 10))
+1 -1
View File
@@ -57,7 +57,7 @@ func TestFleetSend(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.FleetSend(Race_Extinct.Name, fleetSending, 2),
e.GenericErrorText(e.ErrRaceExtinct))
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.FleetSend(Race_0.Name, "UnknownFleet", 2),
e.GenericErrorText(e.ErrInputEntityNotExists))
+3 -3
View File
@@ -37,7 +37,7 @@ func TestShipGroupJoinFleet(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupJoinFleet(Race_Extinct.Name, fleetOne, groupIndex),
e.GenericErrorText(e.ErrRaceExtinct))
e.GenericErrorText(e.ErrRaceExinct))
// ensure race has no Fleets
assert.Len(t, slices.Collect(c.ListFleets(Race_0_idx)), 0)
@@ -124,14 +124,14 @@ func TestFleetMerge(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.FleetMerge(Race_Extinct.Name, fleetSourceOne, fleetTargetTwo),
e.GenericErrorText(e.ErrRaceExtinct))
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ShipGroupJoinFleet(UnknownRace, fleetSourceOne, c.ShipGroup(0).ID),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupJoinFleet(Race_Extinct.Name, fleetSourceOne, c.ShipGroup(0).ID),
e.GenericErrorText(e.ErrRaceExtinct))
e.GenericErrorText(e.ErrRaceExinct))
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, fleetSourceOne, c.ShipGroup(0).ID))
+9 -9
View File
@@ -7,26 +7,22 @@ import (
"galaxy/game/internal/generator"
"galaxy/game/internal/model/game"
"galaxy/game/internal/repo"
"github.com/google/uuid"
)
// NewGame initialises a fresh game in storage under the supplied
// gameID. The caller is expected to have validated gameID against
// uuid.Nil and to have ruled out collisions with existing state.
func NewGame(r *repo.Repo, gameID uuid.UUID, races []string) (uuid.UUID, error) {
func NewGame(r Repo, races []string) (uuid.UUID, error) {
m, err := generator.Generate(func(ms *generator.MapSetting) {
ms.Players = uint32(len(races))
})
if err != nil {
return uuid.Nil, fmt.Errorf("generate map: %s", err)
}
return newGameOnMap(r, gameID, races, m)
return newGameOnMap(r, races, m)
}
func newGameOnMap(r *repo.Repo, gameID uuid.UUID, races []string, m generator.Map) (uuid.UUID, error) {
g, err := buildGameOnMap(gameID, races, m)
func newGameOnMap(r Repo, races []string, m generator.Map) (uuid.UUID, error) {
g, err := buildGameOnMap(races, m)
if err != nil {
return uuid.Nil, err
}
@@ -42,10 +38,14 @@ func newGameOnMap(r *repo.Repo, gameID uuid.UUID, races []string, m generator.Ma
return g.ID, nil
}
func buildGameOnMap(gameID uuid.UUID, races []string, m generator.Map) (*game.Game, error) {
func buildGameOnMap(races []string, m generator.Map) (*game.Game, error) {
if len(races) != len(m.HomePlanets) {
return nil, fmt.Errorf("generate map: wrong number of home planets: %d, expected: %d ", len(m.HomePlanets), len(races))
}
gameID, err := uuid.NewRandom()
if err != nil {
return nil, fmt.Errorf("generate game uuid: %s", err)
}
g := &game.Game{
ID: gameID,
Turn: 0,
+5 -38
View File
@@ -28,17 +28,16 @@ func TestNewGame(t *testing.T) {
for i := range players {
races[i] = fmt.Sprintf("race_%02d", i)
}
requestedID := uuid.New()
gameID, err := controller.NewGame(r, requestedID, races)
assert.NoError(t, r.Lock())
gameID, err := controller.NewGame(r, races)
assert.NoError(t, err)
assert.Equal(t, requestedID, gameID, "NewGame must echo the supplied gameID")
assert.FileExists(t, filepath.Join(root, "state.json"))
assert.FileExists(t, filepath.Join(root, "0000/state.json"))
g, err := r.LoadState()
assert.NoError(t, err)
assert.Equal(t, requestedID, g.ID, "persisted game.ID must match the supplied gameID")
assert.Equal(t, gameID, g.ID)
assert.Equal(t, uint(0), g.Turn)
assert.Equal(t, players, len(g.Race))
@@ -66,38 +65,6 @@ func TestNewGame(t *testing.T) {
numShuffled = numShuffled || p.Number != uint(i)
}
assert.True(t, numShuffled)
}
func TestGenerateGameRejectsExistingState(t *testing.T) {
root, cleanup := util.CreateWorkDir(t)
defer cleanup()
races := make([]string, 10)
for i := range races {
races[i] = fmt.Sprintf("race_%02d", i)
}
svc, err := controller.NewService(root)
assert.NoError(t, err)
firstID := uuid.New()
_, err = svc.GenerateGame(firstID, races)
assert.NoError(t, err)
_, err = svc.GenerateGame(uuid.New(), races)
assert.ErrorIs(t, err, controller.ErrGameAlreadyInit)
}
func TestGenerateGameRejectsNilUUID(t *testing.T) {
root, cleanup := util.CreateWorkDir(t)
defer cleanup()
races := make([]string, 10)
for i := range races {
races[i] = fmt.Sprintf("race_%02d", i)
}
svc, err := controller.NewService(root)
assert.NoError(t, err)
_, err = svc.GenerateGame(uuid.Nil, races)
assert.ErrorIs(t, err, controller.ErrGameInitNilUUID)
assert.NoError(t, r.Release())
}
+11 -15
View File
@@ -20,29 +20,25 @@ func (c *Controller) MakeTurn() error {
c.Cache.g.Turn += 1
c.Cache.g.Stage = 0
// 01. Вышедшие расы удаляются из списка участвующих рас перед началом просчёта очередного хода.
// 01. Вышедшие расы удаляются из списка участвующих рас перед началом просчета очередного хода
c.Cache.TurnWipeExtinctRaces()
// 02. Корабли, где это возможно, объединяются в группы (до боя и до отправки по маршрутам).
// 02. Товары загружаются на корабли, находящиеся в начале грузовых маршрутов, и корабли входят в гиперпространство (но ещё не полетели)
c.Cache.SendRoutedGroups()
// 03. Корабли, где это возможно, объединяются в группы.
c.Cache.TurnMergeEqualShipGroups()
// 03. Враждующие корабли вступают в схватку у планеты отправления. Корабли, которым отдан
// приказ на отлёт (статус Launched), ещё стоят на планете и участвуют в бою; в
// гиперпространство уходят только уцелевшие — так нельзя уклониться от боя.
// 04. Враждующие корабли вступают в схватку.
battles := ProduceBattles(c.Cache)
// 04. Товары загружаются на корабли в начале грузовых маршрутов, и эти корабли входят в
// гиперпространство. Загрузка после боя: маршрутные транспорты сражаются пустыми и не
// могут уклониться от боя, скрывшись в гиперпространстве.
c.Cache.SendRoutedGroups()
// 05. Корабли пролетают сквозь гиперпространство.
c.Cache.MoveShipGroups()
// 06. Корабли, где это возможно, объединяются в группы.
c.Cache.TurnMergeEqualShipGroups()
// 07. Враждующие корабли снова вступают в схватку (после выхода из гиперпространства).
// 07. Враждующие корабли снова вступают в схватку (это происходит после выхода из гиперпространства).
battles = append(battles, ProduceBattles(c.Cache)...)
// 08. Корабли бомбят вражеские планеты.
@@ -71,7 +67,7 @@ func (c *Controller) MakeTurn() error {
// Store bombings
bombingReport := make([]*report.Bombing, len(bombings))
if len(bombings) > 0 {
if err := c.repo.SaveBombings(c.Cache.g.Turn, bombings); err != nil {
if err := c.Repo.SaveBombings(c.Cache.g.Turn, bombings); err != nil {
return err
}
for i := range bombings {
@@ -111,7 +107,7 @@ func (c *Controller) MakeTurn() error {
}
report := TransformBattle(c.Cache, b)
if err := c.repo.SaveBattle(c.Cache.g.Turn, report, &battleMeta[i]); err != nil {
if err := c.Repo.SaveBattle(c.Cache.g.Turn, report, &battleMeta[i]); err != nil {
return err
}
battleReport[i] = report
@@ -122,12 +118,12 @@ func (c *Controller) MakeTurn() error {
c.Cache.DeleteKilledShipGroups()
// Store game state for the new turn and 'current' state as well
if err := c.repo.SaveNewTurn(c.Cache.g.Turn, c.Cache.g); err != nil {
if err := c.Repo.SaveNewTurn(c.Cache.g.Turn, c.Cache.g); err != nil {
return err
}
for rep := range c.Cache.Report(c.Cache.g.Turn, battleReport, bombingReport) {
if err := c.repo.SaveReport(c.Cache.g.Turn, rep); err != nil {
if err := c.Repo.SaveReport(c.Cache.g.Turn, rep); err != nil {
return err
}
}
-14
View File
@@ -1,14 +0,0 @@
package controller
import "errors"
// ErrGameInitNilUUID is returned by GenerateGame when the supplied
// game UUID is the zero value. The HTTP layer maps it to 400.
var ErrGameInitNilUUID = errors.New("game init: gameId must not be the zero UUID")
// ErrGameAlreadyInit is returned by GenerateGame when the engine
// storage directory already contains a state.json. The HTTP layer
// maps it to 409. Repeated init on the same gameId is intentionally
// rejected rather than treated as a no-op; full idempotency is not
// part of the contract.
var ErrGameAlreadyInit = errors.New("game init: game already initialized")
+12 -19
View File
@@ -13,24 +13,17 @@ import (
"github.com/google/uuid"
)
// ValidateOrder applies every command in the order against a transient
// view of the engine state, records the per-command outcome in each
// command's CommandMeta via applyCommand, and reports only order-level
// structural errors as the function return. Per-command rejections are
// surfaced through CommandMeta.Result so the caller can persist and
// forward them as `cmdApplied`/`cmdErrorCode` in the response body.
func (c *Controller) ValidateOrder(actor string, commands ...order.DecodableCommand) error {
func (c *Controller) ValidateOrder(actor string, commands ...order.DecodableCommand) (err error) {
for i := range commands {
if _, ok := commands[i].(*order.CommandRaceQuit); ok && i != len(commands)-1 {
return e.NewQuitCommandFollowedByCommandError()
if _, ok := commands[i].(order.CommandRaceQuit); ok && i != len(commands)-1 {
err = e.NewQuitCommandFollowedByCommandError()
}
// applyCommand never returns a non-GenericError outside of
// programmer-error panics; the per-command code, if any, is
// already recorded on the command's meta and must not abort
// validation of the remaining commands in this order.
_ = c.applyCommand(actor, commands[i])
if err != nil {
return err
}
err = errors.Join(err, c.applyCommand(actor, commands[i]))
}
return nil
return
}
func (c *Controller) applyCommand(actor string, cmd order.DecodableCommand) (err error) {
@@ -109,11 +102,11 @@ func (c *Controller) applyCommand(actor string, cmd order.DecodableCommand) (err
}
if ge, ok := errors.AsType[*e.GenericError](err); ok {
m.Result(ge.Code, ge.Error())
m.Result(ge.Code)
} else if err != nil {
panic(fmt.Errorf("error applying command has unknown origin: %w", err))
} else {
m.Result(0, "")
m.Result(0)
}
return
@@ -127,7 +120,7 @@ func (c *Controller) applyOrders(t uint) error {
cmdApplied := make(map[string]bool)
for ri := range c.Cache.listRaceActingIdx() {
o, ok, err := c.repo.LoadOrder(t, c.Cache.g.Race[ri].ID)
o, ok, err := c.Repo.LoadOrder(t, c.Cache.g.Race[ri].ID)
if err != nil {
return err
}
@@ -166,7 +159,7 @@ func (c *Controller) applyOrders(t uint) error {
_ = c.applyCommand(commandRace[cmd.CommandID()], cmd)
}
// re-save order to persist possible changed commands result outcome
if err := c.repo.SaveOrder(t, c.Cache.g.Race[ri].ID, &order.UserGamesOrder{
if err := c.Repo.SaveOrder(t, c.Cache.g.Race[ri].ID, &order.UserGamesOrder{
GameID: c.Cache.g.ID,
UpdatedAt: raceOrderUpdated[ri],
Commands: raceOrder[ri],
-145
View File
@@ -1,145 +0,0 @@
package controller_test
import (
"errors"
"testing"
e "galaxy/error"
"galaxy/model/order"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestValidateOrderRejectsCommandReferencingMissingShipClass mirrors
// the scenario reported in issue #59: an order whose only command
// builds a ship of a class that does not exist must not turn into a
// generic engine failure. The engine records the rejection in the
// command's meta and reports no order-level error so the caller can
// persist the partial result and forward it as a per-command status.
func TestValidateOrderRejectsCommandReferencingMissingShipClass(t *testing.T) {
_, ctl := newCache()
cmd := &order.CommandPlanetProduce{
CommandMeta: order.CommandMeta{
CmdID: uuid.NewString(),
CmdType: order.CommandTypePlanetProduce,
},
Number: int(R0_Planet_0_num),
Production: "SHIP",
Subject: "Nonexistent",
}
err := ctl.ValidateOrder(Race_0.Name, cmd)
assert.NoError(t, err, "per-command rejection must not become an order-level error")
require.NotNil(t, cmd.CmdApplied, "cmdApplied must be set on every processed command")
assert.False(t, *cmd.CmdApplied)
require.NotNil(t, cmd.CmdErrCode, "cmdErrorCode must be set when the command is rejected")
assert.Equal(t, e.ErrInputEntityNotExists, *cmd.CmdErrCode)
}
// TestValidateOrderContinuesAfterRejection — when one command in an
// order is rejected, every remaining command is still validated and
// receives its own per-command status. Without this property, the
// engine would silently drop the tail of an order on the first
// failure, which is exactly what produced the issue #59 symptom.
func TestValidateOrderContinuesAfterRejection(t *testing.T) {
_, ctl := newCache()
rejected := &order.CommandPlanetProduce{
CommandMeta: order.CommandMeta{
CmdID: uuid.NewString(),
CmdType: order.CommandTypePlanetProduce,
},
Number: int(R0_Planet_0_num),
Production: "SHIP",
Subject: "Nonexistent",
}
succeeding := &order.CommandPlanetRename{
CommandMeta: order.CommandMeta{
CmdID: uuid.NewString(),
CmdType: order.CommandTypePlanetRename,
},
Number: int(R0_Planet_0_num),
Name: "Homeworld",
}
err := ctl.ValidateOrder(Race_0.Name, rejected, succeeding)
assert.NoError(t, err)
require.NotNil(t, rejected.CmdApplied)
assert.False(t, *rejected.CmdApplied)
require.NotNil(t, rejected.CmdErrCode)
assert.Equal(t, e.ErrInputEntityNotExists, *rejected.CmdErrCode)
require.NotNil(t, succeeding.CmdApplied)
assert.True(t, *succeeding.CmdApplied)
require.NotNil(t, succeeding.CmdErrCode)
assert.Equal(t, 0, *succeeding.CmdErrCode)
}
// TestValidateOrderSimulatesPriorCommands — a later command may
// depend on the in-memory state mutation performed by an earlier
// command in the same order. Creating a ship class and producing a
// ship of that class in the same batch should both succeed because
// validation runs the commands against the transient state in
// submission order.
func TestValidateOrderSimulatesPriorCommands(t *testing.T) {
_, ctl := newCache()
create := &order.CommandShipClassCreate{
CommandMeta: order.CommandMeta{
CmdID: uuid.NewString(),
CmdType: order.CommandTypeShipClassCreate,
},
Name: "Drone",
Drive: 1,
}
produce := &order.CommandPlanetProduce{
CommandMeta: order.CommandMeta{
CmdID: uuid.NewString(),
CmdType: order.CommandTypePlanetProduce,
},
Number: int(R0_Planet_0_num),
Production: "SHIP",
Subject: "Drone",
}
err := ctl.ValidateOrder(Race_0.Name, create, produce)
assert.NoError(t, err)
require.NotNil(t, create.CmdApplied)
assert.True(t, *create.CmdApplied)
require.NotNil(t, produce.CmdApplied)
assert.True(t, *produce.CmdApplied)
}
// TestValidateOrderRejectsQuitFollowedByCommand — quit must be the
// last command in the order; if it is followed by another command,
// validation aborts at the order level with a structural error so
// the caller can surface HTTP 400.
func TestValidateOrderRejectsQuitFollowedByCommand(t *testing.T) {
_, ctl := newCache()
quit := &order.CommandRaceQuit{
CommandMeta: order.CommandMeta{
CmdID: uuid.NewString(),
CmdType: order.CommandTypeRaceQuit,
},
}
follow := &order.CommandRaceVote{
CommandMeta: order.CommandMeta{
CmdID: uuid.NewString(),
CmdType: order.CommandTypeRaceVote,
},
Acceptor: Race_1.Name,
}
err := ctl.ValidateOrder(Race_0.Name, quit, follow)
require.Error(t, err)
var ge *e.GenericError
require.True(t, errors.As(err, &ge), "expected GenericError")
assert.Equal(t, e.ErrInputQuitCommandFollowedByCommand, ge.Code)
}
+4 -9
View File
@@ -162,7 +162,7 @@ func (c *Cache) PlanetProductionCapacity(planetNumber uint) float64 {
p := c.MustPlanet(planetNumber)
var busyResources float64
for sg := range c.shipGroupsInUpgrade(p.Number) {
busyResources += c.upgradeCostNow(sg)
busyResources += sg.StateUpgrade.Cost()
}
return p.ProductionCapacity() - busyResources
}
@@ -181,15 +181,10 @@ func (c *Cache) TurnPlanetProductions() {
ri := c.RaceIndex(*p.Owner)
r := &c.g.Race[ri]
// Upgrade groups (most expensive first) and return them to the
// in-orbit state, paying for each upgrade once out of the planet's
// full production potential; whatever remains feeds this turn's
// production below. Starting from PlanetProductionCapacity here would
// have charged every applied upgrade twice, since that helper already
// nets out the reserved upgrade cost for the report.
productionAvailable := p.ProductionCapacity()
// upgrade groups and return to in_orbit state
productionAvailable := c.PlanetProductionCapacity(pn)
for sg := range c.shipGroupsInUpgrade(p.Number) {
cost := c.upgradeCostNow(sg)
cost := sg.StateUpgrade.Cost()
if productionAvailable >= cost {
for i := range sg.StateUpgrade.UpgradeTech {
sg.Tech = sg.Tech.Set(sg.StateUpgrade.UpgradeTech[i].Tech, util.Fixed3(sg.StateUpgrade.UpgradeTech[i].Level.F()))
+3 -36
View File
@@ -27,7 +27,7 @@ func TestPlanetRename(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.PlanetRename(Race_Extinct.Name, int(R0_Planet_0_num), "Home_World"),
e.GenericErrorText(e.ErrRaceExtinct))
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.PlanetRename(Race_0.Name, -1, "Home_World"),
e.GenericErrorText(e.ErrInputPlanetNumber))
@@ -107,7 +107,7 @@ func TestPlanetProduce(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.PlanetProduce(Race_Extinct.Name, pn, "DRIVE", ""),
e.GenericErrorText(e.ErrRaceExtinct))
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.PlanetProduce(Race_0.Name, pn, "Hyperdrive", ""),
e.GenericErrorText(e.ErrInputProductionInvalid))
@@ -208,40 +208,7 @@ func TestProduceShips(t *testing.T) {
assert.Equal(t, game.StateInOrbit, c.ShipGroup(0).State())
assert.Equal(t, 1.5, c.ShipGroup(0).TechLevel(game.TechDrive).F())
// The pending upgrade is now charged once (not twice) against the planet's
// production potential, so MAT production keeps the budget it previously
// lost to the double charge (the pre-fix value here was ~4346.68).
assert.InDelta(t, 7173.3432, c.MustPlanet(R0_Planet_0_num).Material.F(), 0.001)
}
// TestUpgradeDoesNotDoubleChargeProduction guards that a pending upgrade is
// paid for once out of the planet's production potential, leaving the rest for
// the turn's production. The pre-fix code subtracted the upgrade cost twice
// (PlanetProductionCapacity already nets it out for the report, and the apply
// loop netted it again), which both starved production and could skip
// affordable upgrades.
func TestUpgradeDoesNotDoubleChargeProduction(t *testing.T) {
c, _ := newCache()
p := c.MustPlanet(R0_Planet_0_num)
p.Population = 1000
p.Industry = 1000 // ProductionCapacity = 1000*0.75 + 1000*0.25 = 1000
p.Resources = 1 // material produced == leftover production budget
p.Colonists = 0
p.Material = 0
assert.NoError(t, c.PlanetProduce(Race_0_idx, int(R0_Planet_0_num), game.ProductionMaterial, ""))
// One Cruiser with a pending drive upgrade 1.1 -> 2.0:
// block cost = (1 - 1.1/2.0) * 10 * 15 = 67.5 for the single ship.
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1))
c.ShipGroup(0).StateUpgrade = &game.InUpgrade{
UpgradeTech: []game.UpgradePreference{{Tech: game.TechDrive, Level: 2.0}},
}
c.TurnPlanetProductions()
assert.InDelta(t, 2.0, c.ShipGroup(0).TechLevel(game.TechDrive).F(), 0.0001)
// 1000 - 67.5 = 932.5; the pre-fix double charge would have left 865.
assert.InDelta(t, 932.5, c.MustPlanet(R0_Planet_0_num).Material.F(), 0.01)
assert.Equal(t, 4346.676567656759, c.MustPlanet(R0_Planet_0_num).Material.F())
}
func TestProduceShip(t *testing.T) {
+3 -24
View File
@@ -98,7 +98,7 @@ func (c *Cache) validRace(name string) (int, error) {
return -1, err
}
if c.g.Race[i].Extinct {
return -1, e.NewRaceExtinctError(name)
return -1, e.NewRaceExinctError(name)
}
return i, nil
}
@@ -117,34 +117,13 @@ func (c *Cache) raceTechLevel(ri int, t game.Tech, v float64) {
}
func (c *Cache) TurnWipeExtinctRaces() {
for i := range c.listRaceIdx() {
r := &c.g.Race[i]
// Idle timeout or voluntary quit: a still-active race whose TTL ran
// out. Administrative banish: a race already flagged extinct that
// still holds assets to release. Once a race is wiped it owns nothing,
// so the asset check keeps this idempotent across later turns.
if (!r.Extinct && r.TTL == 0) || (r.Extinct && c.raceHasAssets(i)) {
for i := range c.listRaceActingIdx() {
if (c.g.Race[i].Extinct && c.g.Race[i].TTL > 0) || (!c.g.Race[i].Extinct && c.g.Race[i].TTL == 0) {
c.wipeRace(i)
}
}
}
// raceHasAssets reports whether the race still owns a planet or a ship group.
func (c *Cache) raceHasAssets(ri int) bool {
id := c.g.Race[ri].ID
for i := range c.g.Map.Planet {
if c.g.Map.Planet[i].OwnedBy(id) {
return true
}
}
for i := range c.g.ShipGroups {
if c.g.ShipGroups[i].OwnerID == id {
return true
}
}
return false
}
func (c *Cache) wipeRace(ri int) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
+6 -43
View File
@@ -1,7 +1,6 @@
package controller_test
import (
"slices"
"testing"
e "galaxy/error"
@@ -29,10 +28,10 @@ func TestRaceVote(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.RaceVote(Race_0.Name, Race_Extinct.Name),
e.GenericErrorText(e.ErrRaceExtinct))
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.RaceVote(Race_Extinct.Name, Race_1.Name),
e.GenericErrorText(e.ErrRaceExtinct))
e.GenericErrorText(e.ErrRaceExinct))
}
func TestRaceRelation(t *testing.T) {
@@ -55,10 +54,10 @@ func TestRaceRelation(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.RaceRelation(Race_0.Name, Race_Extinct.Name, "War"),
e.GenericErrorText(e.ErrRaceExtinct))
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.RaceRelation(Race_Extinct.Name, Race_0.Name, "War"),
e.GenericErrorText(e.ErrRaceExtinct))
e.GenericErrorText(e.ErrRaceExinct))
}
func TestRaceQuit(t *testing.T) {
@@ -70,7 +69,7 @@ func TestRaceQuit(t *testing.T) {
assert.ErrorContains(t,
g.RaceQuit(Race_Extinct.Name),
e.GenericErrorText(e.ErrRaceExtinct))
e.GenericErrorText(e.ErrRaceExinct))
assert.NoError(t, g.RaceQuit(Race_0.Name))
assert.Equal(t, 3, int(c.Race(Race_0_idx).TTL))
@@ -85,45 +84,9 @@ func TestRaceID(t *testing.T) {
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputUnknownRace))
_, err = g.RaceID(Race_Extinct.Name)
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrRaceExinct))
id, err := g.RaceID(Race_0.Name)
assert.NoError(t, err)
assert.Equal(t, Race_0_ID, id)
}
// TestBanishReleasesAssets checks that an administratively banished race only
// gets flagged extinct, and its planets and ships are released during turn
// generation; a second pass is a no-op.
func TestBanishReleasesAssets(t *testing.T) {
c, g := newCache()
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R1_Planet_1_num, 3)
assert.True(t, c.MustPlanet(R1_Planet_1_num).OwnedBy(Race_1_ID))
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_1_idx)), 1)
assert.NoError(t, g.RaceBanish(Race_1.Name))
assert.True(t, c.Race(Race_1_idx).Extinct)
assert.True(t, c.MustPlanet(R1_Planet_1_num).OwnedBy(Race_1_ID), "still owned until the turn runs")
c.TurnWipeExtinctRaces()
assert.False(t, c.MustPlanet(R1_Planet_1_num).Owned())
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_1_idx)), 0)
// Idempotent: re-running over an already-wiped (asset-less) race is a no-op.
c.TurnWipeExtinctRaces()
assert.False(t, c.MustPlanet(R1_Planet_1_num).Owned())
}
// TestIdleRaceWipedOnTimeout guards that a still-active race whose TTL ran out
// (idle timeout or quit) is still wiped after the iterator change.
func TestIdleRaceWipedOnTimeout(t *testing.T) {
c, _ := newCache()
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R1_Planet_1_num, 1)
c.Race(Race_1_idx).TTL = 0
assert.False(t, c.Race(Race_1_idx).Extinct)
c.TurnWipeExtinctRaces()
assert.True(t, c.Race(Race_1_idx).Extinct)
assert.False(t, c.MustPlanet(R1_Planet_1_num).Owned())
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_1_idx)), 0)
}
+39 -121
View File
@@ -129,16 +129,13 @@ func (c *Cache) ReportRace(ri int, rep *mr.Report, battles []*mr.BattleReport, b
rep.Player[i].Relation = "-"
}
// race exit warnings
c.ReportExitWarnings(ri, rep)
// sciences
c.ReportLocalScience(ri, rep)
c.ReportOtherScience(ri, rep)
// ship classes
c.ReportLocalShipClass(ri, rep)
c.ReportOtherShipClass(ri, rep, battles)
c.ReportOtherShipClass(ri, rep)
// battles
c.ReportBattle(ri, rep, battles)
@@ -180,36 +177,6 @@ func (c *Cache) ReportRace(ri int, rep *mr.Report, battles []*mr.BattleReport, b
c.ReportUnidentifiedGroup(ri, rep)
}
// ReportExitWarnings fills the inactivity-removal warnings. A race's TTL at
// report time equals the number of turns remaining before it is auto-removed
// (it is wiped at the start of turn T+TTL). The recipient gets a personal
// countdown once it is 5 turns out (rep.PersonalExitWarning); every other
// non-extinct race within 3 turns of removal is listed publicly
// (rep.RacesLeavingSoon). Voluntary quit and idle timeout share the TTL
// countdown and are intentionally not distinguished here.
func (c *Cache) ReportExitWarnings(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
rep.PersonalExitWarning = 0
if ttl := c.g.Race[ri].TTL; ttl > 0 && ttl <= 5 {
rep.PersonalExitWarning = ttl
}
rep.RacesLeavingSoon = rep.RacesLeavingSoon[:0]
for i := range c.g.Race {
r := &c.g.Race[i]
if i == ri || r.Extinct {
continue
}
if r.TTL > 0 && r.TTL <= 3 {
rep.RacesLeavingSoon = append(rep.RacesLeavingSoon, mr.RaceExitNotice{
Race: r.Name,
TurnsLeft: r.TTL,
})
}
}
}
func (c *Cache) ReportLocalScience(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
@@ -282,7 +249,7 @@ func (c *Cache) ReportLocalShipClass(ri int, report *mr.Report) {
slices.SortFunc(report.LocalShipClass, func(a, b mr.ShipClass) int { return cmp.Compare(a.Name, b.Name) })
}
func (c *Cache) ReportOtherShipClass(ri int, rep *mr.Report, battles []*mr.BattleReport) {
func (c *Cache) ReportOtherShipClass(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
@@ -306,46 +273,26 @@ func (c *Cache) ReportOtherShipClass(ri int, rep *mr.Report, battles []*mr.Battl
return false
}
// Ship classes seen in battles the recipient took part in or witnessed.
// The battle report carries the class name and owner race; the class
// design is looked up from that race's ship types, which stay present in
// the state even though the groups themselves are deleted before reports
// are generated.
for bi := range battles {
br := battles[bi]
visible := false
for k := range br.Races {
if br.Races[k] == r.ID {
visible = true
break
}
}
if !visible {
continue
}
for si := range br.Ships {
bg := br.Ships[si]
ownerIdx, err := c.raceIndex(bg.Race)
if err != nil {
continue
}
ownerID := c.g.Race[ownerIdx].ID
st, _, ok := c.ShipClass(ownerIdx, bg.ClassName)
if !ok || skip(ownerID, st.Name) {
continue
}
sliceIndexValidate(&rep.OtherShipClass, i)
rep.OtherShipClass[i].Race = bg.Race
rep.OtherShipClass[i].Name = st.Name
rep.OtherShipClass[i].Drive = mr.F(st.Drive.F())
rep.OtherShipClass[i].Armament = st.Armament
rep.OtherShipClass[i].Weapons = mr.F(st.Weapons.F())
rep.OtherShipClass[i].Shields = mr.F(st.Shields.F())
rep.OtherShipClass[i].Cargo = mr.F(st.Cargo.F())
rep.OtherShipClass[i].Mass = mr.F(st.EmptyMass())
i++
}
}
// add visible ship classes from battles
// for bi := range battle {
// for si := range battle[bi].Ships {
// g := battle[bi].Ships[si]
// if skip(g.OwnerID, g.ClassName) {
// continue
// }
// sliceIndexValidate(&rep.OtherShipClass, i)
// rep.OtherShipClass[i].Race = c.g.Race[c.RaceIndex(g.OwnerID)].Name
// rep.OtherShipClass[i].Name = g.ClassName
// rep.OtherShipClass[i].Drive = g.DriveTech
// rep.OtherShipClass[i].Armament = g.ClassArmament
// rep.OtherShipClass[i].Weapons = g.WeaponsTech
// rep.OtherShipClass[i].Shields = g.ShieldsTech
// rep.OtherShipClass[i].Cargo = g.CargoTech
// rep.OtherShipClass[i].Mass = g.ClassMass
// i++
// }
// }
// add visible ships from owned and observed planets
for pn := range rep.OnPlanetGroupCache {
@@ -445,30 +392,13 @@ func (c *Cache) ReportIncomingGroup(ri int, rep *mr.Report) {
if sg.OwnerID == r.ID || sg.State() != game.StateInSpace {
continue
}
p1 := c.MustPlanet(sg.StateInSpace.Origin)
p2 := c.MustPlanet(sg.Destination)
if !p2.OwnedBy(r.ID) {
continue
}
// Beyond the visibility range (driveTech*30) of every owned planet the
// group is not shown at all, even though it heads to one of them.
visible := false
for pi := range c.g.Map.Planet {
op := &c.g.Map.Planet[pi]
if !op.OwnedBy(r.ID) {
continue
}
if d, ok := rep.InSpaceGroupRangeCache[sgi][op.Number]; ok && d <= r.VisibilityDistance() {
visible = true
break
}
}
if !visible {
continue
}
// Remaining distance is measured from the group's current position in
// hyperspace to the destination, not from its origin planet.
distance := calc.ShortDistance(c.g.Map.Width, c.g.Map.Height, sg.StateInSpace.X.F(), sg.StateInSpace.Y.F(), p2.X.F(), p2.Y.F())
distance := calc.ShortDistance(c.g.Map.Width, c.g.Map.Height, p1.X.F(), p1.Y.F(), p2.X.F(), p2.Y.F())
var speed, mass float64
if sg.FleetID != nil {
speed, mass = c.FleetSpeedAndMass(c.MustFleetIndex(*sg.FleetID))
@@ -611,12 +541,12 @@ func (c *Cache) ReportShipProduction(ri int, rep *mr.Report) {
st := c.MustShipType(ri, *p.Production.SubjectID)
sliceIndexValidate(&rep.ShipProduction, i)
rep.ShipProduction[i].Planet = p.Number
rep.ShipProduction[i].Class = st.Name
rep.ShipProduction[i].Cost = mr.F(calc.ShipProductionCost(st.EmptyMass()))
rep.ShipProduction[i].Free = mr.F(c.PlanetProductionCapacity(p.Number))
rep.ShipProduction[i].ProdUsed = mr.F((*p.Production.ProdUsed).F())
rep.ShipProduction[i].Percent = mr.F((*p.Production.Progress).F())
rep.ShipProduction[pi].Planet = p.Number
rep.ShipProduction[pi].Class = st.Name
rep.ShipProduction[pi].Cost = mr.F(calc.ShipProductionCost(st.EmptyMass()))
rep.ShipProduction[pi].Free = mr.F(c.PlanetProductionCapacity(p.Number))
rep.ShipProduction[pi].ProdUsed = mr.F((*p.Production.ProdUsed).F())
rep.ShipProduction[pi].Percent = mr.F((*p.Production.Progress).F())
i++
}
}
@@ -755,44 +685,33 @@ func (c *Cache) ReportOtherGroup(ri int, rep *mr.Report) {
func (c *Cache) ReportUnidentifiedGroup(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
visibility := r.VisibilityDistance()
flightDistance := r.FlightDistance()
clear(rep.UnidentifiedGroup)
i := 0
for sgi := range rep.InSpaceGroupRangeCache {
sg := c.ShipGroup(sgi)
if sg.OwnerID == r.ID {
if sg.OwnerID == rep.RaceID {
continue
}
if sg.StateInSpace == nil {
panic(fmt.Sprintf("pre-calculated distance group not in space: i=%d", sgi))
}
// Groups heading to one of the recipient's planets are listed in full
// under "incoming groups"; the unidentified list is for the rest.
if c.MustPlanet(sg.Destination).OwnedBy(r.ID) {
continue
}
// Shown once, and only within the visibility range (driveTech*30) of at
// least one of the recipient's planets.
visible := false
for pi := range c.g.Map.Planet {
p := &c.g.Map.Planet[pi]
if !p.OwnedBy(r.ID) {
continue
}
if v, ok := rep.InSpaceGroupRangeCache[sgi][p.Number]; ok && v <= visibility {
visible = true
break
if v, ok := rep.InSpaceGroupRangeCache[sgi][p.Number]; !ok {
panic(fmt.Sprintf("distance cache not pre-calculated: i=%d p=#%d", sgi, p.Number))
} else if v <= flightDistance {
sliceIndexValidate(&rep.UnidentifiedGroup, i)
rep.UnidentifiedGroup[i].X = mr.F(sg.StateInSpace.X.F())
rep.UnidentifiedGroup[i].Y = mr.F(sg.StateInSpace.Y.F())
i++
}
}
if !visible {
continue
}
sliceIndexValidate(&rep.UnidentifiedGroup, i)
rep.UnidentifiedGroup[i].X = mr.F(sg.StateInSpace.X.F())
rep.UnidentifiedGroup[i].Y = mr.F(sg.StateInSpace.Y.F())
i++
}
}
@@ -814,7 +733,6 @@ func (c *Cache) otherGroup(v *mr.OtherGroup, sg *game.ShipGroup, st *game.ShipTy
}
v.Speed = mr.F(sg.Speed(st))
v.Mass = mr.F(st.EmptyMass())
v.Race = c.g.Race[c.RaceIndex(sg.OwnerID)].Name
}
func (c *Cache) localPlanet(v *mr.LocalPlanet, p *game.Planet) {
-141
View File
@@ -3,10 +3,8 @@ package controller_test
import (
"testing"
"galaxy/game/internal/model/game"
"galaxy/model/report"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
@@ -88,142 +86,3 @@ func TestReportLocalShipClass(t *testing.T) {
}
}
}
// TestReportIncomingGroupVisibility checks that a group heading to one of the
// recipient's planets is reported only while within the recipient's visibility
// range (driveTech*30); beyond it the group is hidden even though it is inbound.
func TestReportIncomingGroupVisibility(t *testing.T) {
c, _ := newCache()
gi := c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R1_Planet_1_num, 5)
c.ShipGroup(gi).Destination = R0_Planet_0_num
// Within Race_0 visibility (driveTech 1.1 -> 33 ly), near Planet_2 (3,3).
c.ShipGroup(gi).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(5), Y: floatRef(5)}
rep := c.InitReport(1)
c.ReportIncomingGroup(Race_0_idx, rep)
assert.Len(t, rep.IncomingGroup, 1)
// Beyond the visibility of every Race_0 planet: hidden.
c.ShipGroup(gi).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(40), Y: floatRef(40)}
rep = c.InitReport(1)
c.ReportIncomingGroup(Race_0_idx, rep)
assert.Len(t, rep.IncomingGroup, 0)
}
// TestReportUnidentifiedGroup checks the three rules for the unidentified list:
// groups heading to the recipient's planets are excluded (they are "incoming"),
// only groups within visibility (driveTech*30) appear, and each group appears
// once even when several owned planets are in range.
func TestReportUnidentifiedGroup(t *testing.T) {
c, _ := newCache()
cls := c.MustShipClass(Race_1_idx, Race_1_Gunship).ID
// Not inbound to Race_0, within visibility of BOTH Planet_0 and Planet_2.
g0 := c.CreateShipsUnsafe_T(Race_1_idx, cls, R1_Planet_1_num, 1)
c.ShipGroup(g0).Destination = Uninhabited_Planet_3_num
c.ShipGroup(g0).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(5), Y: floatRef(5)}
// Inbound to a Race_0 planet -> reported as incoming, not unidentified.
g1 := c.CreateShipsUnsafe_T(Race_1_idx, cls, R1_Planet_1_num, 1)
c.ShipGroup(g1).Destination = R0_Planet_0_num
c.ShipGroup(g1).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(5), Y: floatRef(5)}
// Not inbound, beyond visibility -> hidden.
g2 := c.CreateShipsUnsafe_T(Race_1_idx, cls, R1_Planet_1_num, 1)
c.ShipGroup(g2).Destination = Uninhabited_Planet_3_num
c.ShipGroup(g2).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(40), Y: floatRef(40)}
rep := c.InitReport(1)
c.ReportUnidentifiedGroup(Race_0_idx, rep)
assert.Len(t, rep.UnidentifiedGroup, 1)
}
// TestReportOtherShipClassFromBattle checks that the class of a foreign ship
// met in a battle the recipient witnessed is surfaced in OtherShipClass, with
// its design looked up from the owner race's ship types, while the recipient's
// own class is skipped.
func TestReportOtherShipClassFromBattle(t *testing.T) {
c, _ := newCache()
br := &report.BattleReport{
Races: map[int]uuid.UUID{0: Race_0.ID, 1: Race_1.ID},
Ships: map[int]report.BattleReportGroup{
0: {Race: Race_1.Name, ClassName: Race_1_Gunship},
1: {Race: Race_0.Name, ClassName: Race_0_Gunship}, // recipient's own -> skipped
},
}
rep := c.InitReport(1)
c.ReportOtherShipClass(Race_0_idx, rep, []*report.BattleReport{br})
assert.Len(t, rep.OtherShipClass, 1)
g := rep.OtherShipClass[0]
assert.Equal(t, Race_1.Name, g.Race)
assert.Equal(t, Race_1_Gunship, g.Name)
assert.Equal(t, report.F(60.), g.Drive)
assert.Equal(t, uint(3), g.Armament)
assert.Equal(t, report.F(30.), g.Weapons)
assert.Equal(t, report.F(100.), g.Shields)
assert.Equal(t, report.F(0.), g.Cargo)
assert.Equal(t, report.F(220.), g.Mass)
}
// TestReportShipProductionIndex guards the report index: when the only
// ship-producing planet is not the first planet in the map, its entry must
// land at the compacted report index, not the planet's map index (which would
// write out of the grown slice and panic).
func TestReportShipProductionIndex(t *testing.T) {
c, _ := newCache()
assert.NoError(t, c.PlanetProduce(Race_0_idx, int(R0_Planet_2_num), game.ProductionShip, ShipType_Cruiser))
rep := c.InitReport(1)
c.ReportShipProduction(Race_0_idx, rep)
assert.Len(t, rep.ShipProduction, 1)
assert.Equal(t, R0_Planet_2_num, rep.ShipProduction[0].Planet)
}
// TestReportIncomingGroupRemainingDistance checks the reported distance is the
// remaining distance from the group's current hyperspace position to the
// destination, not the full origin-to-destination route.
func TestReportIncomingGroupRemainingDistance(t *testing.T) {
c, _ := newCache()
gi := c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R1_Planet_1_num, 1)
c.ShipGroup(gi).Destination = R0_Planet_0_num // Planet_0 at (1,1)
c.ShipGroup(gi).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(5), Y: floatRef(5)}
rep := c.InitReport(1)
c.ReportIncomingGroup(Race_0_idx, rep)
assert.Len(t, rep.IncomingGroup, 1)
// current (5,5) -> dest (1,1) = sqrt(32) ≈ 5.657; the origin (2,2) -> dest
// route would be sqrt(2) ≈ 1.414.
assert.InDelta(t, 5.657, rep.IncomingGroup[0].Distance.F(), 0.01)
}
// TestReportExitWarnings checks the inactivity-removal warnings: the recipient
// gets a personal countdown only at TTL 1..5, other non-extinct races within 3
// turns are listed publicly, the recipient is excluded from its own public
// list, and extinct races never appear.
func TestReportExitWarnings(t *testing.T) {
c, _ := newCache()
c.Race(Race_0_idx).TTL = 5
c.Race(Race_1_idx).TTL = 2
c.Race(2).TTL = 2 // Race_Extinct: extinct, must never appear publicly
// Race_0's report: personal countdown 5; only Race_1 (TTL 2) is public.
r0 := &report.Report{}
c.ReportExitWarnings(Race_0_idx, r0)
assert.Equal(t, uint(5), r0.PersonalExitWarning)
assert.Len(t, r0.RacesLeavingSoon, 1)
assert.Equal(t, Race_1.Name, r0.RacesLeavingSoon[0].Race)
assert.Equal(t, uint(2), r0.RacesLeavingSoon[0].TurnsLeft)
// Race_1's report: personal countdown 2; Race_0 (TTL 5 > 3) is not public.
r1 := &report.Report{}
c.ReportExitWarnings(Race_1_idx, r1)
assert.Equal(t, uint(2), r1.PersonalExitWarning)
assert.Empty(t, r1.RacesLeavingSoon)
// TTL above the 5-turn window → no personal warning.
c.Race(Race_0_idx).TTL = 6
r0b := &report.Report{}
c.ReportExitWarnings(Race_0_idx, r0b)
assert.Zero(t, r0b.PersonalExitWarning)
}
+2 -2
View File
@@ -49,7 +49,7 @@ func TestPlanetRouteSet(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.PlanetRouteSet(Race_Extinct.Name, "COL", 0, 2),
e.GenericErrorText(e.ErrRaceExtinct))
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.PlanetRouteSet(Race_0.Name, "IND", 0, 2),
e.GenericErrorText(e.ErrInputCargoTypeInvalid))
@@ -87,7 +87,7 @@ func TestPlanetRouteRemove(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.PlanetRouteRemove(Race_Extinct.Name, "COL", 0),
e.GenericErrorText(e.ErrRaceExtinct))
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.PlanetRouteRemove(Race_0.Name, "IND", 0),
e.GenericErrorText(e.ErrInputCargoTypeInvalid))
+1 -4
View File
@@ -2,7 +2,6 @@ package controller
import (
"fmt"
"math"
"slices"
"galaxy/util"
@@ -37,9 +36,7 @@ func (c *Cache) ScienceCreate(ri int, name string, drive, weapons, shileds, carg
return e.NewCargoValueError(cargo)
}
sum := drive + weapons + shileds + cargo
// The proportions must add up to one; a small tolerance keeps inputs like
// 0.1+0.2+0.3+0.4 (which is 1 only up to float rounding) from being rejected.
if math.Abs(sum-1) > 1e-9 {
if sum != 1 {
return e.NewScienceSumValuesError("D=%f W=%f S=%f C=%f sum=%f", drive, weapons, shileds, cargo, sum)
}
c.g.Race[ri].Sciences = append(c.g.Race[ri].Sciences, game.Science{
+2 -13
View File
@@ -33,7 +33,7 @@ func TestScienceCreate(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ScienceCreate(Race_Extinct.Name, second, 0.4, 0, 0.6, 0),
e.GenericErrorText(e.ErrRaceExtinct))
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ScienceCreate(Race_0.Name, BadEntityName, 0.4, 0, 0.6, 0),
e.GenericErrorText(e.ErrInputEntityTypeNameInvalid))
@@ -95,7 +95,7 @@ func TestScienceRemove(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ScienceRemove(Race_Extinct.Name, second),
e.GenericErrorText(e.ErrRaceExtinct))
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ScienceRemove(Race_0.Name, first),
e.GenericErrorText(e.ErrInputEntityNotExists))
@@ -136,14 +136,3 @@ func TestResearchTech(t *testing.T) {
assert.Equal(t, 1.35, rr.Tech.Value(game.TechShields))
assert.Equal(t, 1.45, rr.Tech.Value(game.TechCargo))
}
// TestScienceCreateFloatTolerance checks that proportions which sum to 1 only
// up to float rounding (0.1+0.2+0.3+0.4 == 1.0000000000000002) are accepted,
// while a sum clearly off one is still rejected.
func TestScienceCreateFloatTolerance(t *testing.T) {
_, g := newCache()
assert.NoError(t, g.ScienceCreate(Race_0.Name, "FloatSum", 0.1, 0.2, 0.3, 0.4))
assert.ErrorContains(t,
g.ScienceCreate(Race_0.Name, "NotOne", 0.1, 0.2, 0.3, 0.3),
e.GenericErrorText(e.ErrInputScienceSumValues))
}
-76
View File
@@ -1,76 +0,0 @@
package controller_test
import (
"fmt"
"testing"
"galaxy/model/order"
"galaxy/util"
"galaxy/game/internal/controller"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestServiceOrderStoredThenAppliedAtTurn is the end-to-end regression for the
// order lifecycle against a real Service backed by a temporary storage
// directory: an order submitted through ValidateOrder is persisted (FetchOrder
// returns it before the turn), applied when the turn is produced (GenerateTurn
// advances the turn), and its per-command verdict survives turn production
// (FetchOrder still returns it with cmdApplied set). It guards the wiring the
// Stage 3 collapse reworked — Service methods threading the concrete repo
// through validate → store → produce → read-back.
func TestServiceOrderStoredThenAppliedAtTurn(t *testing.T) {
root, cleanup := util.CreateWorkDir(t)
defer cleanup()
svc, err := controller.NewService(root)
require.NoError(t, err)
races := make([]string, 10)
for i := range races {
races[i] = fmt.Sprintf("race_%02d", i)
}
if _, err := svc.GenerateGame(uuid.New(), races); err != nil {
t.Fatalf("init game: %v", err)
}
vote := &order.CommandRaceVote{
CommandMeta: order.CommandMeta{CmdID: uuid.NewString(), CmdType: order.CommandTypeRaceVote},
Acceptor: races[1],
}
stored, err := svc.ValidateOrder(races[0], vote)
require.NoError(t, err)
require.Len(t, stored.Commands, 1)
// The order is persisted and retrievable for the current turn (0)
// before the turn is produced.
got, ok, err := svc.FetchOrder(races[0], 0)
require.NoError(t, err)
require.True(t, ok, "submitted order must be retrievable before the turn")
require.Len(t, got.Commands, 1)
// Producing the turn applies stored orders and advances the turn.
state, err := svc.GenerateTurn()
require.NoError(t, err)
assert.Equal(t, uint(1), state.Turn, "turn must advance after production")
// The turn-0 order still carries its per-command verdict, recorded by
// turn production.
applied, ok, err := svc.FetchOrder(races[0], 0)
require.NoError(t, err)
require.True(t, ok)
require.Len(t, applied.Commands, 1)
v, ok := order.AsCommand[*order.CommandRaceVote](applied.Commands[0])
require.True(t, ok, "stored command must round-trip to its concrete type")
require.NotNil(t, v.CmdApplied, "turn production must record cmdApplied")
assert.True(t, *v.CmdApplied, "a valid vote must apply at turn production")
// Orders are per-turn: the freshly produced turn carries no order yet.
_, ok, err = svc.FetchOrder(races[0], 1)
require.NoError(t, err)
assert.False(t, ok, "the freshly produced turn carries no stored order")
}
+3 -3
View File
@@ -31,7 +31,7 @@ func TestShipClassCreate(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipClassCreate(Race_Extinct.Name, "Drone", 1, 0, 0, 0, 0),
e.GenericErrorText(e.ErrRaceExtinct))
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ShipClassCreate(Race_0.Name, BadEntityName, 1, 0, 0, 0, 0),
e.GenericErrorText(e.ErrInputEntityTypeNameInvalid))
@@ -109,7 +109,7 @@ func TestShipClassMerge(t *testing.T) {
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.ShipClassMerge(Race_Extinct.Name, "Spy", "Drone"),
e.GenericErrorText(e.ErrRaceExtinct))
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ShipClassMerge(Race_0.Name, "Spy", "Spy"),
e.GenericErrorText(e.ErrInputEntityTypeNameEquality))
@@ -134,7 +134,7 @@ func TestShipClassRemove(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipClassRemove(Race_Extinct.Name, Race_0_Freighter),
e.GenericErrorText(e.ErrRaceExtinct))
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ShipClassRemove(Race_0.Name, "Elephant"),
e.GenericErrorText(e.ErrInputEntityNotExists))
+2 -8
View File
@@ -177,12 +177,6 @@ func (c *Cache) shipGroupDismantle(ri int, groupIndex uuid.UUID) error {
case game.CargoColonist:
if p.OwnedBy(c.g.Race[ri].ID) {
p = game.UnloadColonists(p, load)
} else if !p.Owned() {
// Over a neutral planet the colonists settle it: the planet
// becomes the dismantling race's and the colonists join its
// population. Over a foreign planet they are simply lost.
p.Own(c.g.Race[ri].ID)
p = game.UnloadColonists(p, load)
}
case game.CargoMaterial:
p.Material = p.Material.Add(load)
@@ -414,7 +408,7 @@ func (c *Cache) ShipGroupBreak(ri int, groupID, newID uuid.UUID, quantity uint)
}
if c.ShipGroup(sgi).Number < quantity {
return e.NewBreakGroupNumberNotEnoughError("%d<%d", c.ShipGroup(sgi).Number, quantity)
return e.NewBeakGroupNumberNotEnoughError("%d<%d", c.ShipGroup(sgi).Number, quantity)
}
if quantity > 0 && quantity < c.ShipGroup(sgi).Number {
@@ -497,7 +491,7 @@ func (c *Cache) shipGroupsInUpgrade(planetNumber uint) iter.Seq[*game.ShipGroup]
}
}
slices.SortFunc(result, func(a, b int) int {
return cmp.Compare(c.upgradeCostNow(&c.g.ShipGroups[b]), c.upgradeCostNow(&c.g.ShipGroups[a]))
return cmp.Compare(c.g.ShipGroups[b].StateUpgrade.Cost(), c.g.ShipGroups[a].StateUpgrade.Cost())
})
for i := range result {
if !yield(&c.g.ShipGroups[result[i]]) {
+7 -12
View File
@@ -34,20 +34,15 @@ func (c *Cache) MoveShipGroups() {
func (c *Cache) moveShipGroup(i int, delta float64) {
sg := c.ShipGroup(i)
var originX, originY float64
switch sg.State() {
case game.StateLaunched:
// Just launched: the group is still at its origin planet and has not
// stored a hyperspace position yet, so the first leg starts there.
origin := c.MustPlanet(sg.StateInSpace.Origin)
originX, originY = origin.X.F(), origin.Y.F()
case game.StateInSpace:
originX, originY = sg.StateInSpace.X.F(), sg.StateInSpace.Y.F()
default:
panic(fmt.Sprintf("ship group state invalid for move: %v", sg.State()))
originX, originY, ok := sg.Coord()
if !ok {
panic(fmt.Sprintf("ship group state invalid: %v", sg.State()))
}
destPlanet := c.MustPlanet(sg.Destination)
x, y, arrived := util.NextTravelCoord(c.g.Map.Width, c.g.Map.Height, originX, originY, destPlanet.X.F(), destPlanet.Y.F(), delta)
arrived := false
var x, y float64
x, y, arrived =
util.NextTravelCoord(c.g.Map.Width, c.g.Map.Height, originX, originY, destPlanet.X.F(), destPlanet.Y.F(), delta)
fx, fy := game.F(x), game.F(y)
sg.StateInSpace.X = &fx
sg.StateInSpace.Y = &fy
@@ -45,20 +45,3 @@ func TestListMoveableGroupIds(t *testing.T) {
assert.NotEqual(t, game.StateTransfer, sg.State())
}
}
// TestMoveLaunchedGroupFromOrigin guards the launched-coordinate fix: a group
// just sent by an order is Launched with no stored hyperspace position, so its
// first leg must start from the origin planet. The pre-fix code dereferenced
// the nil launch coordinate and panicked.
func TestMoveLaunchedGroupFromOrigin(t *testing.T) {
c, g := newCache()
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1))
assert.NoError(t, g.ShipGroupSend(Race_0.Name, c.ShipGroup(0).ID, R0_Planet_2_num))
assert.Equal(t, game.StateLaunched, c.ShipGroup(0).State())
// Must not panic on the nil launch coordinate. Planet_0 (1,1) -> Planet_2
// (3,3) is ~2.83 ly; a Cruiser covers it in one turn and arrives.
c.MoveShipGroups()
assert.Equal(t, game.StateInOrbit, c.ShipGroup(0).State())
assert.Equal(t, R0_Planet_2_num, c.ShipGroup(0).Destination)
}

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