Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a19512adaa | |||
| 814eae0802 | |||
| cb8491c200 | |||
| 45815c27d9 | |||
| e11092234c | |||
| 84a0ccb23f | |||
| 7fb6a63c2b | |||
| 225f89fad6 | |||
| 0cae89cba2 | |||
| 26f1e62924 | |||
| 7cac910de4 | |||
| 87a272166b | |||
| ecfb2d3351 | |||
| cf34710b4f | |||
| 985e51d25e | |||
| 27916bbe61 | |||
| 5d2f2bfc26 | |||
| e998c8a03a | |||
| a01e3891e7 | |||
| f9f725f657 | |||
| 9b689b2885 | |||
| 2a3f31a32b | |||
| 140ee8e0ee | |||
| d3770e7f77 | |||
| 3d7e4d30bb | |||
| eb549e6049 | |||
| bc838d72af | |||
| 658ab7f6e7 | |||
| afb8c1225c | |||
| 9e9977d5f1 | |||
| 9dce15c7bb | |||
| dc621cc715 | |||
| bef6c46a1c | |||
| 3b1c52cd02 | |||
| 200236369f | |||
| a01f39e4a7 | |||
| 6c00a24577 | |||
| 6ec1098f15 | |||
| f877a199c2 | |||
| 53b3cafbc4 | |||
| b4abf90ec5 | |||
| 5e86ca9999 | |||
| cc67364113 | |||
| 97b5535c02 | |||
| bde9d535dc | |||
| 40d6ba6ba4 | |||
| 06a2e631c9 | |||
| 2f55fc4988 | |||
| 601970b028 | |||
| e36d33482f | |||
| 15d35f6f1f | |||
| 4a7bf0be61 | |||
| 2ffd7527a6 | |||
| 723885e74e | |||
| e038ea6154 | |||
| af30846091 | |||
| ce1dc19a29 | |||
| a37b784452 | |||
| f4670c1831 | |||
| 4d729c1f50 | |||
| 24d75564bb | |||
| eb5018342e | |||
| 6c3cd25476 | |||
| 6996a79286 | |||
| 75a4211373 | |||
| 680ebac919 | |||
| ba93a9092e | |||
| 209f8508cd | |||
| e4fbb6644c | |||
| 8e552f556d | |||
| 80ed11e3b6 | |||
| ef4cecb4b2 | |||
| 1b2c13ecd6 | |||
| cfbe052242 | |||
| 147c7d0a6a | |||
| 24c68e9846 | |||
| cc4bc3c2b7 | |||
| aee5f39a7e | |||
| 4a23c357e5 | |||
| 2901ecb21b | |||
| ed4e2f58a1 | |||
| b31d9f4c45 | |||
| 208d30073b | |||
| 6fbab5417f | |||
| 8e8b34d112 | |||
| 175bf25794 | |||
| 3d8aa91973 | |||
| 3153a95292 | |||
| f42ab87233 | |||
| cff7cc3859 | |||
| 058c4fcf69 | |||
| 009ea560f9 | |||
| 98d1fe6cae |
@@ -148,14 +148,102 @@ 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 containers (incl. engine instances backend
|
||||
# spawned itself with the same label) are left intact —
|
||||
# those are reattached by the backend reconciler on boot.
|
||||
# daemon. Running engine containers spawned by backend with
|
||||
# the same label are left intact when their image SHA still
|
||||
# matches the freshly-built `galaxy-engine:dev` (handled by
|
||||
# the preceding `Recycle engine containers on image drift`
|
||||
# step); the reconciler reattaches them on backend boot.
|
||||
ids=$(docker ps -aq \
|
||||
--filter "label=galaxy.stack=dev-deploy" \
|
||||
--filter "status=exited" \
|
||||
@@ -168,11 +256,24 @@ jobs:
|
||||
|
||||
- name: Bring up the stack
|
||||
working-directory: tools/dev-deploy
|
||||
env:
|
||||
# Recipient regex Mailpit auto-relays to the owner's Gmail.
|
||||
# Unset/empty → the compose default (non-routable) keeps the
|
||||
# stack capture-only.
|
||||
GALAXY_DEV_MAIL_RELAY_MATCH: ${{ vars.GALAXY_DEV_MAIL_RELAY_MATCH }}
|
||||
# Grafana admin password; unset/empty -> compose default 'admin'.
|
||||
GALAXY_DEV_GRAFANA_ADMIN_PASSWORD: ${{ secrets.GALAXY_DEV_GRAFANA_ADMIN_PASSWORD }}
|
||||
run: |
|
||||
# Resolve in the shell, not in YAML expressions — `env.HOME`
|
||||
# is empty at the workflow-evaluation stage.
|
||||
export GALAXY_DEV_GAME_STATE_DIR="$HOME/.galaxy-dev/game-state"
|
||||
mkdir -p "$GALAXY_DEV_GAME_STATE_DIR"
|
||||
# Seed the monitoring config to a stable, reboot-surviving host
|
||||
# path (compose binds \${GALAXY_DEV_MONITORING_DIR} read-only).
|
||||
export GALAXY_DEV_MONITORING_DIR="$HOME/.galaxy-dev/monitoring"
|
||||
rm -rf "$GALAXY_DEV_MONITORING_DIR"
|
||||
mkdir -p "$GALAXY_DEV_MONITORING_DIR"
|
||||
cp -r monitoring/. "$GALAXY_DEV_MONITORING_DIR/"
|
||||
docker compose up -d --wait --remove-orphans
|
||||
|
||||
- name: Probe the stack
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
name: Tests · FBS codegen
|
||||
|
||||
# Guards that the committed FlatBuffers bindings (Go under
|
||||
# pkg/schema/fbs/<schema>/ and TS under ui/frontend/src/proto/galaxy/fbs/)
|
||||
# are exactly what the pinned flatc produces from the .fbs schemas.
|
||||
# Catches both "changed a schema but forgot to regenerate" and
|
||||
# "regenerated with the wrong flatc version" (e.g. a distro's older
|
||||
# flatbuffers-compiler), which silently churns output and can flip
|
||||
# nullable-scalar wire defaults. Path-filtered so it only runs when the
|
||||
# schemas, the generated trees, the fbs Makefiles, or this workflow change.
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'pkg/schema/fbs/**'
|
||||
- 'ui/frontend/src/proto/galaxy/fbs/**'
|
||||
- 'ui/Makefile'
|
||||
- '.gitea/workflows/fbs-codegen.yaml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'pkg/schema/fbs/**'
|
||||
- 'ui/frontend/src/proto/galaxy/fbs/**'
|
||||
- 'ui/Makefile'
|
||||
- '.gitea/workflows/fbs-codegen.yaml'
|
||||
|
||||
concurrency:
|
||||
group: fbs-codegen-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FLATC_VERSION: 25.9.23
|
||||
|
||||
jobs:
|
||||
codegen:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Cache flatc
|
||||
id: cache-flatc
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/flatc-bin
|
||||
key: flatc-${{ env.FLATC_VERSION }}-linux-g++13
|
||||
|
||||
- name: Install pinned flatc
|
||||
if: steps.cache-flatc.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
mkdir -p "${{ runner.temp }}/flatc-bin"
|
||||
curl -sSL -o /tmp/flatc.zip \
|
||||
"https://github.com/google/flatbuffers/releases/download/v${FLATC_VERSION}/Linux.flatc.binary.g++-13.zip"
|
||||
python3 -m zipfile -e /tmp/flatc.zip "${{ runner.temp }}/flatc-bin"
|
||||
chmod +x "${{ runner.temp }}/flatc-bin/flatc"
|
||||
|
||||
- name: Add flatc to PATH
|
||||
run: echo "${{ runner.temp }}/flatc-bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Verify flatc version
|
||||
run: flatc --version
|
||||
|
||||
- name: Regenerate Go + TS bindings
|
||||
run: |
|
||||
make -C pkg/schema/fbs fbs-go
|
||||
make -C ui fbs-ts
|
||||
|
||||
- name: Assert no drift
|
||||
run: |
|
||||
if ! git diff --exit-code || [ -n "$(git status --porcelain)" ]; then
|
||||
echo "::error::Committed FlatBuffers bindings differ from a flatc ${FLATC_VERSION} regeneration."
|
||||
echo "Run 'make -C pkg/schema/fbs fbs-go' and 'make -C ui fbs-ts' with flatc ${FLATC_VERSION} and commit the result."
|
||||
git status --porcelain
|
||||
git --no-pager diff
|
||||
exit 1
|
||||
fi
|
||||
@@ -119,3 +119,16 @@ jobs:
|
||||
name: playwright-traces
|
||||
path: ui/frontend/test-results/
|
||||
retention-days: 14
|
||||
|
||||
- name: Remove root-owned build artifacts
|
||||
if: always()
|
||||
# In host-mode the job runs as root, so vite (test:pwa),
|
||||
# svelte-kit and Playwright write these outputs root-owned into
|
||||
# the shared host workspace. The act_runner (non-root) then
|
||||
# cannot remove them at teardown ("unlinkat ... permission
|
||||
# denied"), which spuriously fails this or a sibling job that
|
||||
# inherits the dirty workspace (observed on go-unit). Clean them
|
||||
# here while the step still has root, after the uploads above.
|
||||
run: |
|
||||
rm -rf ui/frontend/build ui/frontend/.svelte-kit \
|
||||
ui/frontend/test-results ui/frontend/playwright-report
|
||||
|
||||
@@ -16,6 +16,12 @@ This repository hosts the Galaxy Game project.
|
||||
mirrored into `docs/FUNCTIONAL_ru.md` in the same patch (translate
|
||||
the changed paragraphs only, do not re-translate the whole file).
|
||||
A full re-translation only happens on explicit owner request.
|
||||
- `site/ru/rules.md` — the player-facing game rules (ported from the
|
||||
former `game/rules.txt`). **Russian is authoritative here**, inverting
|
||||
the usual English-first rule: the game's rules and lore are
|
||||
Russian-native, so `site/ru/rules.md` leads and the English
|
||||
`site/rules.md` is its mirror. Mirror point edits the same way as
|
||||
`docs/FUNCTIONAL.md`, but RU → EN.
|
||||
- `docs/TESTING.md` — testing layers (unit / integration), the
|
||||
integration runbook, and the principles every test must follow
|
||||
(no-op observability for testcontainers, `t.Fatal` on
|
||||
|
||||
+12
-3
@@ -27,10 +27,16 @@ The implementation specification lives in `PLAN.md`.
|
||||
| ------------------ | ----------------------------------------------- | ------------------------------------- |
|
||||
| `/api/v1/public/*` | none | Registration, code confirmation |
|
||||
| `/api/v1/user/*` | `X-User-ID` injected by gateway | Authenticated end users |
|
||||
| `/api/v1/admin/*` | HTTP Basic Auth against `admin_accounts` | Platform administrators |
|
||||
| `/api/v1/admin/*` | HTTP Basic Auth against `admin_accounts` | Platform administrators (JSON) |
|
||||
| `/_gm`, `/_gm/*` | HTTP Basic Auth against `admin_accounts` | Operator console (server-rendered HTML)|
|
||||
| `/healthz` | none | Liveness probe |
|
||||
| `/readyz` | none | Readiness probe |
|
||||
|
||||
The `/_gm` operator console is the human-facing surface for the admin
|
||||
operations; it reuses the admin Basic Auth verifier, renders with
|
||||
`html/template`, and is the only admin surface exposed publicly (through
|
||||
the gateway). See `docs/admin-console.md`.
|
||||
|
||||
The full contract is documented in `openapi.yaml` and validated at
|
||||
runtime by the contract tests under `internal/server/`.
|
||||
|
||||
@@ -100,6 +106,7 @@ fast.
|
||||
| `BACKEND_GAME_STATE_ROOT` | yes | — | Host directory bind-mounted into engine containers. |
|
||||
| `BACKEND_ADMIN_BOOTSTRAP_USER` | no | — | Initial admin username; idempotent insert. |
|
||||
| `BACKEND_ADMIN_BOOTSTRAP_PASSWORD` | no | — | Initial admin password; required if user is set. |
|
||||
| `BACKEND_ADMIN_CONSOLE_CSRF_KEY` | no | random per-process | Secret keying the `/_gm` console CSRF token. Set a shared value across replicas; unset uses a per-process random key (forms reset on restart). |
|
||||
| `BACKEND_GEOIP_DB_PATH` | yes | — | Filesystem path to GeoLite2 Country `.mmdb`. |
|
||||
| `BACKEND_OTEL_TRACES_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`. |
|
||||
| `BACKEND_OTEL_METRICS_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`, `prometheus`. |
|
||||
@@ -257,11 +264,13 @@ introduce its own request/response types.
|
||||
|
||||
Endpoints used:
|
||||
|
||||
- `POST /api/v1/admin/init`
|
||||
- `POST /api/v1/admin/init` — the runtime worker passes the canonical
|
||||
`game_id` (the same UUID that names the engine container and the
|
||||
host bind-mount directory) in the request body so the engine's
|
||||
`state.json` shares identity with the backend's `games.game_id`.
|
||||
- `GET /api/v1/admin/status`
|
||||
- `PUT /api/v1/admin/turn`
|
||||
- `POST /api/v1/admin/race/banish`
|
||||
- `PUT /api/v1/command`
|
||||
- `PUT /api/v1/order`
|
||||
- `GET /api/v1/report`
|
||||
- `GET /healthz`
|
||||
|
||||
+46
-18
@@ -22,10 +22,10 @@ import (
|
||||
_ "time/tzdata"
|
||||
|
||||
"galaxy/backend/internal/admin"
|
||||
"galaxy/backend/internal/adminconsole"
|
||||
"galaxy/backend/internal/app"
|
||||
"galaxy/backend/internal/auth"
|
||||
"galaxy/backend/internal/config"
|
||||
"galaxy/backend/internal/devsandbox"
|
||||
"galaxy/backend/internal/diplomail"
|
||||
"galaxy/backend/internal/diplomail/detector"
|
||||
"galaxy/backend/internal/diplomail/translator"
|
||||
@@ -37,6 +37,7 @@ import (
|
||||
"galaxy/backend/internal/mail"
|
||||
"galaxy/backend/internal/metricsapi"
|
||||
"galaxy/backend/internal/notification"
|
||||
"galaxy/backend/internal/opsstatus"
|
||||
backendpostgres "galaxy/backend/internal/postgres"
|
||||
"galaxy/backend/push"
|
||||
"galaxy/backend/internal/runtime"
|
||||
@@ -272,29 +273,18 @@ func run(ctx context.Context) (err error) {
|
||||
)
|
||||
runtimeGateway.svc = runtimeSvc
|
||||
|
||||
// Run a single reconciliation pass before the dev-sandbox
|
||||
// bootstrap so any runtime row pointing at a vanished engine
|
||||
// container (host reboot wiped /tmp/galaxy-game-state/<uuid>;
|
||||
// `tools/local-dev`'s `prune-broken-engines` target reaped the
|
||||
// husk) is already cascaded through `markRemoved` → lobby
|
||||
// `cancelled` by the time the bootstrap walks the sandbox list.
|
||||
// Without this pre-tick the bootstrap would reuse the
|
||||
// soon-to-be-cancelled game and force the developer into a
|
||||
// second `make up` cycle to land a healthy sandbox. Failures are
|
||||
// Run a single reconciliation pass at startup so any runtime row
|
||||
// pointing at a vanished engine container (a host reboot wiped
|
||||
// /tmp/galaxy-game-state/<uuid>; `tools/local-dev`'s
|
||||
// `prune-broken-engines` target reaped the husk) is cascaded
|
||||
// through `markRemoved` → lobby `cancelled` before the server
|
||||
// starts serving requests. Failures are
|
||||
// non-fatal: the periodic ticker started later catches up, and
|
||||
// the worst case degrades to the legacy two-cycle recovery.
|
||||
if err := runtimeSvc.Reconciler().Tick(ctx); err != nil {
|
||||
logger.Warn("pre-bootstrap reconciler tick failed", zap.Error(err))
|
||||
}
|
||||
|
||||
if err := devsandbox.Bootstrap(ctx, devsandbox.Deps{
|
||||
Users: userSvc,
|
||||
Lobby: lobbySvc,
|
||||
EngineVersions: engineVersionSvc,
|
||||
}, cfg.DevSandbox, logger); err != nil {
|
||||
return fmt.Errorf("dev sandbox bootstrap: %w", err)
|
||||
}
|
||||
|
||||
notifStore := notification.NewStore(db)
|
||||
notifSvc := notification.NewService(notification.Deps{
|
||||
Store: notifStore,
|
||||
@@ -360,6 +350,32 @@ func run(ctx context.Context) (err error) {
|
||||
return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready()
|
||||
}
|
||||
|
||||
var consoleCSRF *adminconsole.CSRF
|
||||
if cfg.AdminConsole.CSRFKey != "" {
|
||||
consoleCSRF = adminconsole.NewCSRF([]byte(cfg.AdminConsole.CSRFKey))
|
||||
} else {
|
||||
consoleCSRF, err = adminconsole.NewRandomCSRF()
|
||||
if err != nil {
|
||||
return fmt.Errorf("init admin console CSRF: %w", err)
|
||||
}
|
||||
logger.Warn("admin console CSRF key not set; using a per-process random key (forms reset on restart, not valid across replicas)",
|
||||
zap.String("env", "BACKEND_ADMIN_CONSOLE_CSRF_KEY"))
|
||||
}
|
||||
adminConsoleHandlers := backendserver.NewAdminConsoleHandlers(backendserver.AdminConsoleDeps{
|
||||
CSRF: consoleCSRF,
|
||||
Monitor: opsstatus.NewStore(db),
|
||||
Ready: ready,
|
||||
Users: userSvc,
|
||||
Games: lobbySvc,
|
||||
Runtime: runtimeSvc,
|
||||
EngineVersions: engineVersionSvc,
|
||||
Operators: adminSvc,
|
||||
Mail: mailSvc,
|
||||
Notifications: notifSvc,
|
||||
Diplomail: diplomailSvc,
|
||||
Logger: logger,
|
||||
})
|
||||
|
||||
handler, err := backendserver.NewRouter(backendserver.RouterDependencies{
|
||||
Logger: logger,
|
||||
Telemetry: telemetryRT,
|
||||
@@ -388,6 +404,7 @@ func run(ctx context.Context) (err error) {
|
||||
AdminGeo: adminGeoHandlers,
|
||||
UserGames: userGamesHandlers,
|
||||
UserMail: userMailHandlers,
|
||||
AdminConsole: adminConsoleHandlers,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("build backend router: %w", err)
|
||||
@@ -485,6 +502,17 @@ func (a *userEntitlementAdapter) GetMaxRegisteredRaceNames(ctx context.Context,
|
||||
return snap.MaxRegisteredRaceNames, nil
|
||||
}
|
||||
|
||||
func (a *userEntitlementAdapter) IsPaid(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
if a == nil || a.svc == nil {
|
||||
return false, nil
|
||||
}
|
||||
snap, err := a.svc.GetEntitlementSnapshot(ctx, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return snap.IsPaid, nil
|
||||
}
|
||||
|
||||
// runtimeGatewayAdapter implements `lobby.RuntimeGateway` by
|
||||
// delegating to `*runtime.Service`. The svc pointer is patched after
|
||||
// the services are constructed — runtime depends on lobby
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Operator console (`/_gm`)
|
||||
|
||||
The operator console is a server-rendered web UI for the platform's admin
|
||||
operations. It is the human-facing counterpart to the JSON admin API under
|
||||
`/api/v1/admin/*`: both call the same service layer, but the console renders
|
||||
HTML pages an operator drives in a browser, while the JSON API stays internal
|
||||
to the deployment for programmatic and test use.
|
||||
|
||||
## Design choices
|
||||
|
||||
- **Server-rendered, no client framework.** Pages are rendered with the
|
||||
standard library's `html/template`. Navigation is by ordinary links and
|
||||
query parameters; every state change is an HTML form `POST` answered with a
|
||||
Post/Redirect/Get redirect. There is no build step, no JavaScript framework,
|
||||
and no separate asset pipeline — a single embedded stylesheet under
|
||||
`/_gm/assets/`.
|
||||
- **Reuses the existing admin auth.** The console mounts behind the same
|
||||
`basicauth.Middleware(admin.Service)` verifier that gates `/api/v1/admin/*`,
|
||||
so there is one credential store (`admin_accounts`, bcrypt-12) and no second
|
||||
secret to manage.
|
||||
- **Lives in the backend.** The backend owns the admin domain and the data, so
|
||||
rendering there lets the console call the service layer directly. The gateway
|
||||
stays a thin proxy.
|
||||
|
||||
## Request path
|
||||
|
||||
```
|
||||
Browser ── /_gm/* ──► edge Caddy ──► gateway (public listener)
|
||||
gateway: anti-abuse `admin` class (per-IP rate limit, body + method limits)
|
||||
└─► reverse proxy ──► backend /_gm/*
|
||||
backend: basicauth.Middleware(admin.Service)
|
||||
└─► CSRF guard (state-changing methods)
|
||||
└─► console handler ──► admin service layer ──► html/template
|
||||
```
|
||||
|
||||
The gateway preserves the inbound `Host` and relays the backend's `401` Basic
|
||||
Auth challenge unchanged, so the browser shows its native credential dialog.
|
||||
The gateway adds only the edge anti-abuse layer; authentication and every state
|
||||
change are enforced by the backend. The gateway answers `502` when the backend
|
||||
is unreachable. See the gateway README "Operator Console Proxy" section for the
|
||||
`admin` route-class env vars.
|
||||
|
||||
## Components (package `internal/adminconsole`)
|
||||
|
||||
The package is framework-agnostic (no gin) so it unit-tests in isolation:
|
||||
|
||||
- `Renderer` — parses the embedded layout plus one content page per route and
|
||||
renders a named page wrapped in the shared layout. Rendering goes through an
|
||||
intermediate buffer, so a template failure never emits a partial document.
|
||||
- `CSRF` — issues and verifies the stateless anti-CSRF token: HMAC-SHA256 over
|
||||
the authenticated username, keyed by `BACKEND_ADMIN_CONSOLE_CSRF_KEY`. When
|
||||
the key is unset a per-process random key is used (secure, but forms reset on
|
||||
restart and do not validate across replicas — set a shared key for
|
||||
multi-replica deployments).
|
||||
- `Assets` — the embedded stylesheet filesystem served under `/_gm/assets/`.
|
||||
|
||||
The gin glue (route group, Basic Auth, the CSRF guard middleware, the per-page
|
||||
handlers) lives in `internal/server/handlers_admin_console.go` and
|
||||
`internal/server/router.go` (`registerAdminConsoleRoutes`).
|
||||
|
||||
## CSRF protection
|
||||
|
||||
Because the console is sessionless (HTTP Basic Auth, whose credentials the
|
||||
browser replays automatically), state-changing requests are double-guarded:
|
||||
|
||||
1. A stateless per-operator token (`_csrf` form field) that a cross-site page
|
||||
cannot read or forge.
|
||||
2. A same-origin `Origin`/`Referer` check (when the browser sends one), which
|
||||
relies on the gateway preserving the inbound `Host`.
|
||||
|
||||
Safe methods (`GET`/`HEAD`/`OPTIONS`) pass without a token.
|
||||
|
||||
## Monitoring
|
||||
|
||||
The dashboard is the console landing page. It surfaces backend-visible
|
||||
operational state — service health, game-runtime status, and queue depths —
|
||||
read through the existing service and persistence layers. Richer cross-service
|
||||
metrics are out of scope for the console itself: the `/metrics` Prometheus
|
||||
exporters on `backend` and `gateway` are wired and enabled in the dev
|
||||
deployment so a future Prometheus + Grafana stack can scrape them without code
|
||||
changes.
|
||||
|
||||
## Pages
|
||||
|
||||
| Path | Method | Purpose |
|
||||
| --------------------------------- | -------- | -------------------------------------------------------------- |
|
||||
| `/_gm`, `/_gm/` | GET | Dashboard: health, runtime/mail/notification status, queues. |
|
||||
| `/_gm/assets/*` | GET | Embedded stylesheet. |
|
||||
| `/_gm/users` | GET | Paginated account list. |
|
||||
| `/_gm/users/{id}` | GET | Account detail: profile, entitlement, active sanctions. |
|
||||
| `/_gm/users/{id}/block` | POST | Apply a permanent block (reason required). |
|
||||
| `/_gm/users/{id}/entitlement` | POST | Set the entitlement tier. |
|
||||
| `/_gm/users/{id}/soft-delete` | POST | Soft-delete the account (cascades). |
|
||||
| `/_gm/games` | GET/POST | Paginated game list; POST creates a public game. |
|
||||
| `/_gm/games/{id}` | GET | Game detail with the runtime snapshot. |
|
||||
| `/_gm/games/{id}/force-start` | POST | Force-start the game. |
|
||||
| `/_gm/games/{id}/force-stop` | POST | Force-stop the game. |
|
||||
| `/_gm/games/{id}/ban-member` | POST | Ban a member (user id + reason). |
|
||||
| `/_gm/games/{id}/runtime/restart` | POST | Restart the engine container. |
|
||||
| `/_gm/games/{id}/runtime/patch` | POST | Patch the runtime to a target version. |
|
||||
| `/_gm/games/{id}/runtime/force-next-turn` | POST | Force the next turn now. |
|
||||
| `/_gm/engine-versions` | GET/POST | Version registry; POST registers a version. |
|
||||
| `/_gm/engine-versions/{ver}/disable` | POST | Disable a registered version. |
|
||||
| `/_gm/operators` | GET/POST | Admin-account list; POST creates an operator. |
|
||||
| `/_gm/operators/{user}/disable` | POST | Disable an operator. |
|
||||
| `/_gm/operators/{user}/enable` | POST | Re-enable an operator. |
|
||||
| `/_gm/operators/{user}/reset-password` | POST | Reset an operator's password. |
|
||||
| `/_gm/mail` | GET | Mail deliveries (paginated) + a dead-letter snapshot. |
|
||||
| `/_gm/mail/deliveries/{id}` | GET | Delivery detail with its attempts. |
|
||||
| `/_gm/mail/deliveries/{id}/resend`| POST | Re-enqueue a non-sent delivery. |
|
||||
| `/_gm/notifications` | GET | Notifications, dead-letters, and malformed intents overview. |
|
||||
| `/_gm/broadcast` | GET/POST | Admin multi-game diplomatic broadcast. |
|
||||
|
||||
Each page reuses the same service layer as the corresponding `/api/v1/admin/*`
|
||||
JSON endpoint; the console adds no business logic. Collection-mutating POSTs are
|
||||
mounted on the collection path (`POST /_gm/games`, `POST /_gm/engine-versions`)
|
||||
so a static action segment never collides with a path parameter in the gin
|
||||
router. Unblocking a user is not yet available because the JSON admin API
|
||||
exposes no remove-sanction endpoint.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Where | Notes |
|
||||
| --------------------------------- | ------- | ------------------------------------------------------------ |
|
||||
| `BACKEND_ADMIN_CONSOLE_CSRF_KEY` | backend | CSRF token key; unset → per-process random key. |
|
||||
| `BACKEND_ADMIN_BOOTSTRAP_USER` | backend | Bootstrap operator account (shared with the JSON admin API). |
|
||||
| `BACKEND_ADMIN_BOOTSTRAP_PASSWORD`| backend | Bootstrap operator password. |
|
||||
| `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_*` | gateway | `admin` route-class rate-limit and body budgets. |
|
||||
@@ -234,8 +234,8 @@ sequenceDiagram
|
||||
|
||||
Workers->>Docker: pull / create / start engine container
|
||||
Docker-->>Workers: container id
|
||||
Workers->>Engine: POST /api/v1/admin/init
|
||||
Engine-->>Workers: ok / error
|
||||
Workers->>Engine: POST /api/v1/admin/init {gameId, races}
|
||||
Engine-->>Workers: StateResponse{id == gameId} / error
|
||||
Workers->>Runtime: write runtime_records (running or start_failed)
|
||||
Workers->>Lobby: OnRuntimeJobResult
|
||||
|
||||
|
||||
@@ -141,7 +141,10 @@ boot).
|
||||
polls the engine `/healthz` until the listener is bound (Docker
|
||||
marks a container running as soon as the entrypoint starts; the
|
||||
Go binary inside takes a moment to bind its TCP port). Only after
|
||||
`/healthz` succeeds does the worker call `/admin/init`.
|
||||
`/healthz` succeeds does the worker call `/admin/init`, passing the
|
||||
same `game_id` the backend uses to mount the engine's storage
|
||||
directory; the engine echoes it back in `StateResponse.id`. The
|
||||
engine rejects a mismatched gameId with `409 Conflict`.
|
||||
- **Runtime scheduler** (`internal/runtime.SchedulerComponent`) —
|
||||
`pkg/cronutil` schedule per running game; each tick invokes the
|
||||
engine `admin/turn`. Force-next-turn flips a one-shot skip flag in
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/* Admin console stylesheet. Deliberately small and dependency-free: the
|
||||
console is an internal operator tool, not a public surface. */
|
||||
:root {
|
||||
--bg: #11151c;
|
||||
--panel: #1b2230;
|
||||
--panel-hi: #232c3d;
|
||||
--ink: #e6ebf2;
|
||||
--ink-dim: #9aa7ba;
|
||||
--line: #2c3850;
|
||||
--accent: #5aa9ff;
|
||||
--danger: #ff6b6b;
|
||||
--ok: #4ecb8d;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 15px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.topbar .brand { font-weight: 700; letter-spacing: 0.04em; }
|
||||
.topbar .mainnav { display: flex; gap: 1rem; flex: 1; }
|
||||
.topbar .mainnav a.active { color: var(--ink); border-bottom: 2px solid var(--accent); }
|
||||
.topbar .who { color: var(--ink-dim); }
|
||||
.content { padding: 1.5rem; max-width: 1100px; margin: 0 auto; }
|
||||
h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
|
||||
.lede { color: var(--ink-dim); margin-top: 0; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-top: 1.5rem; }
|
||||
.card {
|
||||
display: block;
|
||||
padding: 1rem 1.2rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.card:hover { background: var(--panel-hi); text-decoration: none; }
|
||||
.card h2 { font-size: 1.05rem; margin: 0 0 0.3rem; color: var(--accent); }
|
||||
.card p { margin: 0; color: var(--ink-dim); font-size: 0.9rem; }
|
||||
|
||||
.panel {
|
||||
padding: 0.9rem 1.1rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.panel h2 { font-size: 1rem; margin: 0 0 0.6rem; color: var(--ink); }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
|
||||
.grid .panel { margin-bottom: 0; }
|
||||
.kv { list-style: none; margin: 0; padding: 0; }
|
||||
.kv li { padding: 0.15rem 0; color: var(--ink-dim); }
|
||||
.counts { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||
.counts td { padding: 0.2rem 0; border-bottom: 1px solid var(--line); color: var(--ink-dim); }
|
||||
.counts td.num { text-align: right; color: var(--ink); font-variant-numeric: tabular-nums; }
|
||||
.bignum { font-size: 1.6rem; margin: 0; color: var(--ink); }
|
||||
.note { color: var(--ink-dim); font-style: italic; margin: 0.2rem 0; }
|
||||
.errors { border-color: var(--danger); }
|
||||
.errors ul { margin: 0; padding-left: 1.1rem; color: var(--danger); }
|
||||
.ok { color: var(--ok); }
|
||||
.bad { color: var(--danger); }
|
||||
|
||||
.list { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.list th, .list td { text-align: left; padding: 0.35rem 0.6rem; border-bottom: 1px solid var(--line); }
|
||||
.list th { color: var(--ink-dim); font-weight: 600; }
|
||||
.list tr:hover td { background: var(--panel-hi); }
|
||||
.pager { display: flex; gap: 1rem; align-items: center; color: var(--ink-dim); }
|
||||
.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.8rem; }
|
||||
.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
|
||||
.form input, .form select {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: #06121f;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { filter: brightness(1.1); }
|
||||
button.danger { background: var(--danger); color: #1a0606; }
|
||||
code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
|
||||
.actions form { margin: 0; }
|
||||
.subnav { color: var(--ink-dim); margin: -0.3rem 0 1rem; font-size: 0.9rem; }
|
||||
@@ -0,0 +1,54 @@
|
||||
package adminconsole
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CSRF issues and verifies the stateless anti-CSRF token used by the admin
|
||||
// console. The token is an HMAC-SHA256 over the authenticated operator's
|
||||
// username keyed by a process secret, so a cross-site request cannot forge it
|
||||
// without already being able to read an authenticated page. The console is
|
||||
// sessionless (HTTP Basic Auth), which makes a stateless, per-operator token
|
||||
// the natural fit.
|
||||
type CSRF struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
// NewCSRF returns a CSRF signer keyed by key. A shared key across backend
|
||||
// replicas lets a form rendered by one replica validate on another; callers
|
||||
// that pass a per-process random key (see NewRandomCSRF) accept that forms do
|
||||
// not survive a restart or span replicas.
|
||||
func NewCSRF(key []byte) *CSRF {
|
||||
return &CSRF{key: key}
|
||||
}
|
||||
|
||||
// NewRandomCSRF returns a CSRF signer keyed by a fresh 32-byte random secret.
|
||||
// It is the secure default when no shared key is configured.
|
||||
func NewRandomCSRF() (*CSRF, error) {
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, fmt.Errorf("generate admin console CSRF key: %w", err)
|
||||
}
|
||||
return &CSRF{key: key}, nil
|
||||
}
|
||||
|
||||
// Token returns the anti-CSRF token bound to username.
|
||||
func (c *CSRF) Token(username string) string {
|
||||
mac := hmac.New(sha256.New, c.key)
|
||||
mac.Write([]byte(username))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// Verify reports whether token is the valid anti-CSRF token for username. The
|
||||
// comparison runs in constant time relative to the token bytes.
|
||||
func (c *CSRF) Verify(username, token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
expected := c.Token(username)
|
||||
return hmac.Equal([]byte(token), []byte(expected))
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package adminconsole
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCSRFTokenRoundTrip(t *testing.T) {
|
||||
signer := NewCSRF([]byte("shared-secret"))
|
||||
token := signer.Token("alice")
|
||||
|
||||
if !signer.Verify("alice", token) {
|
||||
t.Fatal("valid token rejected")
|
||||
}
|
||||
if signer.Verify("bob", token) {
|
||||
t.Fatal("token accepted for a different operator")
|
||||
}
|
||||
if signer.Verify("alice", "") {
|
||||
t.Fatal("empty token accepted")
|
||||
}
|
||||
if signer.Verify("alice", token+"x") {
|
||||
t.Fatal("tampered token accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFKeySeparation(t *testing.T) {
|
||||
a := NewCSRF([]byte("key-a"))
|
||||
b := NewCSRF([]byte("key-b"))
|
||||
if a.Token("operator") == b.Token("operator") {
|
||||
t.Fatal("tokens collide across distinct keys")
|
||||
}
|
||||
if b.Verify("operator", a.Token("operator")) {
|
||||
t.Fatal("token minted under one key verified under another")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomCSRFRoundTrip(t *testing.T) {
|
||||
signer, err := NewRandomCSRF()
|
||||
if err != nil {
|
||||
t.Fatalf("NewRandomCSRF: %v", err)
|
||||
}
|
||||
if !signer.Verify("operator", signer.Token("operator")) {
|
||||
t.Fatal("random-key token failed to round-trip")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package adminconsole
|
||||
|
||||
// StatusCount pairs a status label with its current row count for the
|
||||
// dashboard's per-status tables. It is the view-layer counterpart of the
|
||||
// data gathered by the ops-status reader; the server handler maps between
|
||||
// them so this package stays free of database concerns.
|
||||
type StatusCount struct {
|
||||
Status string
|
||||
Count int64
|
||||
}
|
||||
|
||||
// DashboardData is the view model for the console landing page. MonitorAvailable
|
||||
// is false when no ops-status reader is wired, in which case the monitoring
|
||||
// panels are omitted. Errors carries non-fatal probe failures for display.
|
||||
type DashboardData struct {
|
||||
MonitorAvailable bool
|
||||
BackendReady bool
|
||||
PostgresHealthy bool
|
||||
Runtimes []StatusCount
|
||||
MailDeliveries []StatusCount
|
||||
NotificationRoutes []StatusCount
|
||||
NotificationMalformed int64
|
||||
Errors []string
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Package adminconsole renders the server-side operator console mounted by the
|
||||
// backend under the `/_gm` route group.
|
||||
//
|
||||
// The console is a multi-page, server-rendered surface built on the standard
|
||||
// library's html/template package: navigation is driven by request path and
|
||||
// query, state changes are submitted with HTML forms and answered with a
|
||||
// Post/Redirect/Get redirect. The package owns three concerns and nothing
|
||||
// transport-specific:
|
||||
//
|
||||
// - Renderer composes the shared layout with one content page per route.
|
||||
// - CSRF issues and verifies the stateless anti-CSRF token embedded in every
|
||||
// state-changing form.
|
||||
// - Assets exposes the embedded stylesheet served under `/_gm/assets/`.
|
||||
//
|
||||
// The gin glue (route registration, Basic Auth, the CSRF guard middleware, and
|
||||
// the per-page handlers) lives in package server; this package stays free of
|
||||
// the web framework so it can be unit-tested in isolation.
|
||||
package adminconsole
|
||||
@@ -0,0 +1,67 @@
|
||||
package adminconsole
|
||||
|
||||
// GameRow is one line in the games list table.
|
||||
type GameRow struct {
|
||||
GameID string
|
||||
GameName string
|
||||
Visibility string
|
||||
Status string
|
||||
Owner string
|
||||
Players string
|
||||
TurnSchedule string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// GamesListData is the view model for the paginated games list.
|
||||
type GamesListData struct {
|
||||
Items []GameRow
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
}
|
||||
|
||||
// GameDetailData is the view model for a single game, combining the lobby
|
||||
// record with the runtime snapshot and the available actions.
|
||||
type GameDetailData struct {
|
||||
GameID string
|
||||
GameName string
|
||||
Description string
|
||||
Visibility string
|
||||
Status string
|
||||
Owner string
|
||||
MinPlayers int32
|
||||
MaxPlayers int32
|
||||
StartGapHours int32
|
||||
StartGapPlayers int32
|
||||
TurnSchedule string
|
||||
TargetEngineVersion string
|
||||
EnrollmentEndsAt string
|
||||
CreatedAt string
|
||||
StartedAt string
|
||||
FinishedAt string
|
||||
|
||||
HasRuntime bool
|
||||
RuntimeStatus string
|
||||
CurrentEngineVersion string
|
||||
EngineHealth string
|
||||
CurrentTurn int32
|
||||
NextGenerationAt string
|
||||
Paused bool
|
||||
}
|
||||
|
||||
// EngineVersionRow is one line in the engine-version registry table.
|
||||
type EngineVersionRow struct {
|
||||
Version string
|
||||
ImageRef string
|
||||
Enabled bool
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// EngineVersionsData is the view model for the engine-version registry page.
|
||||
type EngineVersionsData struct {
|
||||
Items []EngineVersionRow
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package adminconsole
|
||||
|
||||
// MailDeliveryRow is one line in the mail deliveries table.
|
||||
type MailDeliveryRow struct {
|
||||
DeliveryID string
|
||||
Template string
|
||||
Status string
|
||||
Attempts int32
|
||||
NextAttempt string
|
||||
Created string
|
||||
}
|
||||
|
||||
// MailDeadLetterRow is one line in the mail dead-letters table.
|
||||
type MailDeadLetterRow struct {
|
||||
DeliveryID string
|
||||
Reason string
|
||||
Archived string
|
||||
}
|
||||
|
||||
// MailData is the view model for the mail page: a paginated deliveries list
|
||||
// plus a snapshot of dead-letters.
|
||||
type MailData struct {
|
||||
Deliveries []MailDeliveryRow
|
||||
DeadLetters []MailDeadLetterRow
|
||||
Page int
|
||||
PageSize int
|
||||
Total int64
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
}
|
||||
|
||||
// MailAttemptRow is one delivery attempt on the mail detail page.
|
||||
type MailAttemptRow struct {
|
||||
AttemptNo int32
|
||||
Outcome string
|
||||
Started string
|
||||
Finished string
|
||||
Error string
|
||||
}
|
||||
|
||||
// MailDeliveryDetail is the view model for a single delivery.
|
||||
type MailDeliveryDetail struct {
|
||||
DeliveryID string
|
||||
Template string
|
||||
Status string
|
||||
Attempts int32
|
||||
NextAttempt string
|
||||
LastError string
|
||||
Created string
|
||||
Sent string
|
||||
DeadLettered string
|
||||
CanResend bool
|
||||
AttemptRows []MailAttemptRow
|
||||
}
|
||||
|
||||
// NotificationRow is one line in the notifications table.
|
||||
type NotificationRow struct {
|
||||
NotificationID string
|
||||
Kind string
|
||||
UserID string
|
||||
Created string
|
||||
}
|
||||
|
||||
// NotificationDeadLetterRow is one line in the notification dead-letters table.
|
||||
type NotificationDeadLetterRow struct {
|
||||
NotificationID string
|
||||
RouteID string
|
||||
Reason string
|
||||
Archived string
|
||||
}
|
||||
|
||||
// MalformedRow is one line in the malformed-intents table.
|
||||
type MalformedRow struct {
|
||||
ID string
|
||||
Reason string
|
||||
Received string
|
||||
}
|
||||
|
||||
// NotificationsData is the view model for the notifications overview page.
|
||||
type NotificationsData struct {
|
||||
Notifications []NotificationRow
|
||||
DeadLetters []NotificationDeadLetterRow
|
||||
Malformed []MalformedRow
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package adminconsole
|
||||
|
||||
// MessageData is the view model for the generic message page used to render
|
||||
// not-found, validation, and operation-failure notices. Class selects the CSS
|
||||
// styling (for example "bad" for errors); BackHref, when set, renders a link
|
||||
// back to a relevant page.
|
||||
type MessageData struct {
|
||||
Message string
|
||||
Class string
|
||||
BackHref string
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package adminconsole
|
||||
|
||||
// OperatorRow is one line in the operators (admin accounts) table.
|
||||
type OperatorRow struct {
|
||||
Username string
|
||||
CreatedAt string
|
||||
LastUsedAt string
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// OperatorsData is the view model for the operators page.
|
||||
type OperatorsData struct {
|
||||
Items []OperatorRow
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package adminconsole
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed templates
|
||||
var templatesFS embed.FS
|
||||
|
||||
//go:embed assets
|
||||
var assetsFS embed.FS
|
||||
|
||||
// Renderer holds the parsed admin console templates. It composes one template
|
||||
// set per content page, each combining the shared layout (defining the page
|
||||
// chrome and the "layout" entry template) with that page's "content" block, so
|
||||
// rendering a page is a single ExecuteTemplate call against the "layout" name.
|
||||
type Renderer struct {
|
||||
pages map[string]*template.Template
|
||||
}
|
||||
|
||||
// PageData is the view model passed to every admin console page. Title is the
|
||||
// document title; Username is the authenticated operator; CSRFToken is the
|
||||
// per-operator token embedded into state-changing forms; ActiveNav marks the
|
||||
// highlighted navigation entry; Data carries the page-specific payload.
|
||||
type PageData struct {
|
||||
Title string
|
||||
Username string
|
||||
CSRFToken string
|
||||
ActiveNav string
|
||||
Data any
|
||||
}
|
||||
|
||||
// NewRenderer parses the embedded layout and every content page under
|
||||
// templates/pages, returning a Renderer ready to serve them. It fails when a
|
||||
// template cannot be parsed.
|
||||
func NewRenderer() (*Renderer, error) {
|
||||
base, err := template.New("layout").ParseFS(templatesFS, "templates/layout.gohtml")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin console layout: %w", err)
|
||||
}
|
||||
|
||||
pageFiles, err := fs.Glob(templatesFS, "templates/pages/*.gohtml")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("enumerate admin console pages: %w", err)
|
||||
}
|
||||
if len(pageFiles) == 0 {
|
||||
return nil, fmt.Errorf("admin console: no page templates found under templates/pages")
|
||||
}
|
||||
|
||||
pages := make(map[string]*template.Template, len(pageFiles))
|
||||
for _, file := range pageFiles {
|
||||
name := strings.TrimSuffix(path.Base(file), ".gohtml")
|
||||
clone, err := base.Clone()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone admin console layout for %q: %w", name, err)
|
||||
}
|
||||
if _, err := clone.ParseFS(templatesFS, file); err != nil {
|
||||
return nil, fmt.Errorf("parse admin console page %q: %w", name, err)
|
||||
}
|
||||
pages[name] = clone
|
||||
}
|
||||
|
||||
return &Renderer{pages: pages}, nil
|
||||
}
|
||||
|
||||
// MustNewRenderer is like NewRenderer but panics on error. The templates are
|
||||
// embedded at build time, so a parse failure is a programmer error rather than
|
||||
// a runtime condition.
|
||||
func MustNewRenderer() *Renderer {
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
// Render writes the named page, wrapped in the shared layout, to w using data.
|
||||
// It returns an error when page is unknown or template execution fails; the
|
||||
// page is rendered into an intermediate buffer first so a mid-render failure
|
||||
// never emits a partial document to w.
|
||||
func (r *Renderer) Render(w io.Writer, page string, data PageData) error {
|
||||
tmpl, ok := r.pages[page]
|
||||
if !ok {
|
||||
return fmt.Errorf("admin console: unknown page %q", page)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
|
||||
return fmt.Errorf("render admin console page %q: %w", page, err)
|
||||
}
|
||||
|
||||
_, err := buf.WriteTo(w)
|
||||
return err
|
||||
}
|
||||
|
||||
// Assets returns the embedded static asset tree rooted at the assets directory,
|
||||
// suitable for serving under `/_gm/assets/`.
|
||||
func Assets() (fs.FS, error) {
|
||||
return fs.Sub(assetsFS, "assets")
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package adminconsole
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRendererRendersDashboard(t *testing.T) {
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewRenderer: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = renderer.Render(&buf, "dashboard", PageData{
|
||||
Title: "Dashboard",
|
||||
Username: "ops-bob",
|
||||
ActiveNav: "dashboard",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"<!DOCTYPE html>",
|
||||
"Dashboard",
|
||||
"ops-bob",
|
||||
`href="/_gm/users"`,
|
||||
"/_gm/assets/console.css",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("rendered page missing %q\n--- page ---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRendererUnknownPage(t *testing.T) {
|
||||
renderer := MustNewRenderer()
|
||||
if err := renderer.Render(&bytes.Buffer{}, "does-not-exist", PageData{}); err == nil {
|
||||
t.Fatal("expected an error rendering an unknown page")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRendererEscapesUsername(t *testing.T) {
|
||||
renderer := MustNewRenderer()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := renderer.Render(&buf, "dashboard", PageData{Username: "<script>evil</script>"}); err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
if strings.Contains(buf.String(), "<script>evil</script>") {
|
||||
t.Error("username was not HTML-escaped in the rendered page")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetsContainsStylesheet(t *testing.T) {
|
||||
fsys, err := Assets()
|
||||
if err != nil {
|
||||
t.Fatalf("Assets: %v", err)
|
||||
}
|
||||
if _, err := fs.Stat(fsys, "console.css"); err != nil {
|
||||
t.Fatalf("console.css missing from embedded assets: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{{define "layout" -}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>{{.Title}} · Galaxy GM</title>
|
||||
<link rel="stylesheet" href="/_gm/assets/console.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<span class="brand">Galaxy · GM</span>
|
||||
<nav class="mainnav">
|
||||
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a>
|
||||
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
|
||||
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
|
||||
<a href="/_gm/operators"{{if eq .ActiveNav "operators"}} class="active"{{end}}>Operators</a>
|
||||
<a href="/_gm/mail"{{if eq .ActiveNav "mail"}} class="active"{{end}}>Mail</a>
|
||||
<a href="/_gm/grafana/" target="_blank" rel="noopener">Grafana</a>
|
||||
<a href="/_gm/mailpit/" target="_blank" rel="noopener">Mailpit</a>
|
||||
</nav>
|
||||
<span class="who">{{.Username}}</span>
|
||||
</header>
|
||||
<main class="content">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{- end}}
|
||||
@@ -0,0 +1,21 @@
|
||||
{{define "content" -}}
|
||||
{{$csrf := .CSRFToken}}
|
||||
<h1>Broadcast</h1>
|
||||
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
|
||||
<section class="panel">
|
||||
<h2>Admin broadcast</h2>
|
||||
<form method="post" action="/_gm/broadcast" class="form">
|
||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||
<label>Scope
|
||||
<select name="scope"><option value="all_running">all running games</option><option value="selected">selected games</option></select>
|
||||
</label>
|
||||
<label>Game IDs (comma-separated, for "selected") <input type="text" name="game_ids" placeholder="uuid,uuid"></label>
|
||||
<label>Recipients
|
||||
<select name="recipients"><option value="active">active members</option><option value="active_and_removed">active and removed</option><option value="all_members">all members</option></select>
|
||||
</label>
|
||||
<label>Subject <input type="text" name="subject"></label>
|
||||
<label>Body <input type="text" name="body" required></label>
|
||||
<button type="submit">Send broadcast</button>
|
||||
</form>
|
||||
</section>
|
||||
{{- end}}
|
||||
@@ -0,0 +1,69 @@
|
||||
{{define "content" -}}
|
||||
<h1>Dashboard</h1>
|
||||
<p class="lede">Signed in as <strong>{{.Username}}</strong>.</p>
|
||||
{{with .Data}}
|
||||
<section class="panel">
|
||||
<h2>Health</h2>
|
||||
<ul class="kv">
|
||||
<li>Backend ready: {{if .BackendReady}}<span class="ok">yes</span>{{else}}<span class="bad">no</span>{{end}}</li>
|
||||
<li>Postgres: {{if .PostgresHealthy}}<span class="ok">healthy</span>{{else}}<span class="bad">unreachable</span>{{end}}</li>
|
||||
</ul>
|
||||
</section>
|
||||
{{if .MonitorAvailable}}
|
||||
<div class="grid">
|
||||
<section class="panel">
|
||||
<h2>Game runtimes</h2>
|
||||
{{template "statuscounts" .Runtimes}}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Mail deliveries</h2>
|
||||
{{template "statuscounts" .MailDeliveries}}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Notification routes</h2>
|
||||
{{template "statuscounts" .NotificationRoutes}}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Malformed notifications</h2>
|
||||
<p class="bignum {{if gt .NotificationMalformed 0}}bad{{end}}">{{.NotificationMalformed}}</p>
|
||||
</section>
|
||||
</div>
|
||||
{{if .Errors}}
|
||||
<section class="panel errors">
|
||||
<h2>Collection errors</h2>
|
||||
<ul>{{range .Errors}}<li>{{.}}</li>{{end}}</ul>
|
||||
</section>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="note">Monitoring is not wired in this deployment.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<section class="cards">
|
||||
<a class="card" href="/_gm/users">
|
||||
<h2>Users</h2>
|
||||
<p>Accounts, sanctions, entitlements, soft-delete.</p>
|
||||
</a>
|
||||
<a class="card" href="/_gm/games">
|
||||
<h2>Games & 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 & notifications</h2>
|
||||
<p>Deliveries, dead-letters, broadcasts.</p>
|
||||
</a>
|
||||
</section>
|
||||
{{- end}}
|
||||
|
||||
{{define "statuscounts" -}}
|
||||
{{if .}}
|
||||
<table class="counts"><tbody>
|
||||
{{range .}}<tr><td>{{.Status}}</td><td class="num">{{.Count}}</td></tr>{{end}}
|
||||
</tbody></table>
|
||||
{{else}}
|
||||
<p class="note">none</p>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,30 @@
|
||||
{{define "content" -}}
|
||||
{{$csrf := .CSRFToken}}
|
||||
<h1>Engine versions</h1>
|
||||
{{with .Data}}
|
||||
<table class="list">
|
||||
<thead><tr><th>Version</th><th>Image</th><th>Enabled</th><th>Created</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td>{{.Version}}</td>
|
||||
<td><code>{{.ImageRef}}</code></td>
|
||||
<td>{{if .Enabled}}<span class="ok">yes</span>{{else}}<span class="bad">no</span>{{end}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
<td>{{if .Enabled}}<form method="post" action="/_gm/engine-versions/{{.Version}}/disable" onsubmit="return confirm('Disable {{.Version}}?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Disable</button></form>{{end}}</td>
|
||||
</tr>
|
||||
{{else}}<tr><td colspan="5"><span class="note">no engine versions</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
<section class="panel">
|
||||
<h2>Register version</h2>
|
||||
<form method="post" action="/_gm/engine-versions" class="form">
|
||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||
<label>Version <input type="text" name="version" placeholder="semver e.g. 0.1.0" required></label>
|
||||
<label>Image ref <input type="text" name="image_ref" required></label>
|
||||
<label>Enabled <input type="checkbox" name="enabled" value="true" checked></label>
|
||||
<button type="submit">Register</button>
|
||||
</form>
|
||||
</section>
|
||||
{{- end}}
|
||||
@@ -0,0 +1,65 @@
|
||||
{{define "content" -}}
|
||||
{{$csrf := .CSRFToken}}
|
||||
{{with .Data}}
|
||||
<p><a href="/_gm/games">« all games</a></p>
|
||||
<h1>{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}</h1>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Game</h2>
|
||||
<ul class="kv">
|
||||
<li>Game ID: <code>{{.GameID}}</code></li>
|
||||
<li>Visibility: {{.Visibility}}</li>
|
||||
<li>Status: {{.Status}}</li>
|
||||
<li>Owner: {{.Owner}}</li>
|
||||
<li>Players: {{.MinPlayers}}–{{.MaxPlayers}}</li>
|
||||
<li>Start gap: {{.StartGapHours}}h / {{.StartGapPlayers}} players</li>
|
||||
<li>Turn schedule: {{.TurnSchedule}}</li>
|
||||
<li>Target engine: {{.TargetEngineVersion}}</li>
|
||||
<li>Enrollment ends: {{.EnrollmentEndsAt}}</li>
|
||||
<li>Created: {{.CreatedAt}}</li>
|
||||
<li>Started: {{if .StartedAt}}{{.StartedAt}}{{else}}—{{end}}</li>
|
||||
<li>Finished: {{if .FinishedAt}}{{.FinishedAt}}{{else}}—{{end}}</li>
|
||||
</ul>
|
||||
{{if .Description}}<p>{{.Description}}</p>{{end}}
|
||||
<div class="actions">
|
||||
<form method="post" action="/_gm/games/{{.GameID}}/force-start" onsubmit="return confirm('Force-start this game?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Force start</button></form>
|
||||
<form method="post" action="/_gm/games/{{.GameID}}/force-stop" onsubmit="return confirm('Force-stop this game?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Force stop</button></form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Runtime</h2>
|
||||
{{if .HasRuntime}}
|
||||
<ul class="kv">
|
||||
<li>Status: {{.RuntimeStatus}}</li>
|
||||
<li>Engine version: {{.CurrentEngineVersion}}</li>
|
||||
<li>Engine health: {{.EngineHealth}}</li>
|
||||
<li>Current turn: {{.CurrentTurn}}</li>
|
||||
<li>Next generation: {{if .NextGenerationAt}}{{.NextGenerationAt}}{{else}}—{{end}}</li>
|
||||
<li>Paused: {{if .Paused}}yes{{else}}no{{end}}</li>
|
||||
</ul>
|
||||
<div class="actions">
|
||||
<form method="post" action="/_gm/games/{{.GameID}}/runtime/restart" onsubmit="return confirm('Restart the engine container?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Restart</button></form>
|
||||
<form method="post" action="/_gm/games/{{.GameID}}/runtime/force-next-turn" onsubmit="return confirm('Force the next turn now?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Force next turn</button></form>
|
||||
</div>
|
||||
<form method="post" action="/_gm/games/{{.GameID}}/runtime/patch" class="form">
|
||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||
<label>Patch to version <input type="text" name="target_version" placeholder="e.g. 0.1.1" required></label>
|
||||
<button type="submit">Patch</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="note">No runtime record for this game yet.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Ban member</h2>
|
||||
<form method="post" action="/_gm/games/{{.GameID}}/ban-member" class="form" onsubmit="return confirm('Ban this member from the game?');">
|
||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||
<label>User ID <input type="text" name="user_id" required></label>
|
||||
<label>Reason <input type="text" name="reason"></label>
|
||||
<button type="submit" class="danger">Ban member</button>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,43 @@
|
||||
{{define "content" -}}
|
||||
{{$csrf := .CSRFToken}}
|
||||
<h1>Games</h1>
|
||||
{{with .Data}}
|
||||
<table class="list">
|
||||
<thead><tr><th>Name</th><th>Visibility</th><th>Status</th><th>Owner</th><th>Players</th><th>Schedule</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td><a href="/_gm/games/{{.GameID}}">{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}</a></td>
|
||||
<td>{{.Visibility}}</td>
|
||||
<td>{{.Status}}</td>
|
||||
<td>{{.Owner}}</td>
|
||||
<td>{{.Players}}</td>
|
||||
<td>{{.TurnSchedule}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
</tr>
|
||||
{{else}}<tr><td colspan="7"><span class="note">no games</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .HasPrev}}<a href="/_gm/games?page={{.PrevPage}}&page_size={{.PageSize}}">« prev</a>{{end}}
|
||||
<span>page {{.Page}} · {{.Total}} total</span>
|
||||
{{if .HasNext}}<a href="/_gm/games?page={{.NextPage}}&page_size={{.PageSize}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
<section class="panel">
|
||||
<h2>Create public game</h2>
|
||||
<form method="post" action="/_gm/games" class="form">
|
||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||
<label>Name <input type="text" name="game_name" required></label>
|
||||
<label>Description <input type="text" name="description"></label>
|
||||
<label>Min players <input type="number" name="min_players" value="2" min="1"></label>
|
||||
<label>Max players <input type="number" name="max_players" value="8" min="1"></label>
|
||||
<label>Start gap hours <input type="number" name="start_gap_hours" value="0" min="0"></label>
|
||||
<label>Start gap players <input type="number" name="start_gap_players" value="0" min="0"></label>
|
||||
<label>Enrollment ends <input type="datetime-local" name="enrollment_ends_at" required></label>
|
||||
<label>Turn schedule <input type="text" name="turn_schedule" placeholder="e.g. @every 24h" required></label>
|
||||
<label>Engine version <input type="text" name="target_engine_version" placeholder="e.g. 0.1.0" required></label>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
</section>
|
||||
{{- end}}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{define "content" -}}
|
||||
<h1>Mail</h1>
|
||||
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
|
||||
{{with .Data}}
|
||||
<section class="panel">
|
||||
<h2>Deliveries</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Delivery</th><th>Template</th><th>Status</th><th>Attempts</th><th>Next attempt</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Deliveries}}
|
||||
<tr><td><a href="/_gm/mail/deliveries/{{.DeliveryID}}"><code>{{.DeliveryID}}</code></a></td><td>{{.Template}}</td><td>{{.Status}}</td><td>{{.Attempts}}</td><td>{{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}</td><td>{{.Created}}</td></tr>
|
||||
{{else}}<tr><td colspan="6"><span class="note">no deliveries</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .HasPrev}}<a href="/_gm/mail?page={{.PrevPage}}&page_size={{.PageSize}}">« prev</a>{{end}}
|
||||
<span>page {{.Page}} · {{.Total}} total</span>
|
||||
{{if .HasNext}}<a href="/_gm/mail?page={{.NextPage}}&page_size={{.PageSize}}">next »</a>{{end}}
|
||||
</nav>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Dead-letters</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Delivery</th><th>Reason</th><th>Archived</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .DeadLetters}}<tr><td><a href="/_gm/mail/deliveries/{{.DeliveryID}}"><code>{{.DeliveryID}}</code></a></td><td>{{.Reason}}</td><td>{{.Archived}}</td></tr>
|
||||
{{else}}<tr><td colspan="3"><span class="note">no dead-letters</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{define "content" -}}
|
||||
{{$csrf := .CSRFToken}}
|
||||
{{with .Data}}
|
||||
<p><a href="/_gm/mail">« mail</a></p>
|
||||
<h1>Delivery</h1>
|
||||
<section class="panel">
|
||||
<ul class="kv">
|
||||
<li>Delivery ID: <code>{{.DeliveryID}}</code></li>
|
||||
<li>Template: {{.Template}}</li>
|
||||
<li>Status: {{.Status}}</li>
|
||||
<li>Attempts: {{.Attempts}}</li>
|
||||
<li>Next attempt: {{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}</li>
|
||||
<li>Created: {{.Created}}</li>
|
||||
<li>Sent: {{if .Sent}}{{.Sent}}{{else}}—{{end}}</li>
|
||||
<li>Dead-lettered: {{if .DeadLettered}}{{.DeadLettered}}{{else}}—{{end}}</li>
|
||||
<li>Last error: {{if .LastError}}{{.LastError}}{{else}}—{{end}}</li>
|
||||
</ul>
|
||||
{{if .CanResend}}
|
||||
<form method="post" action="/_gm/mail/deliveries/{{.DeliveryID}}/resend" class="form" onsubmit="return confirm('Resend this delivery?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Resend</button></form>
|
||||
{{else}}<p class="note">Already sent — resend is not available.</p>{{end}}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Attempts</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>#</th><th>Outcome</th><th>Started</th><th>Finished</th><th>Error</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .AttemptRows}}<tr><td>{{.AttemptNo}}</td><td>{{.Outcome}}</td><td>{{.Started}}</td><td>{{if .Finished}}{{.Finished}}{{else}}—{{end}}</td><td>{{.Error}}</td></tr>
|
||||
{{else}}<tr><td colspan="5"><span class="note">no attempts</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,7 @@
|
||||
{{define "content" -}}
|
||||
<h1>{{.Title}}</h1>
|
||||
{{with .Data}}
|
||||
<p class="{{.Class}}">{{.Message}}</p>
|
||||
{{if .BackHref}}<p><a href="{{.BackHref}}">« back</a></p>{{end}}
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,27 @@
|
||||
{{define "content" -}}
|
||||
<h1>Notifications</h1>
|
||||
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
|
||||
{{with .Data}}
|
||||
<section class="panel">
|
||||
<h2>Recent notifications</h2>
|
||||
<table class="list"><thead><tr><th>ID</th><th>Kind</th><th>User</th><th>Created</th></tr></thead><tbody>
|
||||
{{range .Notifications}}<tr><td><code>{{.NotificationID}}</code></td><td>{{.Kind}}</td><td>{{.UserID}}</td><td>{{.Created}}</td></tr>
|
||||
{{else}}<tr><td colspan="4"><span class="note">none</span></td></tr>{{end}}
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Dead-letters</h2>
|
||||
<table class="list"><thead><tr><th>Notification</th><th>Route</th><th>Reason</th><th>Archived</th></tr></thead><tbody>
|
||||
{{range .DeadLetters}}<tr><td><code>{{.NotificationID}}</code></td><td><code>{{.RouteID}}</code></td><td>{{.Reason}}</td><td>{{.Archived}}</td></tr>
|
||||
{{else}}<tr><td colspan="4"><span class="note">none</span></td></tr>{{end}}
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Malformed intents</h2>
|
||||
<table class="list"><thead><tr><th>ID</th><th>Reason</th><th>Received</th></tr></thead><tbody>
|
||||
{{range .Malformed}}<tr><td><code>{{.ID}}</code></td><td>{{.Reason}}</td><td>{{.Received}}</td></tr>
|
||||
{{else}}<tr><td colspan="3"><span class="note">none</span></td></tr>{{end}}
|
||||
</tbody></table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,38 @@
|
||||
{{define "content" -}}
|
||||
{{$csrf := .CSRFToken}}
|
||||
<h1>Operators</h1>
|
||||
{{with .Data}}
|
||||
<table class="list">
|
||||
<thead><tr><th>Username</th><th>Status</th><th>Created</th><th>Last used</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td>{{.Username}}</td>
|
||||
<td>{{if .Disabled}}<span class="bad">disabled</span>{{else}}<span class="ok">active</span>{{end}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
<td>{{if .LastUsedAt}}{{.LastUsedAt}}{{else}}—{{end}}</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
{{if .Disabled}}
|
||||
<form method="post" action="/_gm/operators/{{.Username}}/enable"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Enable</button></form>
|
||||
{{else}}
|
||||
<form method="post" action="/_gm/operators/{{.Username}}/disable" onsubmit="return confirm('Disable {{.Username}}?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Disable</button></form>
|
||||
{{end}}
|
||||
<form method="post" action="/_gm/operators/{{.Username}}/reset-password" class="form"><input type="hidden" name="_csrf" value="{{$csrf}}"><input type="password" name="password" placeholder="new password" required><button type="submit">Reset</button></form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}<tr><td colspan="5"><span class="note">no operators</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
<section class="panel">
|
||||
<h2>Create operator</h2>
|
||||
<form method="post" action="/_gm/operators" class="form">
|
||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||
<label>Username <input type="text" name="username" required></label>
|
||||
<label>Password <input type="password" name="password" required></label>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
</section>
|
||||
{{- end}}
|
||||
@@ -0,0 +1,68 @@
|
||||
{{define "content" -}}
|
||||
{{$csrf := .CSRFToken}}
|
||||
{{with .Data}}
|
||||
<p><a href="/_gm/users">« all users</a></p>
|
||||
<h1>{{.Email}}</h1>
|
||||
{{if .Deleted}}<p class="bad">This account is soft-deleted.</p>{{end}}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Account</h2>
|
||||
<ul class="kv">
|
||||
<li>User ID: <code>{{.UserID}}</code></li>
|
||||
<li>User name: {{.UserName}}</li>
|
||||
<li>Display name: {{.DisplayName}}</li>
|
||||
<li>Preferred language: {{.PreferredLanguage}}</li>
|
||||
<li>Time zone: {{.TimeZone}}</li>
|
||||
<li>Declared country: {{.DeclaredCountry}}</li>
|
||||
<li>Status: {{if .Blocked}}<span class="bad">blocked</span>{{else}}<span class="ok">active</span>{{end}}</li>
|
||||
<li>Created: {{.CreatedAt}}</li>
|
||||
<li>Updated: {{.UpdatedAt}}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Entitlement</h2>
|
||||
<ul class="kv">
|
||||
<li>Tier: <strong>{{.Tier}}</strong> ({{if .IsPaid}}paid{{else}}free{{end}})</li>
|
||||
<li>Source: {{.EntitlementSource}}</li>
|
||||
<li>Reason: {{.EntitlementReason}}</li>
|
||||
<li>Ends: {{if .EntitlementEnds}}{{.EntitlementEnds}}{{else}}—{{end}}</li>
|
||||
</ul>
|
||||
<form method="post" action="/_gm/users/{{.UserID}}/entitlement" class="form">
|
||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||
<label>Tier
|
||||
<select name="tier">{{range .Tiers}}<option value="{{.}}">{{.}}</option>{{end}}</select>
|
||||
</label>
|
||||
<label>Source <input type="text" name="source" value="admin"></label>
|
||||
<label>Reason <input type="text" name="reason_code" placeholder="optional"></label>
|
||||
<button type="submit">Update entitlement</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Active sanctions</h2>
|
||||
{{if .Sanctions}}
|
||||
<table class="counts"><tbody>
|
||||
{{range .Sanctions}}<tr><td>{{.SanctionCode}}</td><td>{{.Scope}}</td><td>{{.ReasonCode}}</td><td>{{.AppliedAt}}</td></tr>{{end}}
|
||||
</tbody></table>
|
||||
{{else}}<p class="note">none</p>{{end}}
|
||||
{{if .Blocked}}
|
||||
<p class="note">User is permanently blocked. Unblock is not available in the current admin API.</p>
|
||||
{{else}}
|
||||
<form method="post" action="/_gm/users/{{.UserID}}/block" class="form" onsubmit="return confirm('Permanently block this user?');">
|
||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||
<label>Reason <input type="text" name="reason_code" required></label>
|
||||
<button type="submit" class="danger">Permanently block</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Danger zone</h2>
|
||||
<form method="post" action="/_gm/users/{{.UserID}}/soft-delete" class="form" onsubmit="return confirm('Soft-delete this account? This cascades to sessions, memberships, and owned games.');">
|
||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||
<button type="submit" class="danger">Soft-delete account</button>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,27 @@
|
||||
{{define "content" -}}
|
||||
<h1>Users</h1>
|
||||
{{with .Data}}
|
||||
<table class="list">
|
||||
<thead><tr><th>Email</th><th>User name</th><th>Display</th><th>Tier</th><th>Status</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td><a href="/_gm/users/{{.UserID}}">{{.Email}}</a></td>
|
||||
<td>{{.UserName}}</td>
|
||||
<td>{{.DisplayName}}</td>
|
||||
<td>{{.Tier}}</td>
|
||||
<td>{{if .Deleted}}<span class="bad">deleted</span>{{else if .Blocked}}<span class="bad">blocked</span>{{else}}<span class="ok">active</span>{{end}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="6"><span class="note">no users</span></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .HasPrev}}<a href="/_gm/users?page={{.PrevPage}}&page_size={{.PageSize}}">« prev</a>{{end}}
|
||||
<span>page {{.Page}} · {{.Total}} total</span>
|
||||
{{if .HasNext}}<a href="/_gm/users?page={{.NextPage}}&page_size={{.PageSize}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,61 @@
|
||||
package adminconsole
|
||||
|
||||
// UserRow is one line in the users list table.
|
||||
type UserRow struct {
|
||||
UserID string
|
||||
Email string
|
||||
UserName string
|
||||
DisplayName string
|
||||
Tier string
|
||||
Blocked bool
|
||||
Deleted bool
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// UsersListData is the view model for the paginated users list.
|
||||
type UsersListData struct {
|
||||
Items []UserRow
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
}
|
||||
|
||||
// SanctionView is one active sanction shown on the user detail page.
|
||||
type SanctionView struct {
|
||||
SanctionCode string
|
||||
Scope string
|
||||
ReasonCode string
|
||||
AppliedAt string
|
||||
ExpiresAt string
|
||||
}
|
||||
|
||||
// UserDetailData is the view model for a single user's detail page,
|
||||
// combining the account aggregate with the form option lists.
|
||||
type UserDetailData struct {
|
||||
UserID string
|
||||
Email string
|
||||
UserName string
|
||||
DisplayName string
|
||||
PreferredLanguage string
|
||||
TimeZone string
|
||||
DeclaredCountry string
|
||||
Blocked bool
|
||||
Deleted bool
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
|
||||
Tier string
|
||||
IsPaid bool
|
||||
EntitlementSource string
|
||||
EntitlementReason string
|
||||
EntitlementEnds string
|
||||
|
||||
Sanctions []SanctionView
|
||||
|
||||
// Tiers lists the selectable entitlement tiers for the form.
|
||||
Tiers []string
|
||||
}
|
||||
@@ -55,6 +55,8 @@ const (
|
||||
envAdminBootstrapUser = "BACKEND_ADMIN_BOOTSTRAP_USER"
|
||||
envAdminBootstrapPassword = "BACKEND_ADMIN_BOOTSTRAP_PASSWORD"
|
||||
|
||||
envAdminConsoleCSRFKey = "BACKEND_ADMIN_CONSOLE_CSRF_KEY"
|
||||
|
||||
envGeoIPDBPath = "BACKEND_GEOIP_DB_PATH"
|
||||
|
||||
envOTelTracesExporter = "BACKEND_OTEL_TRACES_EXPORTER"
|
||||
@@ -103,11 +105,6 @@ const (
|
||||
envDiplomailTranslatorTimeout = "BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT"
|
||||
envDiplomailTranslatorMaxAttempts = "BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS"
|
||||
envDiplomailWorkerInterval = "BACKEND_DIPLOMAIL_WORKER_INTERVAL"
|
||||
|
||||
envDevSandboxEmail = "BACKEND_DEV_SANDBOX_EMAIL"
|
||||
envDevSandboxEngineImage = "BACKEND_DEV_SANDBOX_ENGINE_IMAGE"
|
||||
envDevSandboxEngineVersion = "BACKEND_DEV_SANDBOX_ENGINE_VERSION"
|
||||
envDevSandboxPlayerCount = "BACKEND_DEV_SANDBOX_PLAYER_COUNT"
|
||||
)
|
||||
|
||||
// Default values applied when an environment variable is absent.
|
||||
@@ -176,9 +173,6 @@ const (
|
||||
defaultDiplomailTranslatorTimeout = 10 * time.Second
|
||||
defaultDiplomailTranslatorMaxAttempts = 5
|
||||
defaultDiplomailWorkerInterval = 2 * time.Second
|
||||
|
||||
defaultDevSandboxEngineVersion = "0.1.0"
|
||||
defaultDevSandboxPlayerCount = 20
|
||||
)
|
||||
|
||||
// Allowed values for the closed-set string options.
|
||||
@@ -208,6 +202,7 @@ type Config struct {
|
||||
Docker DockerConfig
|
||||
Game GameConfig
|
||||
Admin AdminBootstrapConfig
|
||||
AdminConsole AdminConsoleConfig
|
||||
GeoIP GeoIPConfig
|
||||
Telemetry TelemetryConfig
|
||||
Auth AuthConfig
|
||||
@@ -216,29 +211,12 @@ type Config struct {
|
||||
Runtime RuntimeConfig
|
||||
Notification NotificationConfig
|
||||
Diplomail DiplomailConfig
|
||||
DevSandbox DevSandboxConfig
|
||||
|
||||
// FreshnessWindow mirrors the gateway freshness window and is used by the
|
||||
// push server to bound the cursor TTL.
|
||||
FreshnessWindow time.Duration
|
||||
}
|
||||
|
||||
// DevSandboxConfig configures the boot-time bootstrap implemented in
|
||||
// `backend/internal/devsandbox`. When Email is empty the bootstrap
|
||||
// is a no-op, which is the production posture. When Email is set —
|
||||
// from `BACKEND_DEV_SANDBOX_EMAIL` in the `tools/local-dev` stack —
|
||||
// the bootstrap idempotently provisions a real user, the configured
|
||||
// number of dummy participants, a private "Dev Sandbox" game, the
|
||||
// matching memberships, and drives the lifecycle to `running`. The
|
||||
// engine image and engine version refer to a row that the bootstrap
|
||||
// also seeds in `engine_versions`.
|
||||
type DevSandboxConfig struct {
|
||||
Email string
|
||||
EngineImage string
|
||||
EngineVersion string
|
||||
PlayerCount int
|
||||
}
|
||||
|
||||
// LoggingConfig stores the parameters used by the structured logger.
|
||||
type LoggingConfig struct {
|
||||
// Level is the zap level name (e.g. "debug", "info", "warn", "error").
|
||||
@@ -308,6 +286,15 @@ type AdminBootstrapConfig struct {
|
||||
Password string
|
||||
}
|
||||
|
||||
// AdminConsoleConfig configures the server-rendered operator console.
|
||||
// CSRFKey is the secret keying the console's stateless anti-CSRF token.
|
||||
// When empty the console falls back to a per-process random key, which is
|
||||
// secure but means forms do not survive a restart and do not validate across
|
||||
// replicas; set a shared key when running more than one backend instance.
|
||||
type AdminConsoleConfig struct {
|
||||
CSRFKey string
|
||||
}
|
||||
|
||||
// GeoIPConfig configures the GeoLite2 country database used by geo lookups.
|
||||
type GeoIPConfig struct {
|
||||
DBPath string
|
||||
@@ -560,10 +547,6 @@ func DefaultConfig() Config {
|
||||
TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts,
|
||||
WorkerInterval: defaultDiplomailWorkerInterval,
|
||||
},
|
||||
DevSandbox: DevSandboxConfig{
|
||||
EngineVersion: defaultDevSandboxEngineVersion,
|
||||
PlayerCount: defaultDevSandboxPlayerCount,
|
||||
},
|
||||
Runtime: RuntimeConfig{
|
||||
WorkerPoolSize: defaultRuntimeWorkerPoolSize,
|
||||
JobQueueSize: defaultRuntimeJobQueueSize,
|
||||
@@ -644,6 +627,8 @@ func LoadFromEnv() (Config, error) {
|
||||
cfg.Admin.User = loadString(envAdminBootstrapUser, cfg.Admin.User)
|
||||
cfg.Admin.Password = loadString(envAdminBootstrapPassword, cfg.Admin.Password)
|
||||
|
||||
cfg.AdminConsole.CSRFKey = loadString(envAdminConsoleCSRFKey, cfg.AdminConsole.CSRFKey)
|
||||
|
||||
cfg.GeoIP.DBPath = loadString(envGeoIPDBPath, cfg.GeoIP.DBPath)
|
||||
|
||||
cfg.Telemetry.TracesExporter = strings.ToLower(loadString(envOTelTracesExporter, cfg.Telemetry.TracesExporter))
|
||||
@@ -741,13 +726,6 @@ func LoadFromEnv() (Config, error) {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.DevSandbox.Email = strings.TrimSpace(loadString(envDevSandboxEmail, cfg.DevSandbox.Email))
|
||||
cfg.DevSandbox.EngineImage = strings.TrimSpace(loadString(envDevSandboxEngineImage, cfg.DevSandbox.EngineImage))
|
||||
cfg.DevSandbox.EngineVersion = strings.TrimSpace(loadString(envDevSandboxEngineVersion, cfg.DevSandbox.EngineVersion))
|
||||
if cfg.DevSandbox.PlayerCount, err = loadInt(envDevSandboxPlayerCount, cfg.DevSandbox.PlayerCount); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
@@ -959,21 +937,6 @@ func (c Config) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if email := strings.TrimSpace(c.DevSandbox.Email); email != "" {
|
||||
if _, err := netmail.ParseAddress(email); err != nil {
|
||||
return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envDevSandboxEmail, err)
|
||||
}
|
||||
if strings.TrimSpace(c.DevSandbox.EngineImage) == "" {
|
||||
return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineImage, envDevSandboxEmail)
|
||||
}
|
||||
if strings.TrimSpace(c.DevSandbox.EngineVersion) == "" {
|
||||
return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineVersion, envDevSandboxEmail)
|
||||
}
|
||||
if c.DevSandbox.PlayerCount <= 0 {
|
||||
return fmt.Errorf("%s must be positive when %s is set", envDevSandboxPlayerCount, envDevSandboxEmail)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
// Package devsandbox provisions a ready-to-play game on backend boot
|
||||
// for the `tools/local-dev` stack.
|
||||
//
|
||||
// Bootstrap is invoked from `backend/cmd/backend/main.go` after the
|
||||
// admin bootstrap and before the HTTP listener starts. It reads
|
||||
// `cfg.DevSandbox`; when `Email` is empty (the production posture)
|
||||
// the function logs "skipped" and returns nil. When set, it
|
||||
// idempotently:
|
||||
//
|
||||
// 1. registers the configured engine version and image;
|
||||
// 2. find-or-creates the real dev user with the configured email;
|
||||
// 3. find-or-creates `cfg.PlayerCount - 1` deterministic dummy
|
||||
// users so the engine's minimum-players constraint is met;
|
||||
// 4. find-or-creates a private "Dev Sandbox" game owned by the
|
||||
// real user with min/max_players = cfg.PlayerCount and a
|
||||
// year-out turn schedule (effectively frozen at turn 1);
|
||||
// 5. inserts memberships for all participants bypassing the
|
||||
// application/approval flow;
|
||||
// 6. drives the lifecycle to `running` (or as far as possible if
|
||||
// the runtime is busy).
|
||||
//
|
||||
// The function is a no-op on subsequent boots once the game is
|
||||
// running; partial states from earlier crashes are recovered.
|
||||
package devsandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/config"
|
||||
"galaxy/backend/internal/lobby"
|
||||
"galaxy/backend/internal/runtime"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// SandboxGameName is the display name used to identify the
|
||||
// auto-provisioned game on subsequent reboots. The combination of
|
||||
// game_name and owner_user_id is unique enough in practice — only
|
||||
// the dev sandbox bootstrap creates a game owned by the configured
|
||||
// real user with this exact name.
|
||||
const SandboxGameName = "Dev Sandbox"
|
||||
|
||||
// SandboxTurnSchedule keeps the game on turn 1 by scheduling the
|
||||
// next turn a year out. The runtime scheduler still parses this and
|
||||
// will tick once a year — long enough to never interfere with
|
||||
// solo UI development.
|
||||
const SandboxTurnSchedule = "0 0 1 1 *"
|
||||
|
||||
// UserEnsurer matches `auth.UserEnsurer`. We define a local
|
||||
// interface to avoid importing the auth package and circular
|
||||
// dependencies — the production wiring passes the same `*user.Service`
|
||||
// instance used by auth.
|
||||
type UserEnsurer interface {
|
||||
EnsureByEmail(ctx context.Context, email, preferredLanguage, timeZone, declaredCountry string) (uuid.UUID, error)
|
||||
}
|
||||
|
||||
// Deps aggregates the collaborators Bootstrap needs.
|
||||
type Deps struct {
|
||||
Users UserEnsurer
|
||||
Lobby *lobby.Service
|
||||
EngineVersions *runtime.EngineVersionService
|
||||
}
|
||||
|
||||
// Bootstrap runs the seven-step provisioning flow described on the
|
||||
// package doc comment. Errors are returned to the caller; the boot
|
||||
// path in `cmd/backend/main.go` aborts startup if Bootstrap fails so
|
||||
// a misconfigured dev environment surfaces immediately rather than
|
||||
// silently leaving the lobby empty.
|
||||
func Bootstrap(ctx context.Context, deps Deps, cfg config.DevSandboxConfig, logger *zap.Logger) error {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
logger = logger.Named("dev_sandbox")
|
||||
|
||||
if cfg.Email == "" {
|
||||
logger.Info("skipped (no email)")
|
||||
return nil
|
||||
}
|
||||
if deps.Users == nil || deps.Lobby == nil || deps.EngineVersions == nil {
|
||||
return errors.New("dev_sandbox: deps.Users, deps.Lobby and deps.EngineVersions are required")
|
||||
}
|
||||
if cfg.PlayerCount <= 0 {
|
||||
return fmt.Errorf("dev_sandbox: PlayerCount must be positive, got %d", cfg.PlayerCount)
|
||||
}
|
||||
|
||||
if err := ensureEngineVersion(ctx, deps.EngineVersions, cfg, logger); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
realID, err := deps.Users.EnsureByEmail(ctx, cfg.Email, "en", "UTC", "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("dev_sandbox: ensure real user: %w", err)
|
||||
}
|
||||
|
||||
dummyIDs := make([]uuid.UUID, 0, cfg.PlayerCount-1)
|
||||
for i := 1; i < cfg.PlayerCount; i++ {
|
||||
email := fmt.Sprintf("dev-dummy-%02d@local.test", i)
|
||||
id, err := deps.Users.EnsureByEmail(ctx, email, "en", "UTC", "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("dev_sandbox: ensure dummy %d: %w", i, err)
|
||||
}
|
||||
dummyIDs = append(dummyIDs, id)
|
||||
}
|
||||
|
||||
if err := purgeTerminalSandboxGames(ctx, deps.Lobby, realID, logger); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
game, err := findOrCreateSandboxGame(ctx, deps.Lobby, realID, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
game, err = ensureMembershipsAndDrive(ctx, deps.Lobby, game, realID, dummyIDs, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info("bootstrap complete",
|
||||
zap.String("user_id", realID.String()),
|
||||
zap.String("game_id", game.GameID.String()),
|
||||
zap.String("status", game.Status),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureEngineVersion(ctx context.Context, svc *runtime.EngineVersionService, cfg config.DevSandboxConfig, logger *zap.Logger) error {
|
||||
_, err := svc.Register(ctx, runtime.RegisterInput{
|
||||
Version: cfg.EngineVersion,
|
||||
ImageRef: cfg.EngineImage,
|
||||
})
|
||||
switch {
|
||||
case err == nil:
|
||||
logger.Info("engine version registered",
|
||||
zap.String("version", cfg.EngineVersion),
|
||||
zap.String("image", cfg.EngineImage),
|
||||
)
|
||||
return nil
|
||||
case errors.Is(err, runtime.ErrEngineVersionTaken):
|
||||
logger.Debug("engine version already registered",
|
||||
zap.String("version", cfg.EngineVersion),
|
||||
)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("dev_sandbox: register engine version: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// terminalSandboxStatus reports whether a sandbox game has reached a
|
||||
// state from which it can no longer be driven back to running. We
|
||||
// treat such games as "absent" so the next bootstrap creates a fresh
|
||||
// one rather than handing the developer a dead lobby tile.
|
||||
func terminalSandboxStatus(status string) bool {
|
||||
switch status {
|
||||
case lobby.GameStatusCancelled, lobby.GameStatusFinished, lobby.GameStatusStartFailed:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// purgeTerminalSandboxGames deletes every previous "Dev Sandbox" game
|
||||
// the dev user owns that has reached a terminal state
|
||||
// (cancelled / finished / start_failed). The cascade declared in
|
||||
// `00001_init.sql` removes the matching memberships, applications,
|
||||
// invites, runtime records, and player mappings in the same write,
|
||||
// so the developer's lobby never piles up dead tiles between
|
||||
// `make rebuild` cycles. Non-terminal games are left untouched —
|
||||
// a `running` sandbox from a previous boot is the happy path.
|
||||
func purgeTerminalSandboxGames(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, logger *zap.Logger) error {
|
||||
games, err := svc.ListMyGames(ctx, ownerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dev_sandbox: list my games: %w", err)
|
||||
}
|
||||
for _, g := range games {
|
||||
if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID {
|
||||
continue
|
||||
}
|
||||
if !terminalSandboxStatus(g.Status) {
|
||||
continue
|
||||
}
|
||||
if err := svc.DeleteGame(ctx, g.GameID); err != nil {
|
||||
return fmt.Errorf("dev_sandbox: delete terminal sandbox %s: %w", g.GameID, err)
|
||||
}
|
||||
logger.Info("purged terminal sandbox game",
|
||||
zap.String("game_id", g.GameID.String()),
|
||||
zap.String("status", g.Status),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findOrCreateSandboxGame(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, cfg config.DevSandboxConfig) (lobby.GameRecord, error) {
|
||||
games, err := svc.ListMyGames(ctx, ownerID)
|
||||
if err != nil {
|
||||
return lobby.GameRecord{}, fmt.Errorf("dev_sandbox: list my games: %w", err)
|
||||
}
|
||||
for _, g := range games {
|
||||
if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID {
|
||||
continue
|
||||
}
|
||||
// `purgeTerminalSandboxGames` ran before us, so any sandbox
|
||||
// game still in the list is either a live one we should
|
||||
// reuse or a transient state we can drive forward.
|
||||
return g, nil
|
||||
}
|
||||
rec, err := svc.CreateGame(ctx, lobby.CreateGameInput{
|
||||
OwnerUserID: &ownerID,
|
||||
Visibility: lobby.VisibilityPrivate,
|
||||
GameName: SandboxGameName,
|
||||
Description: "Auto-provisioned by backend/internal/devsandbox for solo UI development.",
|
||||
MinPlayers: int32(cfg.PlayerCount),
|
||||
MaxPlayers: int32(cfg.PlayerCount),
|
||||
StartGapHours: 0,
|
||||
StartGapPlayers: 0,
|
||||
EnrollmentEndsAt: time.Now().Add(365 * 24 * time.Hour),
|
||||
TurnSchedule: SandboxTurnSchedule,
|
||||
TargetEngineVersion: cfg.EngineVersion,
|
||||
})
|
||||
if err != nil {
|
||||
return lobby.GameRecord{}, fmt.Errorf("dev_sandbox: create game: %w", err)
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func ensureMembershipsAndDrive(ctx context.Context, svc *lobby.Service, game lobby.GameRecord, realID uuid.UUID, dummyIDs []uuid.UUID, logger *zap.Logger) (lobby.GameRecord, error) {
|
||||
caller := realID
|
||||
if game.Status == lobby.GameStatusDraft {
|
||||
next, err := svc.OpenEnrollment(ctx, &caller, false, game.GameID)
|
||||
if err != nil {
|
||||
return game, fmt.Errorf("dev_sandbox: open enrollment: %w", err)
|
||||
}
|
||||
game = next
|
||||
}
|
||||
|
||||
if game.Status == lobby.GameStatusEnrollmentOpen {
|
||||
users := append([]uuid.UUID{realID}, dummyIDs...)
|
||||
for i, uid := range users {
|
||||
raceName := fmt.Sprintf("Sandbox-%02d", i+1)
|
||||
if _, err := svc.InsertMembershipDirect(ctx, lobby.InsertMembershipDirectInput{
|
||||
GameID: game.GameID,
|
||||
UserID: uid,
|
||||
RaceName: raceName,
|
||||
}); err != nil {
|
||||
return game, fmt.Errorf("dev_sandbox: insert membership %d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
logger.Info("memberships ensured",
|
||||
zap.Int("count", len(users)),
|
||||
zap.String("game_id", game.GameID.String()),
|
||||
)
|
||||
next, err := svc.ReadyToStart(ctx, &caller, false, game.GameID)
|
||||
if err != nil {
|
||||
return game, fmt.Errorf("dev_sandbox: ready to start: %w", err)
|
||||
}
|
||||
game = next
|
||||
}
|
||||
|
||||
if game.Status == lobby.GameStatusReadyToStart {
|
||||
next, err := svc.Start(ctx, &caller, false, game.GameID)
|
||||
if err != nil {
|
||||
return game, fmt.Errorf("dev_sandbox: start: %w", err)
|
||||
}
|
||||
game = next
|
||||
}
|
||||
|
||||
if game.Status == lobby.GameStatusStartFailed {
|
||||
next, err := svc.RetryStart(ctx, &caller, false, game.GameID)
|
||||
if err != nil {
|
||||
logger.Warn("retry start failed", zap.Error(err))
|
||||
return game, nil
|
||||
}
|
||||
game = next
|
||||
if game.Status == lobby.GameStatusReadyToStart {
|
||||
next, err := svc.Start(ctx, &caller, false, game.GameID)
|
||||
if err != nil {
|
||||
return game, fmt.Errorf("dev_sandbox: start after retry: %w", err)
|
||||
}
|
||||
game = next
|
||||
}
|
||||
}
|
||||
|
||||
return game, nil
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package devsandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"galaxy/backend/internal/config"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// TestBootstrapSkippedWhenEmailEmpty exercises the no-op branch: with
|
||||
// the production posture (Email == "") Bootstrap must return without
|
||||
// touching any dependency. The fact that Users/Lobby/EngineVersions
|
||||
// are nil here doubles as a check that the early-return runs first.
|
||||
func TestBootstrapSkippedWhenEmailEmpty(t *testing.T) {
|
||||
err := Bootstrap(
|
||||
context.Background(),
|
||||
Deps{},
|
||||
config.DevSandboxConfig{},
|
||||
zap.NewNop(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error on empty email, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapRejectsZeroPlayerCount confirms the validation
|
||||
// short-circuits the flow before any DB call when PlayerCount is
|
||||
// non-positive but Email is set. The error path is fast and never
|
||||
// dereferences the (still-nil) Users/Lobby deps.
|
||||
func TestBootstrapRejectsZeroPlayerCount(t *testing.T) {
|
||||
err := Bootstrap(
|
||||
context.Background(),
|
||||
Deps{Users: stubEnsurer{}, Lobby: nil, EngineVersions: nil},
|
||||
config.DevSandboxConfig{
|
||||
Email: "dev@local.test",
|
||||
EngineImage: "galaxy-engine:local-dev",
|
||||
EngineVersion: "0.0.0-local-dev",
|
||||
PlayerCount: 0,
|
||||
},
|
||||
zap.NewNop(),
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on zero PlayerCount, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapRejectsMissingDeps checks that a misconfigured wiring
|
||||
// (Email set but one of the required services nil) fails fast rather
|
||||
// than panicking when the bootstrap reaches its first service call.
|
||||
func TestBootstrapRejectsMissingDeps(t *testing.T) {
|
||||
err := Bootstrap(
|
||||
context.Background(),
|
||||
Deps{Users: stubEnsurer{}, Lobby: nil, EngineVersions: nil},
|
||||
config.DevSandboxConfig{
|
||||
Email: "dev@local.test",
|
||||
EngineImage: "galaxy-engine:local-dev",
|
||||
EngineVersion: "0.0.0-local-dev",
|
||||
PlayerCount: 20,
|
||||
},
|
||||
zap.NewNop(),
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on missing deps, got nil")
|
||||
}
|
||||
if !errors.Is(err, errMissingDepsSentinel) && err.Error() == "" {
|
||||
// The exact wording is not part of the contract; this branch
|
||||
// only asserts the error is non-nil and human-readable.
|
||||
t.Fatalf("error has empty message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// errMissingDepsSentinel exists so the assertion above can compile;
|
||||
// the real error is constructed via errors.New inside Bootstrap and
|
||||
// is intentionally not exported. The test only needs to confirm the
|
||||
// returned error has a message.
|
||||
var errMissingDepsSentinel = errors.New("sentinel")
|
||||
|
||||
// TestTerminalSandboxStatus pins the contract that decides whether a
|
||||
// previously created sandbox game gets purged on the next boot.
|
||||
// Terminal states are deleted (cascade-style) so the developer's
|
||||
// lobby never piles up dead tiles between `make rebuild` cycles.
|
||||
func TestTerminalSandboxStatus(t *testing.T) {
|
||||
terminal := []string{"cancelled", "finished", "start_failed"}
|
||||
live := []string{"draft", "enrollment_open", "ready_to_start", "starting", "running", "paused"}
|
||||
|
||||
for _, status := range terminal {
|
||||
if !terminalSandboxStatus(status) {
|
||||
t.Errorf("expected %q to be terminal", status)
|
||||
}
|
||||
}
|
||||
for _, status := range live {
|
||||
if terminalSandboxStatus(status) {
|
||||
t.Errorf("expected %q to be non-terminal", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type stubEnsurer struct{}
|
||||
|
||||
func (stubEnsurer) EnsureByEmail(_ context.Context, _, _, _, _ string) (uuid.UUID, error) {
|
||||
return uuid.UUID{}, nil
|
||||
}
|
||||
@@ -23,7 +23,6 @@ const (
|
||||
pathAdminStatus = "/api/v1/admin/status"
|
||||
pathAdminTurn = "/api/v1/admin/turn"
|
||||
pathAdminRaceBanish = "/api/v1/admin/race/banish"
|
||||
pathPlayerCommand = "/api/v1/command"
|
||||
pathPlayerOrder = "/api/v1/order"
|
||||
pathPlayerReport = "/api/v1/report"
|
||||
pathPlayerBattle = "/api/v1/battle"
|
||||
@@ -183,16 +182,10 @@ func (c *Client) BanishRace(ctx context.Context, baseURL, raceName string) error
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteCommands calls `PUT /api/v1/command` with payload forwarded
|
||||
// PutOrders calls `PUT /api/v1/order` with the payload forwarded
|
||||
// verbatim. The engine response body is returned verbatim; on 4xx the
|
||||
// body is returned alongside ErrEngineValidation so callers can
|
||||
// forward the per-command error.
|
||||
func (c *Client) ExecuteCommands(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
|
||||
return c.forwardPlayerWrite(ctx, baseURL, pathPlayerCommand, payload, "engine command")
|
||||
}
|
||||
|
||||
// PutOrders calls `PUT /api/v1/order` with the same forwarding
|
||||
// semantics as ExecuteCommands.
|
||||
// body is returned alongside ErrEngineValidation so callers can forward
|
||||
// the per-command error.
|
||||
func (c *Client) PutOrders(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
|
||||
return c.forwardPlayerWrite(ctx, baseURL, pathPlayerOrder, payload, "engine order")
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ func newTestClient(t *testing.T, srv *httptest.Server) *Client {
|
||||
|
||||
func TestClientInitSuccess(t *testing.T) {
|
||||
wantID := uuid.New()
|
||||
var gotReq rest.InitRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != pathAdminInit {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
@@ -33,13 +34,16 @@ func TestClientInitSuccess(t *testing.T) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("unexpected method: %s", r.Method)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: wantID, Turn: 1, Players: []rest.PlayerState{{ID: uuid.New(), RaceName: "alpha"}}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cli := newTestClient(t, srv)
|
||||
got, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{Races: []rest.InitRace{{RaceName: "alpha"}}})
|
||||
got, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{GameID: wantID, Races: []rest.InitRace{{RaceName: "alpha"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Init returned error: %v", err)
|
||||
}
|
||||
@@ -49,6 +53,9 @@ func TestClientInitSuccess(t *testing.T) {
|
||||
if got.Turn != 1 {
|
||||
t.Fatalf("Turn = %d, want 1", got.Turn)
|
||||
}
|
||||
if gotReq.GameID != wantID {
|
||||
t.Fatalf("request gameId = %s, want %s", gotReq.GameID, wantID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientInitValidationError(t *testing.T) {
|
||||
@@ -149,27 +156,6 @@ func TestClientBanishRace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientCommandsForwardsBody(t *testing.T) {
|
||||
want := json.RawMessage(`{"actor":"alpha","cmd":[{"@type":"raceQuit"}]}`)
|
||||
gotResp := json.RawMessage(`{"applied":true}`)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != pathPlayerCommand || r.Method != http.MethodPut {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write(gotResp)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cli := newTestClient(t, srv)
|
||||
resp, err := cli.ExecuteCommands(context.Background(), srv.URL, want)
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteCommands: %v", err)
|
||||
}
|
||||
if string(resp) != string(gotResp) {
|
||||
t.Fatalf("response = %s, want %s", string(resp), string(gotResp))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReportsForwardsQuery(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != pathPlayerReport {
|
||||
|
||||
@@ -9,14 +9,21 @@ import (
|
||||
|
||||
// EntitlementProvider is the read-only view the lobby needs over the
|
||||
// user-domain entitlement snapshot. The canonical implementation is
|
||||
// `*user.Service` exposing `GetEntitlement(ctx, userID)`; tests substitute
|
||||
// a fake.
|
||||
// `*user.Service` exposing `GetEntitlementSnapshot(ctx, userID)`; tests
|
||||
// substitute a fake.
|
||||
//
|
||||
// `MaxRegisteredRaceNames` is the only field consumed by when
|
||||
// the caller attempts to register a `pending_registration` row the lobby
|
||||
// counts already-`registered` rows for that user against this limit.
|
||||
// `GetMaxRegisteredRaceNames` is consumed at race-name registration time
|
||||
// — when the caller attempts to register a `pending_registration` row the
|
||||
// lobby counts already-`registered` rows for that user against this limit.
|
||||
//
|
||||
// `IsPaid` is consumed by the user-facing private-game creation gate at
|
||||
// the HTTP handler level (`POST /api/v1/user/lobby/games`): free-tier
|
||||
// callers are rejected with `403 forbidden` before the lobby Service is
|
||||
// invoked. Admin-driven public-game creation
|
||||
// (`POST /api/v1/admin/games`) bypasses the gate.
|
||||
type EntitlementProvider interface {
|
||||
GetMaxRegisteredRaceNames(ctx context.Context, userID uuid.UUID) (int32, error)
|
||||
IsPaid(ctx context.Context, userID uuid.UUID) (bool, error)
|
||||
}
|
||||
|
||||
// RuntimeGateway is the outbound surface the lobby uses to ask the runtime
|
||||
|
||||
@@ -274,11 +274,10 @@ func (s *Service) ListFinishedGamesBefore(ctx context.Context, cutoff time.Time)
|
||||
// `ON DELETE CASCADE` constraints declared in `00001_init.sql`.
|
||||
// Idempotent: returns nil when no game matches.
|
||||
//
|
||||
// Phase 14 introduces this method for the dev-sandbox bootstrap so a
|
||||
// terminal "Dev Sandbox" tile from a previous local-dev session can
|
||||
// be scrubbed before a fresh game spawns. Production callers must
|
||||
// stay on the regular cancel / finish lifecycle — `DeleteGame` is
|
||||
// destructive and bypasses the cascade-notification machinery.
|
||||
// `DeleteGame` is destructive — a hard delete that bypasses the
|
||||
// cascade-notification machinery — so production callers stay on the
|
||||
// regular cancel / finish lifecycle. It is exercised by the lobby
|
||||
// integration tests.
|
||||
func (s *Service) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
|
||||
if err := s.deps.Store.DeleteGame(ctx, gameID); err != nil {
|
||||
return err
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
package lobby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -28,6 +29,7 @@ import (
|
||||
|
||||
"galaxy/backend/internal/config"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -207,6 +209,17 @@ func (s *Service) Config() config.LobbyConfig {
|
||||
return s.deps.Config
|
||||
}
|
||||
|
||||
// IsPaid reports whether userID currently sits on a paid tier. Thin
|
||||
// pass-through over EntitlementProvider used by the HTTP handler that
|
||||
// fronts user-driven private-game creation; admin-driven public-game
|
||||
// creation does not consult this gate.
|
||||
func (s *Service) IsPaid(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
if s == nil || s.deps.Entitlement == nil {
|
||||
return false, fmt.Errorf("lobby: entitlement provider not configured")
|
||||
}
|
||||
return s.deps.Entitlement.IsPaid(ctx, userID)
|
||||
}
|
||||
|
||||
// generateInviteCode produces an `inviteCodeBytes`-byte hex code used
|
||||
// for code-based invites. The function uses `crypto/rand`; a failure to
|
||||
// read entropy is propagated to the caller.
|
||||
|
||||
@@ -103,6 +103,10 @@ func (s stubEntitlement) GetMaxRegisteredRaceNames(_ context.Context, _ uuid.UUI
|
||||
return s.max, nil
|
||||
}
|
||||
|
||||
func (s stubEntitlement) IsPaid(_ context.Context, _ uuid.UUID) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func newServiceForTest(t *testing.T, db *sql.DB, now func() time.Time, max int32) *lobby.Service {
|
||||
t.Helper()
|
||||
store := lobby.NewStore(db)
|
||||
@@ -244,8 +248,8 @@ func TestEndToEndPrivateGameFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteGameCascadesEverything pins the contract the dev-sandbox
|
||||
// bootstrap relies on: removing a game wipes every referencing row
|
||||
// TestDeleteGameCascadesEverything pins the DeleteGame contract:
|
||||
// removing a game wipes every referencing row
|
||||
// (memberships, applications, invites, runtime_records,
|
||||
// player_mappings) in a single SQL statement. Before this is wired
|
||||
// the developer's lobby pile up cancelled tiles between
|
||||
|
||||
@@ -20,9 +20,9 @@ type InsertMembershipDirectInput struct {
|
||||
// writes as ApproveApplication: the per-game race-name reservation
|
||||
// row plus the membership row, and refreshes the in-memory caches.
|
||||
//
|
||||
// The method is intended for boot-time provisioning by
|
||||
// `backend/internal/devsandbox` and similar trusted callers. It is
|
||||
// not exposed through any HTTP handler. The caller must guarantee
|
||||
// The method is intended for trusted boot-time provisioning and
|
||||
// integration tests; it is not exposed through any HTTP handler. The
|
||||
// caller must guarantee
|
||||
// game.Status == GameStatusEnrollmentOpen — the function returns
|
||||
// ErrConflict otherwise — and that the race-name policy and
|
||||
// canonical-key invariants are honoured (the implementation reuses
|
||||
@@ -30,9 +30,8 @@ type InsertMembershipDirectInput struct {
|
||||
// or unsuitable name still fails).
|
||||
//
|
||||
// Idempotency: if a membership for (GameID, UserID) already exists
|
||||
// the function returns the existing row without modifying state.
|
||||
// This makes the helper safe to call on every backend boot from
|
||||
// devsandbox.Bootstrap.
|
||||
// the function returns the existing row without modifying state, so
|
||||
// the helper is safe to call repeatedly.
|
||||
func (s *Service) InsertMembershipDirect(ctx context.Context, in InsertMembershipDirectInput) (Membership, error) {
|
||||
displayName, err := ValidateDisplayName(in.RaceName)
|
||||
if err != nil {
|
||||
|
||||
@@ -236,9 +236,8 @@ func (s *Store) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameRecord
|
||||
// referencing table (memberships / applications / invites /
|
||||
// runtime_records / player_mappings — all declared with ON DELETE
|
||||
// CASCADE in `00001_init.sql`). Idempotent: returns nil when no row
|
||||
// matches. Used by the dev-sandbox bootstrap to scrub terminal
|
||||
// games on every backend boot so the developer's lobby never piles
|
||||
// up cancelled tiles.
|
||||
// matches. A hard delete for trusted callers and integration tests;
|
||||
// production lifecycle uses cancel / finish.
|
||||
func (s *Store) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
|
||||
g := table.Games
|
||||
stmt := g.DELETE().WHERE(g.GameID.EQ(postgres.UUID(gameID)))
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// Package opsstatus reads point-in-time operational signals from Postgres for
|
||||
// the admin console dashboard: database reachability, per-status counts of game
|
||||
// runtimes, mail deliveries, and notification routes, plus the malformed
|
||||
// notification-intent count.
|
||||
//
|
||||
// It is a read-only projection built entirely through the go-jet query builder
|
||||
// against the generated table bindings; it owns no business logic and mutates
|
||||
// nothing. Richer, historical metrics are out of scope — those belong to the
|
||||
// Prometheus exporters wired on `backend` and `gateway`.
|
||||
package opsstatus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/postgres/jet/backend/table"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
// defaultCollectTimeout bounds a single Collect call so a slow or wedged
|
||||
// database cannot hang the dashboard request.
|
||||
const defaultCollectTimeout = 3 * time.Second
|
||||
|
||||
// StatusCount pairs a status value with the number of rows currently in it.
|
||||
type StatusCount struct {
|
||||
Status string
|
||||
Count int64
|
||||
}
|
||||
|
||||
// Snapshot is a point-in-time view of the operational signals rendered on the
|
||||
// dashboard. Errors collects per-query failures so a single failing probe
|
||||
// degrades to a visible note rather than failing the whole page.
|
||||
type Snapshot struct {
|
||||
PostgresHealthy bool
|
||||
Runtimes []StatusCount
|
||||
MailDeliveries []StatusCount
|
||||
NotificationRoutes []StatusCount
|
||||
NotificationMalformed int64
|
||||
Errors []string
|
||||
}
|
||||
|
||||
// Reader collects an operational Snapshot. The admin console depends on this
|
||||
// interface so the dashboard can be tested without a database.
|
||||
type Reader interface {
|
||||
Collect(ctx context.Context) Snapshot
|
||||
}
|
||||
|
||||
// Store is the Postgres-backed Reader.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewStore constructs a Store reading from db.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db, timeout: defaultCollectTimeout}
|
||||
}
|
||||
|
||||
// Collect gathers the dashboard signals within a bounded timeout. It never
|
||||
// returns an error: a failed probe is recorded in Snapshot.Errors and the
|
||||
// remaining probes still run, except that a failed Postgres ping short-circuits
|
||||
// the rest (the dependent queries would only fail the same way).
|
||||
func (s *Store) Collect(ctx context.Context) Snapshot {
|
||||
ctx, cancel := context.WithTimeout(ctx, s.timeout)
|
||||
defer cancel()
|
||||
|
||||
var snap Snapshot
|
||||
|
||||
if err := s.db.PingContext(ctx); err != nil {
|
||||
snap.Errors = append(snap.Errors, fmt.Sprintf("postgres ping: %v", err))
|
||||
return snap
|
||||
}
|
||||
snap.PostgresHealthy = true
|
||||
|
||||
if counts, err := s.statusCounts(ctx, table.RuntimeRecords.Status, table.RuntimeRecords); err != nil {
|
||||
snap.Errors = append(snap.Errors, fmt.Sprintf("runtime status counts: %v", err))
|
||||
} else {
|
||||
snap.Runtimes = counts
|
||||
}
|
||||
|
||||
if counts, err := s.statusCounts(ctx, table.MailDeliveries.Status, table.MailDeliveries); err != nil {
|
||||
snap.Errors = append(snap.Errors, fmt.Sprintf("mail delivery counts: %v", err))
|
||||
} else {
|
||||
snap.MailDeliveries = counts
|
||||
}
|
||||
|
||||
if counts, err := s.statusCounts(ctx, table.NotificationRoutes.Status, table.NotificationRoutes); err != nil {
|
||||
snap.Errors = append(snap.Errors, fmt.Sprintf("notification route counts: %v", err))
|
||||
} else {
|
||||
snap.NotificationRoutes = counts
|
||||
}
|
||||
|
||||
if n, err := s.countAll(ctx, table.NotificationMalformedIntents); err != nil {
|
||||
snap.Errors = append(snap.Errors, fmt.Sprintf("malformed notification count: %v", err))
|
||||
} else {
|
||||
snap.NotificationMalformed = n
|
||||
}
|
||||
|
||||
return snap
|
||||
}
|
||||
|
||||
// statusCounts runs `SELECT status, COUNT(*) FROM <from> GROUP BY status`
|
||||
// through jet and returns the rows ordered by status.
|
||||
func (s *Store) statusCounts(ctx context.Context, status postgres.ColumnString, from postgres.ReadableTable) ([]StatusCount, error) {
|
||||
stmt := postgres.SELECT(
|
||||
status.AS("status_count.status"),
|
||||
postgres.COUNT(postgres.STAR).AS("status_count.count"),
|
||||
).FROM(from).GROUP_BY(status).ORDER_BY(status.ASC())
|
||||
|
||||
var rows []struct {
|
||||
Status string `alias:"status_count.status"`
|
||||
Count int64 `alias:"status_count.count"`
|
||||
}
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]StatusCount, len(rows))
|
||||
for i, row := range rows {
|
||||
out[i] = StatusCount{Status: row.Status, Count: row.Count}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// countAll runs `SELECT COUNT(*) FROM <from>` through jet.
|
||||
func (s *Store) countAll(ctx context.Context, from postgres.ReadableTable) (int64, error) {
|
||||
stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).FROM(from)
|
||||
|
||||
var row struct {
|
||||
Count int64 `alias:"count"`
|
||||
}
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return row.Count, nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package opsstatus_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/mail"
|
||||
"galaxy/backend/internal/opsstatus"
|
||||
backendpg "galaxy/backend/internal/postgres"
|
||||
pgshared "galaxy/postgres"
|
||||
|
||||
"github.com/google/uuid"
|
||||
testcontainers "github.com/testcontainers/testcontainers-go"
|
||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
const (
|
||||
pgImage = "postgres:16-alpine"
|
||||
pgUser = "galaxy"
|
||||
pgPassword = "galaxy"
|
||||
pgDatabase = "galaxy_backend"
|
||||
pgSchema = "backend"
|
||||
pgStartup = 90 * time.Second
|
||||
pgOpTO = 10 * time.Second
|
||||
)
|
||||
|
||||
// startPostgres mirrors the per-package scaffolding used by the other store
|
||||
// tests: spin up Postgres, apply migrations, return *sql.DB.
|
||||
func startPostgres(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
pgContainer, err := tcpostgres.Run(ctx, pgImage,
|
||||
tcpostgres.WithDatabase(pgDatabase),
|
||||
tcpostgres.WithUsername(pgUser),
|
||||
tcpostgres.WithPassword(pgPassword),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("database system is ready to accept connections").
|
||||
WithOccurrence(2).
|
||||
WithStartupTimeout(pgStartup),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
t.Skipf("postgres testcontainer unavailable, skipping: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil {
|
||||
t.Errorf("terminate postgres container: %v", termErr)
|
||||
}
|
||||
})
|
||||
|
||||
baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
t.Fatalf("connection string: %v", err)
|
||||
}
|
||||
scopedDSN, err := dsnWithSearchPath(baseDSN, pgSchema)
|
||||
if err != nil {
|
||||
t.Fatalf("scope dsn: %v", err)
|
||||
}
|
||||
|
||||
cfg := pgshared.DefaultConfig()
|
||||
cfg.PrimaryDSN = scopedDSN
|
||||
cfg.OperationTimeout = pgOpTO
|
||||
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
|
||||
if err != nil {
|
||||
t.Fatalf("open primary: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil {
|
||||
t.Fatalf("ping: %v", err)
|
||||
}
|
||||
if err := backendpg.ApplyMigrations(ctx, db); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func dsnWithSearchPath(baseDSN, schema string) (string, error) {
|
||||
parsed, err := url.Parse(baseDSN)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
values := parsed.Query()
|
||||
values.Set("search_path", schema)
|
||||
if values.Get("sslmode") == "" {
|
||||
values.Set("sslmode", "disable")
|
||||
}
|
||||
parsed.RawQuery = values.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func TestStoreCollect(t *testing.T) {
|
||||
db := startPostgres(t)
|
||||
store := opsstatus.NewStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Empty schema: queries must execute cleanly with zero counts.
|
||||
empty := store.Collect(ctx)
|
||||
if !empty.PostgresHealthy {
|
||||
t.Fatal("PostgresHealthy must be true against a reachable database")
|
||||
}
|
||||
if len(empty.Errors) != 0 {
|
||||
t.Fatalf("unexpected collection errors: %v", empty.Errors)
|
||||
}
|
||||
if got := totalCount(empty.MailDeliveries); got != 0 {
|
||||
t.Fatalf("mail deliveries total = %d, want 0", got)
|
||||
}
|
||||
if len(empty.Runtimes) != 0 || len(empty.NotificationRoutes) != 0 {
|
||||
t.Fatalf("expected empty status slices, got runtimes=%v routes=%v", empty.Runtimes, empty.NotificationRoutes)
|
||||
}
|
||||
if empty.NotificationMalformed != 0 {
|
||||
t.Fatalf("malformed notifications = %d, want 0", empty.NotificationMalformed)
|
||||
}
|
||||
|
||||
// Enqueue one mail delivery and confirm the GROUP BY count reflects it.
|
||||
mailStore := mail.NewStore(db)
|
||||
inserted, err := mailStore.InsertEnqueue(ctx, mail.EnqueueArgs{
|
||||
DeliveryID: uuid.New(),
|
||||
TemplateID: mail.TemplateLoginCode,
|
||||
IdempotencyKey: uuid.NewString(),
|
||||
Recipients: []string{"ops@example.test"},
|
||||
ContentType: "text/plain",
|
||||
Subject: "hello",
|
||||
Body: []byte("hi"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("insert mail delivery: %v", err)
|
||||
}
|
||||
if !inserted {
|
||||
t.Fatal("expected the delivery to be inserted")
|
||||
}
|
||||
|
||||
after := store.Collect(ctx)
|
||||
if len(after.Errors) != 0 {
|
||||
t.Fatalf("unexpected collection errors after insert: %v", after.Errors)
|
||||
}
|
||||
if got := totalCount(after.MailDeliveries); got != 1 {
|
||||
t.Fatalf("mail deliveries total after insert = %d, want 1 (statuses: %v)", got, after.MailDeliveries)
|
||||
}
|
||||
}
|
||||
|
||||
func totalCount(counts []opsstatus.StatusCount) int64 {
|
||||
var total int64
|
||||
for _, c := range counts {
|
||||
total += c.Count
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -10,7 +10,10 @@ package postgres
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/config"
|
||||
@@ -67,18 +70,84 @@ 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 {
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsTransientConnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"driver.ErrBadConn", driver.ErrBadConn, true},
|
||||
{"wrapped ErrBadConn", fmt.Errorf("run migrations: %w", driver.ErrBadConn), true},
|
||||
// The exact shape observed flaking CI: goose surfaces the driver
|
||||
// error as a plain string, so errors.Is can't see ErrBadConn.
|
||||
{"bad connection string", errors.New(`apply backend migrations: run migrations: ERROR 00001_init.sql: CREATE TABLE race_names: driver: bad connection`), true},
|
||||
{"connection refused", errors.New("dial tcp 127.0.0.1:5432: connect: connection refused"), true},
|
||||
{"connection reset", errors.New("read tcp: connection reset by peer"), true},
|
||||
{"broken pipe", errors.New("write tcp: broken pipe"), true},
|
||||
{"server closed", errors.New("pq: server closed the connection unexpectedly"), true},
|
||||
{"syntax error is not transient", errors.New(`pq: syntax error at or near "TABL"`), false},
|
||||
{"constraint violation is not transient", errors.New("pq: duplicate key value violates unique constraint"), false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, tt.want, isTransientConnError(tt.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryOnTransientSucceedsAfterTransientFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
calls := 0
|
||||
err := retryOnTransient(context.Background(), 5, time.Millisecond, func() error {
|
||||
calls++
|
||||
if calls < 3 {
|
||||
return fmt.Errorf("attempt %d: %w", calls, driver.ErrBadConn)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3, calls, "should retry until the transient error clears")
|
||||
}
|
||||
|
||||
func TestRetryOnTransientStopsOnNonTransient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sentinel := errors.New(`pq: syntax error at or near "TABL"`)
|
||||
calls := 0
|
||||
err := retryOnTransient(context.Background(), 5, time.Millisecond, func() error {
|
||||
calls++
|
||||
return sentinel
|
||||
})
|
||||
|
||||
require.ErrorIs(t, err, sentinel)
|
||||
assert.Equal(t, 1, calls, "a deterministic SQL error must not be retried")
|
||||
}
|
||||
|
||||
func TestRetryOnTransientExhaustsAttempts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
calls := 0
|
||||
err := retryOnTransient(context.Background(), 3, time.Millisecond, func() error {
|
||||
calls++
|
||||
return driver.ErrBadConn
|
||||
})
|
||||
|
||||
require.ErrorIs(t, err, driver.ErrBadConn)
|
||||
assert.Equal(t, 3, calls, "must stop after the attempt budget is spent")
|
||||
}
|
||||
|
||||
func TestRetryOnTransientRespectsContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
calls := 0
|
||||
err := retryOnTransient(ctx, 5, time.Hour, func() error {
|
||||
calls++
|
||||
return driver.ErrBadConn
|
||||
})
|
||||
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
require.ErrorIs(t, err, driver.ErrBadConn, "the underlying transient error is preserved")
|
||||
assert.Equal(t, 1, calls, "cancellation during backoff stops further attempts")
|
||||
}
|
||||
@@ -52,7 +52,7 @@ var (
|
||||
ErrTurnAlreadyClosed = errors.New("runtime: turn already closed")
|
||||
|
||||
// ErrGamePaused reports that the game is not in a state that
|
||||
// accepts user-games commands or orders: the runtime row
|
||||
// accepts user-games orders: the runtime row
|
||||
// carries `paused = true`, or the runtime status lands on any
|
||||
// terminal value (`engine_unreachable`, `generation_failed`,
|
||||
// `stopped`, `finished`, `removed`), or the game has not yet
|
||||
|
||||
@@ -258,10 +258,10 @@ func (s *Service) ResolvePlayerMapping(ctx context.Context, gameID, userID uuid.
|
||||
}
|
||||
|
||||
// CheckOrdersAccept verifies that the runtime is in a state that
|
||||
// accepts user-games commands and orders. It is called by the user
|
||||
// game-proxy handlers (`Commands`, `Orders`) before forwarding to
|
||||
// engine, so the backend's turn-cutoff and pause guards run before
|
||||
// network traffic leaves the host. The decision itself lives in the
|
||||
// accepts user-games orders. It is called by the user game-proxy
|
||||
// handler (`Orders`) before forwarding to engine, so the backend's
|
||||
// turn-cutoff and pause guards run before network traffic leaves the
|
||||
// host. The decision itself lives in the
|
||||
// pure helper `OrdersAcceptStatus` so it can be unit-tested without
|
||||
// constructing a full Service.
|
||||
//
|
||||
@@ -276,7 +276,7 @@ func (s *Service) CheckOrdersAccept(ctx context.Context, gameID uuid.UUID) error
|
||||
}
|
||||
|
||||
// OrdersAcceptStatus inspects a runtime record and returns the
|
||||
// matching sentinel for the user-games order/command pre-check:
|
||||
// matching sentinel for the user-games order pre-check:
|
||||
//
|
||||
// - `runtime_status = generation_in_progress` → `ErrTurnAlreadyClosed`.
|
||||
// The cron-driven `Scheduler.tick` has flipped the row before
|
||||
@@ -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{Races: races})
|
||||
initResp, err := s.deps.Engine.Init(ctx, runResult.EngineEndpoint, rest.InitRequest{GameID: gameID, Races: races})
|
||||
if err != nil {
|
||||
s.deps.Logger.Warn("engine init failed",
|
||||
zap.String("game_id", gameID.String()),
|
||||
|
||||
@@ -203,6 +203,13 @@ func TestServiceStartGameEndToEnd(t *testing.T) {
|
||||
case "/healthz":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/api/v1/admin/init":
|
||||
var got rest.InitRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Errorf("decode init request: %v", err)
|
||||
}
|
||||
if got.GameID != gameID {
|
||||
t.Errorf("init request gameId = %s, want %s", got.GameID, gameID)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 0, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 3, Population: 10}}})
|
||||
case "/api/v1/admin/status":
|
||||
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 1, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 5, Population: 12}}})
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"galaxy/backend/internal/adminconsole"
|
||||
"galaxy/backend/internal/opsstatus"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/basicauth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminConsoleHandlers renders the server-side operator console mounted under
|
||||
// the `/_gm` route group. It wraps the framework-agnostic
|
||||
// adminconsole.Renderer and CSRF signer with the gin glue: the per-page
|
||||
// handlers, the embedded static-asset handler, and the CSRF guard middleware
|
||||
// applied to state-changing requests. Authentication is provided by the shared
|
||||
// admin Basic Auth middleware mounted on the group, so this type assumes the
|
||||
// caller has already been verified.
|
||||
type AdminConsoleHandlers struct {
|
||||
renderer *adminconsole.Renderer
|
||||
csrf *adminconsole.CSRF
|
||||
assets http.Handler
|
||||
monitor opsstatus.Reader
|
||||
ready func() bool
|
||||
users UserAdmin
|
||||
games GameAdmin
|
||||
runtime RuntimeAdmin
|
||||
engineVersions EngineVersionAdmin
|
||||
operators OperatorAdmin
|
||||
mail MailAdmin
|
||||
notifications NotificationAdmin
|
||||
diplomail DiplomailAdmin
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// AdminConsoleDeps bundles the collaborators for the operator console. Every
|
||||
// field is optional: a nil Renderer or CSRF falls back to the embedded default
|
||||
// templates and a per-process random key; a nil Monitor renders the dashboard
|
||||
// without the monitoring panels; a nil Ready reports backend readiness as not
|
||||
// ready; a nil Logger falls back to zap.NewNop.
|
||||
type AdminConsoleDeps struct {
|
||||
Renderer *adminconsole.Renderer
|
||||
CSRF *adminconsole.CSRF
|
||||
Monitor opsstatus.Reader
|
||||
Ready func() bool
|
||||
Users UserAdmin
|
||||
Games GameAdmin
|
||||
Runtime RuntimeAdmin
|
||||
EngineVersions EngineVersionAdmin
|
||||
Operators OperatorAdmin
|
||||
Mail MailAdmin
|
||||
Notifications NotificationAdmin
|
||||
Diplomail DiplomailAdmin
|
||||
Logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAdminConsoleHandlers constructs the console handler set from deps. It
|
||||
// panics only on conditions that are unrecoverable at startup (template parse
|
||||
// failure or crypto/rand failure), both of which indicate a broken build or
|
||||
// host rather than a runtime input.
|
||||
func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers {
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
renderer := deps.Renderer
|
||||
if renderer == nil {
|
||||
renderer = adminconsole.MustNewRenderer()
|
||||
}
|
||||
csrf := deps.CSRF
|
||||
if csrf == nil {
|
||||
generated, err := adminconsole.NewRandomCSRF()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
csrf = generated
|
||||
}
|
||||
|
||||
assetsFS, err := adminconsole.Assets()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return &AdminConsoleHandlers{
|
||||
renderer: renderer,
|
||||
csrf: csrf,
|
||||
assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))),
|
||||
monitor: deps.Monitor,
|
||||
ready: deps.Ready,
|
||||
users: deps.Users,
|
||||
games: deps.Games,
|
||||
runtime: deps.Runtime,
|
||||
engineVersions: deps.EngineVersions,
|
||||
operators: deps.Operators,
|
||||
mail: deps.Mail,
|
||||
notifications: deps.Notifications,
|
||||
diplomail: deps.Diplomail,
|
||||
logger: logger.Named("http.admin.console"),
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard renders the console landing page (GET /_gm and GET /_gm/),
|
||||
// including the monitoring panels when an ops-status reader is wired.
|
||||
func (h *AdminConsoleHandlers) Dashboard() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
data := adminconsole.DashboardData{}
|
||||
if h.ready != nil {
|
||||
data.BackendReady = h.ready()
|
||||
}
|
||||
if h.monitor != nil {
|
||||
data.MonitorAvailable = true
|
||||
snapshot := h.monitor.Collect(c.Request.Context())
|
||||
data.PostgresHealthy = snapshot.PostgresHealthy
|
||||
data.Runtimes = toViewCounts(snapshot.Runtimes)
|
||||
data.MailDeliveries = toViewCounts(snapshot.MailDeliveries)
|
||||
data.NotificationRoutes = toViewCounts(snapshot.NotificationRoutes)
|
||||
data.NotificationMalformed = snapshot.NotificationMalformed
|
||||
data.Errors = snapshot.Errors
|
||||
}
|
||||
h.render(c, http.StatusOK, "dashboard", "dashboard", "Dashboard", data)
|
||||
}
|
||||
}
|
||||
|
||||
// toViewCounts maps ops-status counts to the console's view-layer counts.
|
||||
func toViewCounts(in []opsstatus.StatusCount) []adminconsole.StatusCount {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]adminconsole.StatusCount, len(in))
|
||||
for i, sc := range in {
|
||||
out[i] = adminconsole.StatusCount{Status: sc.Status, Count: sc.Count}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Asset serves the embedded console static assets under `/_gm/assets/`.
|
||||
func (h *AdminConsoleHandlers) Asset() gin.HandlerFunc {
|
||||
return gin.WrapH(h.assets)
|
||||
}
|
||||
|
||||
// RequireCSRF returns middleware guarding state-changing requests against
|
||||
// cross-site request forgery. Safe methods pass through untouched. For unsafe
|
||||
// methods it requires both a same-origin Origin/Referer header (when the
|
||||
// browser sends one) and a valid per-operator token in the `_csrf` form field;
|
||||
// either check failing yields 403.
|
||||
func (h *AdminConsoleHandlers) RequireCSRF() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if isSafeHTTPMethod(c.Request.Method) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if !sameOriginRequest(c.Request) {
|
||||
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "cross-origin request rejected")
|
||||
return
|
||||
}
|
||||
username, _ := basicauth.UsernameFromContext(c.Request.Context())
|
||||
if !h.csrf.Verify(username, c.PostForm("_csrf")) {
|
||||
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "invalid or missing CSRF token")
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// render composes the data common to every console page (operator name, CSRF
|
||||
// token, active navigation entry) and writes the named page. It renders into an
|
||||
// intermediate buffer so a template failure surfaces as a clean 500 without
|
||||
// emitting a partial document.
|
||||
func (h *AdminConsoleHandlers) render(c *gin.Context, status int, page, activeNav, title string, data any) {
|
||||
username, _ := basicauth.UsernameFromContext(c.Request.Context())
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := h.renderer.Render(&buf, page, adminconsole.PageData{
|
||||
Title: title,
|
||||
Username: username,
|
||||
CSRFToken: h.csrf.Token(username),
|
||||
ActiveNav: activeNav,
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("render admin console page", zap.String("page", page), zap.Error(err))
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "failed to render page")
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(status, "text/html; charset=utf-8", buf.Bytes())
|
||||
}
|
||||
|
||||
// renderMessage renders the generic message page (not-found, validation, or
|
||||
// operation-failure notices). class selects the CSS styling and backHref, when
|
||||
// non-empty, adds a back link.
|
||||
func (h *AdminConsoleHandlers) renderMessage(c *gin.Context, status int, activeNav, title, message, class, backHref string) {
|
||||
h.render(c, status, "message", activeNav, title, adminconsole.MessageData{
|
||||
Message: message,
|
||||
Class: class,
|
||||
BackHref: backHref,
|
||||
})
|
||||
}
|
||||
|
||||
// isSafeHTTPMethod reports whether method is a read-only HTTP method that the
|
||||
// CSRF guard may let through without a token.
|
||||
func isSafeHTTPMethod(method string) bool {
|
||||
switch method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// sameOriginRequest reports whether the request's Origin (or, failing that,
|
||||
// Referer) names the same host as the request itself. A request that carries
|
||||
// neither header is treated as same-origin, leaving the CSRF token as the sole
|
||||
// guard; a malformed or cross-host value is rejected. This relies on the
|
||||
// gateway reverse proxy preserving the inbound Host header.
|
||||
func sameOriginRequest(r *http.Request) bool {
|
||||
source := r.Header.Get("Origin")
|
||||
if source == "" {
|
||||
source = r.Header.Get("Referer")
|
||||
}
|
||||
if source == "" {
|
||||
return true
|
||||
}
|
||||
parsed, err := url.Parse(source)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(parsed.Host, r.Host)
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/adminconsole"
|
||||
"galaxy/backend/internal/lobby"
|
||||
"galaxy/backend/internal/runtime"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// GameAdmin is the subset of the lobby service the console uses for games.
|
||||
type GameAdmin interface {
|
||||
ListAdminGames(ctx context.Context, page, pageSize int) (lobby.GamePage, error)
|
||||
GetGame(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
|
||||
CreateGame(ctx context.Context, input lobby.CreateGameInput) (lobby.GameRecord, error)
|
||||
AdminForceStart(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
|
||||
AdminForceStop(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
|
||||
AdminBanMember(ctx context.Context, gameID, userID uuid.UUID, reason string) (lobby.Membership, error)
|
||||
}
|
||||
|
||||
// RuntimeAdmin is the subset of the runtime service the console uses.
|
||||
type RuntimeAdmin interface {
|
||||
GetRuntime(ctx context.Context, gameID uuid.UUID) (runtime.RuntimeRecord, error)
|
||||
AdminRestart(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error)
|
||||
AdminPatch(ctx context.Context, gameID uuid.UUID, targetVersion string) (runtime.OperationLog, error)
|
||||
AdminForceNextTurn(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error)
|
||||
}
|
||||
|
||||
// EngineVersionAdmin is the subset of the engine-version service the console uses.
|
||||
type EngineVersionAdmin interface {
|
||||
List(ctx context.Context) ([]runtime.EngineVersion, error)
|
||||
Register(ctx context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error)
|
||||
Disable(ctx context.Context, version string) (runtime.EngineVersion, error)
|
||||
}
|
||||
|
||||
// GamesList renders GET /_gm/games.
|
||||
func (h *AdminConsoleHandlers) GamesList() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.games == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||
result, err := h.games.ListAdminGames(c.Request.Context(), page, pageSize)
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: list games", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load games.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
h.render(c, http.StatusOK, "games", "games", "Games", toGamesListData(result))
|
||||
}
|
||||
}
|
||||
|
||||
// GameCreate handles POST /_gm/games — create a public game.
|
||||
func (h *AdminConsoleHandlers) GameCreate() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.games == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
enrollmentEndsAt, err := parseConsoleDateTime(c.PostForm("enrollment_ends_at"))
|
||||
if err != nil {
|
||||
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "Enrollment end must be a valid date/time.", "bad", "/_gm/games")
|
||||
return
|
||||
}
|
||||
game, err := h.games.CreateGame(c.Request.Context(), lobby.CreateGameInput{
|
||||
OwnerUserID: nil,
|
||||
Visibility: lobby.VisibilityPublic,
|
||||
GameName: strings.TrimSpace(c.PostForm("game_name")),
|
||||
Description: strings.TrimSpace(c.PostForm("description")),
|
||||
MinPlayers: formInt32(c, "min_players"),
|
||||
MaxPlayers: formInt32(c, "max_players"),
|
||||
StartGapHours: formInt32(c, "start_gap_hours"),
|
||||
StartGapPlayers: formInt32(c, "start_gap_players"),
|
||||
EnrollmentEndsAt: enrollmentEndsAt,
|
||||
TurnSchedule: strings.TrimSpace(c.PostForm("turn_schedule")),
|
||||
TargetEngineVersion: strings.TrimSpace(c.PostForm("target_engine_version")),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, lobby.ErrInvalidInput) {
|
||||
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "The game could not be created: check the fields.", "bad", "/_gm/games")
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin console: create game", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Create failed", "Failed to create the game.", "bad", "/_gm/games")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, "/_gm/games/"+game.GameID.String())
|
||||
}
|
||||
}
|
||||
|
||||
// GameDetail renders GET /_gm/games/:game_id with the runtime snapshot.
|
||||
func (h *AdminConsoleHandlers) GameDetail() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.games == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
game, err := h.games.GetGame(c.Request.Context(), gameID)
|
||||
if err != nil {
|
||||
if errors.Is(err, lobby.ErrNotFound) {
|
||||
h.renderMessage(c, http.StatusNotFound, "games", "Game not found", "No such game.", "bad", "/_gm/games")
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin console: get game", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load the game.", "bad", "/_gm/games")
|
||||
return
|
||||
}
|
||||
|
||||
var runtimeRecord *runtime.RuntimeRecord
|
||||
if h.runtime != nil {
|
||||
if record, rtErr := h.runtime.GetRuntime(c.Request.Context(), gameID); rtErr == nil {
|
||||
runtimeRecord = &record
|
||||
}
|
||||
}
|
||||
h.render(c, http.StatusOK, "game_detail", "games", game.GameName, toGameDetailData(game, runtimeRecord))
|
||||
}
|
||||
}
|
||||
|
||||
// GameForceStart handles POST /_gm/games/:game_id/force-start.
|
||||
func (h *AdminConsoleHandlers) GameForceStart() gin.HandlerFunc {
|
||||
return h.gameAction("force-start", func(ctx context.Context, gameID uuid.UUID) error {
|
||||
_, err := h.games.AdminForceStart(ctx, gameID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// GameForceStop handles POST /_gm/games/:game_id/force-stop.
|
||||
func (h *AdminConsoleHandlers) GameForceStop() gin.HandlerFunc {
|
||||
return h.gameAction("force-stop", func(ctx context.Context, gameID uuid.UUID) error {
|
||||
_, err := h.games.AdminForceStop(ctx, gameID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// GameBanMember handles POST /_gm/games/:game_id/ban-member.
|
||||
func (h *AdminConsoleHandlers) GameBanMember() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.games == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/games/" + gameID.String()
|
||||
userID, err := uuid.Parse(strings.TrimSpace(c.PostForm("user_id")))
|
||||
if err != nil {
|
||||
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "User ID must be a valid UUID.", "bad", back)
|
||||
return
|
||||
}
|
||||
if _, err := h.games.AdminBanMember(c.Request.Context(), gameID, userID, strings.TrimSpace(c.PostForm("reason"))); err != nil {
|
||||
h.logger.Error("admin console: ban member", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Ban failed", "Failed to ban the member.", "bad", back)
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, back)
|
||||
}
|
||||
}
|
||||
|
||||
// RuntimeRestart handles POST /_gm/games/:game_id/runtime/restart.
|
||||
func (h *AdminConsoleHandlers) RuntimeRestart() gin.HandlerFunc {
|
||||
return h.runtimeAction("restart", func(ctx context.Context, gameID uuid.UUID) error {
|
||||
_, err := h.runtime.AdminRestart(ctx, gameID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// RuntimeForceNextTurn handles POST /_gm/games/:game_id/runtime/force-next-turn.
|
||||
func (h *AdminConsoleHandlers) RuntimeForceNextTurn() gin.HandlerFunc {
|
||||
return h.runtimeAction("force-next-turn", func(ctx context.Context, gameID uuid.UUID) error {
|
||||
_, err := h.runtime.AdminForceNextTurn(ctx, gameID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// RuntimePatch handles POST /_gm/games/:game_id/runtime/patch.
|
||||
func (h *AdminConsoleHandlers) RuntimePatch() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.runtime == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/games/" + gameID.String()
|
||||
target := strings.TrimSpace(c.PostForm("target_version"))
|
||||
if target == "" {
|
||||
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "A target version is required.", "bad", back)
|
||||
return
|
||||
}
|
||||
if _, err := h.runtime.AdminPatch(c.Request.Context(), gameID, target); err != nil {
|
||||
h.logger.Error("admin console: runtime patch", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Patch failed", "Failed to patch the runtime.", "bad", back)
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, back)
|
||||
}
|
||||
}
|
||||
|
||||
// gameAction is the shared shape for game-state POST actions that take only the
|
||||
// game id and redirect back to the detail page.
|
||||
func (h *AdminConsoleHandlers) gameAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.games == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/games/" + gameID.String()
|
||||
if err := run(c.Request.Context(), gameID); err != nil {
|
||||
h.logger.Error("admin console: game "+label, zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The "+label+" action failed.", "bad", back)
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, back)
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeAction is the shared shape for runtime POST actions that take only the
|
||||
// game id and redirect back to the detail page.
|
||||
func (h *AdminConsoleHandlers) runtimeAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.runtime == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/games/" + gameID.String()
|
||||
if err := run(c.Request.Context(), gameID); err != nil {
|
||||
h.logger.Error("admin console: runtime "+label, zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The runtime "+label+" action failed.", "bad", back)
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, back)
|
||||
}
|
||||
}
|
||||
|
||||
// EngineVersionsList renders GET /_gm/engine-versions.
|
||||
func (h *AdminConsoleHandlers) EngineVersionsList() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.engineVersions == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
items, err := h.engineVersions.List(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: list engine versions", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Engine versions", "Failed to load engine versions.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
h.render(c, http.StatusOK, "engine_versions", "games", "Engine versions", toEngineVersionsData(items))
|
||||
}
|
||||
}
|
||||
|
||||
// EngineVersionRegister handles POST /_gm/engine-versions.
|
||||
func (h *AdminConsoleHandlers) EngineVersionRegister() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.engineVersions == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
enabled := c.PostForm("enabled") == "true"
|
||||
_, err := h.engineVersions.Register(c.Request.Context(), runtime.RegisterInput{
|
||||
Version: strings.TrimSpace(c.PostForm("version")),
|
||||
ImageRef: strings.TrimSpace(c.PostForm("image_ref")),
|
||||
Enabled: &enabled,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, runtime.ErrInvalidInput) || errors.Is(err, runtime.ErrEngineVersionTaken) {
|
||||
h.renderMessage(c, http.StatusBadRequest, "engine-versions", "Invalid input", "The version could not be registered (invalid semver, missing image, or duplicate).", "bad", "/_gm/engine-versions")
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin console: register engine version", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Register failed", "Failed to register the engine version.", "bad", "/_gm/engine-versions")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, "/_gm/engine-versions")
|
||||
}
|
||||
}
|
||||
|
||||
// EngineVersionDisable handles POST /_gm/engine-versions/:version/disable.
|
||||
func (h *AdminConsoleHandlers) EngineVersionDisable() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.engineVersions == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
version := strings.TrimSpace(c.Param("version"))
|
||||
if _, err := h.engineVersions.Disable(c.Request.Context(), version); err != nil {
|
||||
h.logger.Error("admin console: disable engine version", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Disable failed", "Failed to disable the engine version.", "bad", "/_gm/engine-versions")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, "/_gm/engine-versions")
|
||||
}
|
||||
}
|
||||
|
||||
// formInt32 reads a non-negative int32 form field, defaulting to 0.
|
||||
func formInt32(c *gin.Context, name string) int32 {
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(c.PostForm(name)))
|
||||
if err != nil || parsed < 0 {
|
||||
return 0
|
||||
}
|
||||
return int32(parsed)
|
||||
}
|
||||
|
||||
// parseConsoleDateTime parses the value of an <input type="datetime-local">
|
||||
// (or an RFC 3339 timestamp) as UTC.
|
||||
func parseConsoleDateTime(raw string) (time.Time, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", time.RFC3339} {
|
||||
if t, err := time.ParseInLocation(layout, raw, time.UTC); err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, errors.New("invalid date/time")
|
||||
}
|
||||
|
||||
// toGamesListData maps a game page into the games list view model.
|
||||
func toGamesListData(page lobby.GamePage) adminconsole.GamesListData {
|
||||
data := adminconsole.GamesListData{
|
||||
Items: make([]adminconsole.GameRow, 0, len(page.Items)),
|
||||
Page: page.Page,
|
||||
PageSize: page.PageSize,
|
||||
Total: page.Total,
|
||||
PrevPage: page.Page - 1,
|
||||
NextPage: page.Page + 1,
|
||||
HasPrev: page.Page > 1,
|
||||
HasNext: page.Page*page.PageSize < page.Total,
|
||||
}
|
||||
for _, game := range page.Items {
|
||||
data.Items = append(data.Items, adminconsole.GameRow{
|
||||
GameID: game.GameID.String(),
|
||||
GameName: game.GameName,
|
||||
Visibility: game.Visibility,
|
||||
Status: game.Status,
|
||||
Owner: ownerLabel(game.OwnerUserID),
|
||||
Players: strconv.Itoa(int(game.MinPlayers)) + "–" + strconv.Itoa(int(game.MaxPlayers)),
|
||||
TurnSchedule: game.TurnSchedule,
|
||||
CreatedAt: fmtConsoleTime(game.CreatedAt),
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// toGameDetailData maps a game record and optional runtime record into the
|
||||
// detail view model.
|
||||
func toGameDetailData(game lobby.GameRecord, rec *runtime.RuntimeRecord) adminconsole.GameDetailData {
|
||||
data := adminconsole.GameDetailData{
|
||||
GameID: game.GameID.String(),
|
||||
GameName: game.GameName,
|
||||
Description: game.Description,
|
||||
Visibility: game.Visibility,
|
||||
Status: game.Status,
|
||||
Owner: ownerLabel(game.OwnerUserID),
|
||||
MinPlayers: game.MinPlayers,
|
||||
MaxPlayers: game.MaxPlayers,
|
||||
StartGapHours: game.StartGapHours,
|
||||
StartGapPlayers: game.StartGapPlayers,
|
||||
TurnSchedule: game.TurnSchedule,
|
||||
TargetEngineVersion: game.TargetEngineVersion,
|
||||
EnrollmentEndsAt: fmtConsoleTime(game.EnrollmentEndsAt),
|
||||
CreatedAt: fmtConsoleTime(game.CreatedAt),
|
||||
StartedAt: fmtConsoleTimePtr(game.StartedAt),
|
||||
FinishedAt: fmtConsoleTimePtr(game.FinishedAt),
|
||||
}
|
||||
if rec != nil {
|
||||
data.HasRuntime = true
|
||||
data.RuntimeStatus = rec.Status
|
||||
data.CurrentEngineVersion = rec.CurrentEngineVersion
|
||||
data.EngineHealth = rec.EngineHealth
|
||||
data.CurrentTurn = rec.CurrentTurn
|
||||
data.NextGenerationAt = fmtConsoleTimePtr(rec.NextGenerationAt)
|
||||
data.Paused = rec.Paused
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// toEngineVersionsData maps engine versions into the registry view model.
|
||||
func toEngineVersionsData(items []runtime.EngineVersion) adminconsole.EngineVersionsData {
|
||||
data := adminconsole.EngineVersionsData{Items: make([]adminconsole.EngineVersionRow, 0, len(items))}
|
||||
for _, v := range items {
|
||||
data.Items = append(data.Items, adminconsole.EngineVersionRow{
|
||||
Version: v.Version,
|
||||
ImageRef: v.ImageRef,
|
||||
Enabled: v.Enabled,
|
||||
CreatedAt: fmtConsoleTime(v.CreatedAt),
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// ownerLabel renders an optional owner id; public games have no owner.
|
||||
func ownerLabel(ownerID *uuid.UUID) string {
|
||||
if ownerID == nil {
|
||||
return "—"
|
||||
}
|
||||
return ownerID.String()
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"galaxy/backend/internal/adminconsole"
|
||||
"galaxy/backend/internal/lobby"
|
||||
"galaxy/backend/internal/runtime"
|
||||
"galaxy/backend/internal/server/middleware/basicauth"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type fakeGameAdmin struct {
|
||||
page lobby.GamePage
|
||||
game lobby.GameRecord
|
||||
getErr error
|
||||
created lobby.CreateGameInput
|
||||
|
||||
createCalls int
|
||||
forceStartCalls int
|
||||
forceStopCalls int
|
||||
banCalls int
|
||||
lastBanUser uuid.UUID
|
||||
lastBanReason string
|
||||
}
|
||||
|
||||
func (f *fakeGameAdmin) ListAdminGames(context.Context, int, int) (lobby.GamePage, error) {
|
||||
return f.page, nil
|
||||
}
|
||||
func (f *fakeGameAdmin) GetGame(context.Context, uuid.UUID) (lobby.GameRecord, error) {
|
||||
return f.game, f.getErr
|
||||
}
|
||||
func (f *fakeGameAdmin) CreateGame(_ context.Context, in lobby.CreateGameInput) (lobby.GameRecord, error) {
|
||||
f.createCalls++
|
||||
f.created = in
|
||||
return f.game, nil
|
||||
}
|
||||
func (f *fakeGameAdmin) AdminForceStart(context.Context, uuid.UUID) (lobby.GameRecord, error) {
|
||||
f.forceStartCalls++
|
||||
return f.game, nil
|
||||
}
|
||||
func (f *fakeGameAdmin) AdminForceStop(context.Context, uuid.UUID) (lobby.GameRecord, error) {
|
||||
f.forceStopCalls++
|
||||
return f.game, nil
|
||||
}
|
||||
func (f *fakeGameAdmin) AdminBanMember(_ context.Context, _, userID uuid.UUID, reason string) (lobby.Membership, error) {
|
||||
f.banCalls++
|
||||
f.lastBanUser = userID
|
||||
f.lastBanReason = reason
|
||||
return lobby.Membership{}, nil
|
||||
}
|
||||
|
||||
type fakeRuntimeAdmin struct {
|
||||
record runtime.RuntimeRecord
|
||||
getErr error
|
||||
restartCalls int
|
||||
forceNextCalls int
|
||||
patchCalls int
|
||||
lastPatchVersion string
|
||||
}
|
||||
|
||||
func (f *fakeRuntimeAdmin) GetRuntime(context.Context, uuid.UUID) (runtime.RuntimeRecord, error) {
|
||||
return f.record, f.getErr
|
||||
}
|
||||
func (f *fakeRuntimeAdmin) AdminRestart(context.Context, uuid.UUID) (runtime.OperationLog, error) {
|
||||
f.restartCalls++
|
||||
return runtime.OperationLog{}, nil
|
||||
}
|
||||
func (f *fakeRuntimeAdmin) AdminPatch(_ context.Context, _ uuid.UUID, target string) (runtime.OperationLog, error) {
|
||||
f.patchCalls++
|
||||
f.lastPatchVersion = target
|
||||
return runtime.OperationLog{}, nil
|
||||
}
|
||||
func (f *fakeRuntimeAdmin) AdminForceNextTurn(context.Context, uuid.UUID) (runtime.OperationLog, error) {
|
||||
f.forceNextCalls++
|
||||
return runtime.OperationLog{}, nil
|
||||
}
|
||||
|
||||
type fakeEngineVersionAdmin struct {
|
||||
list []runtime.EngineVersion
|
||||
registered runtime.RegisterInput
|
||||
registerCalls int
|
||||
disableCalls int
|
||||
lastDisabled string
|
||||
}
|
||||
|
||||
func (f *fakeEngineVersionAdmin) List(context.Context) ([]runtime.EngineVersion, error) {
|
||||
return f.list, nil
|
||||
}
|
||||
func (f *fakeEngineVersionAdmin) Register(_ context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error) {
|
||||
f.registerCalls++
|
||||
f.registered = in
|
||||
return runtime.EngineVersion{}, nil
|
||||
}
|
||||
func (f *fakeEngineVersionAdmin) Disable(_ context.Context, version string) (runtime.EngineVersion, error) {
|
||||
f.disableCalls++
|
||||
f.lastDisabled = version
|
||||
return runtime.EngineVersion{}, nil
|
||||
}
|
||||
|
||||
func newGamesConsoleRouter(t *testing.T, games GameAdmin, rt RuntimeAdmin, ev EngineVersionAdmin) (http.Handler, *adminconsole.CSRF) {
|
||||
t.Helper()
|
||||
csrf := adminconsole.NewCSRF([]byte("test-key"))
|
||||
handler, err := NewRouter(RouterDependencies{
|
||||
Logger: zap.NewNop(),
|
||||
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
||||
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{
|
||||
CSRF: csrf, Games: games, Runtime: rt, EngineVersions: ev,
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
}
|
||||
return handler, csrf
|
||||
}
|
||||
|
||||
func consoleGet(t *testing.T, router http.Handler, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.SetBasicAuth("ops", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func consolePost(t *testing.T, router http.Handler, path, form string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan"+path, strings.NewReader(form))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Origin", "https://galaxy.lan")
|
||||
req.SetBasicAuth("ops", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestConsoleGamesList(t *testing.T) {
|
||||
games := &fakeGameAdmin{page: lobby.GamePage{
|
||||
Items: []lobby.GameRecord{{GameID: uuid.New(), GameName: "Nova", Visibility: "public", Status: "enrollment_open"}},
|
||||
Page: 1, PageSize: 50, Total: 1,
|
||||
}}
|
||||
router, _ := newGamesConsoleRouter(t, games, nil, nil)
|
||||
|
||||
rec := consoleGet(t, router, "/_gm/games")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, want := range []string{"Nova", "public", "enrollment_open", "Create public game"} {
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Errorf("games list missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleGameDetailWithRuntime(t *testing.T) {
|
||||
id := uuid.New()
|
||||
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova", Status: "running"}}
|
||||
rt := &fakeRuntimeAdmin{record: runtime.RuntimeRecord{GameID: id, Status: "running", CurrentEngineVersion: "0.1.0", CurrentTurn: 7}}
|
||||
router, csrf := newGamesConsoleRouter(t, games, rt, nil)
|
||||
|
||||
rec := consoleGet(t, router, "/_gm/games/"+id.String())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, want := range []string{"Nova", "Force start", "Force stop", "0.1.0", "Patch", "Ban member", csrf.Token("ops")} {
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Errorf("game detail missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleGameDetailNoRuntime(t *testing.T) {
|
||||
id := uuid.New()
|
||||
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova"}}
|
||||
rt := &fakeRuntimeAdmin{getErr: errors.New("not found")}
|
||||
router, _ := newGamesConsoleRouter(t, games, rt, nil)
|
||||
|
||||
rec := consoleGet(t, router, "/_gm/games/"+id.String())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "No runtime record") {
|
||||
t.Error("expected a no-runtime note")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleGameCreate(t *testing.T) {
|
||||
id := uuid.New()
|
||||
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
||||
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
|
||||
|
||||
form := "_csrf=" + csrf.Token("ops") +
|
||||
"&game_name=Nova&description=d&min_players=2&max_players=8&start_gap_hours=0&start_gap_players=0" +
|
||||
"&enrollment_ends_at=2030-01-02T15:04&turn_schedule=@every+24h&target_engine_version=0.1.0"
|
||||
rec := consolePost(t, router, "/_gm/games", form)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Location"); got != "/_gm/games/"+id.String() {
|
||||
t.Errorf("redirect = %q, want detail page", got)
|
||||
}
|
||||
if games.createCalls != 1 {
|
||||
t.Fatalf("CreateGame called %d times, want 1", games.createCalls)
|
||||
}
|
||||
if games.created.Visibility != lobby.VisibilityPublic {
|
||||
t.Errorf("visibility = %q, want public", games.created.Visibility)
|
||||
}
|
||||
if games.created.GameName != "Nova" {
|
||||
t.Errorf("game name = %q", games.created.GameName)
|
||||
}
|
||||
if games.created.EnrollmentEndsAt.Year() != 2030 {
|
||||
t.Errorf("enrollment year = %d, want 2030", games.created.EnrollmentEndsAt.Year())
|
||||
}
|
||||
if games.created.OwnerUserID != nil {
|
||||
t.Error("public game must have a nil owner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleGameForceStart(t *testing.T) {
|
||||
id := uuid.New()
|
||||
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
||||
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
|
||||
|
||||
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "_csrf="+csrf.Token("ops"))
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", rec.Code)
|
||||
}
|
||||
if games.forceStartCalls != 1 {
|
||||
t.Errorf("AdminForceStart called %d times, want 1", games.forceStartCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleGameForceStartRejectsBadCSRF(t *testing.T) {
|
||||
id := uuid.New()
|
||||
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
||||
router, _ := newGamesConsoleRouter(t, games, nil, nil)
|
||||
|
||||
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
if games.forceStartCalls != 0 {
|
||||
t.Error("force-start must not run without a CSRF token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleGameBanMember(t *testing.T) {
|
||||
gameID := uuid.New()
|
||||
target := uuid.New()
|
||||
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}}
|
||||
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
|
||||
|
||||
form := "_csrf=" + csrf.Token("ops") + "&user_id=" + target.String() + "&reason=cheating"
|
||||
rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", form)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if games.banCalls != 1 || games.lastBanUser != target || games.lastBanReason != "cheating" {
|
||||
t.Errorf("ban recorded %d user=%s reason=%q", games.banCalls, games.lastBanUser, games.lastBanReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleGameBanMemberRejectsBadUUID(t *testing.T) {
|
||||
gameID := uuid.New()
|
||||
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}}
|
||||
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
|
||||
|
||||
rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", "_csrf="+csrf.Token("ops")+"&user_id=not-a-uuid")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
if games.banCalls != 0 {
|
||||
t.Error("ban must not run with an invalid user id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleRuntimePatch(t *testing.T) {
|
||||
id := uuid.New()
|
||||
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
||||
rt := &fakeRuntimeAdmin{}
|
||||
router, csrf := newGamesConsoleRouter(t, games, rt, nil)
|
||||
|
||||
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops")+"&target_version=0.1.1")
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", rec.Code)
|
||||
}
|
||||
if rt.patchCalls != 1 || rt.lastPatchVersion != "0.1.1" {
|
||||
t.Errorf("patch recorded %d version=%q", rt.patchCalls, rt.lastPatchVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleRuntimePatchMissingVersion(t *testing.T) {
|
||||
id := uuid.New()
|
||||
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
||||
rt := &fakeRuntimeAdmin{}
|
||||
router, csrf := newGamesConsoleRouter(t, games, rt, nil)
|
||||
|
||||
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops"))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
if rt.patchCalls != 0 {
|
||||
t.Error("patch must not run without a target version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleEngineVersions(t *testing.T) {
|
||||
ev := &fakeEngineVersionAdmin{list: []runtime.EngineVersion{{Version: "0.1.0", ImageRef: "img:0.1.0", Enabled: true}}}
|
||||
router, csrf := newGamesConsoleRouter(t, nil, nil, ev)
|
||||
|
||||
rec := consoleGet(t, router, "/_gm/engine-versions")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, want := range []string{"0.1.0", "img:0.1.0", "Register version", "Disable"} {
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Errorf("engine versions page missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
rec = consolePost(t, router, "/_gm/engine-versions", "_csrf="+csrf.Token("ops")+"&version=0.2.0&image_ref=img:0.2.0&enabled=true")
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("register status = %d, want 303", rec.Code)
|
||||
}
|
||||
if ev.registerCalls != 1 || ev.registered.Version != "0.2.0" || ev.registered.Enabled == nil || !*ev.registered.Enabled {
|
||||
t.Errorf("register recorded %d version=%q enabled=%v", ev.registerCalls, ev.registered.Version, ev.registered.Enabled)
|
||||
}
|
||||
|
||||
rec = consolePost(t, router, "/_gm/engine-versions/0.1.0/disable", "_csrf="+csrf.Token("ops"))
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("disable status = %d, want 303", rec.Code)
|
||||
}
|
||||
if ev.disableCalls != 1 || ev.lastDisabled != "0.1.0" {
|
||||
t.Errorf("disable recorded %d version=%q", ev.disableCalls, ev.lastDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleGamesUnavailable(t *testing.T) {
|
||||
router, _ := newGamesConsoleRouter(t, nil, nil, nil)
|
||||
|
||||
rec := consoleGet(t, router, "/_gm/games")
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/backend/internal/adminconsole"
|
||||
"galaxy/backend/internal/diplomail"
|
||||
"galaxy/backend/internal/mail"
|
||||
"galaxy/backend/internal/notification"
|
||||
"galaxy/backend/internal/server/clientip"
|
||||
"galaxy/backend/internal/server/middleware/basicauth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// MailAdmin is the subset of the mail service the console uses.
|
||||
type MailAdmin interface {
|
||||
AdminListDeliveries(ctx context.Context, page, pageSize int) (mail.AdminListDeliveriesPage, error)
|
||||
AdminGetDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error)
|
||||
AdminListAttempts(ctx context.Context, deliveryID uuid.UUID) ([]mail.Attempt, error)
|
||||
AdminResendDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error)
|
||||
AdminListDeadLetters(ctx context.Context, page, pageSize int) (mail.AdminListDeadLettersPage, error)
|
||||
}
|
||||
|
||||
// NotificationAdmin is the subset of the notification service the console uses.
|
||||
type NotificationAdmin interface {
|
||||
AdminListNotifications(ctx context.Context, page, pageSize int) (notification.AdminListNotificationsPage, error)
|
||||
AdminListDeadLetters(ctx context.Context, page, pageSize int) (notification.AdminListDeadLettersPage, error)
|
||||
AdminListMalformed(ctx context.Context, page, pageSize int) (notification.AdminListMalformedPage, error)
|
||||
}
|
||||
|
||||
// DiplomailAdmin is the subset of the diplomail service the console uses.
|
||||
type DiplomailAdmin interface {
|
||||
SendAdminMultiGameBroadcast(ctx context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error)
|
||||
}
|
||||
|
||||
const consoleSnapshotPageSize = 50
|
||||
|
||||
// MailPage renders GET /_gm/mail — paginated deliveries plus a dead-letter snapshot.
|
||||
func (h *AdminConsoleHandlers) MailPage() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.mail == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
deliveries, err := h.mail.AdminListDeliveries(ctx, page, pageSize)
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: list deliveries", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load deliveries.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
dead, err := h.mail.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize)
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: list mail dead-letters", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load dead-letters.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
h.render(c, http.StatusOK, "mail", "mail", "Mail", toMailData(deliveries, dead))
|
||||
}
|
||||
}
|
||||
|
||||
// MailDeliveryDetail renders GET /_gm/mail/deliveries/:delivery_id.
|
||||
func (h *AdminConsoleHandlers) MailDeliveryDetail() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.mail == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
deliveryID, ok := parseConsoleDeliveryID(c, h)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
delivery, err := h.mail.AdminGetDelivery(ctx, deliveryID)
|
||||
if err != nil {
|
||||
if errors.Is(err, mail.ErrDeliveryNotFound) {
|
||||
h.renderMessage(c, http.StatusNotFound, "mail", "Delivery not found", "No such delivery.", "bad", "/_gm/mail")
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin console: get delivery", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load the delivery.", "bad", "/_gm/mail")
|
||||
return
|
||||
}
|
||||
attempts, err := h.mail.AdminListAttempts(ctx, deliveryID)
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: list attempts", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load attempts.", "bad", "/_gm/mail")
|
||||
return
|
||||
}
|
||||
h.render(c, http.StatusOK, "mail_delivery", "mail", "Delivery", toMailDeliveryDetail(delivery, attempts))
|
||||
}
|
||||
}
|
||||
|
||||
// MailResend handles POST /_gm/mail/deliveries/:delivery_id/resend.
|
||||
func (h *AdminConsoleHandlers) MailResend() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.mail == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
deliveryID, ok := parseConsoleDeliveryID(c, h)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/mail/deliveries/" + deliveryID.String()
|
||||
if _, err := h.mail.AdminResendDelivery(c.Request.Context(), deliveryID); err != nil {
|
||||
h.logger.Error("admin console: resend delivery", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Resend failed", "Failed to resend the delivery (it may already be sent).", "bad", back)
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, back)
|
||||
}
|
||||
}
|
||||
|
||||
// NotificationsPage renders GET /_gm/notifications — notifications, dead-letters,
|
||||
// and malformed intents on one overview page.
|
||||
func (h *AdminConsoleHandlers) NotificationsPage() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.notifications == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Notifications", "Notification administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
notifications, err := h.notifications.AdminListNotifications(ctx, 1, consoleSnapshotPageSize)
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: list notifications", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load notifications.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
dead, err := h.notifications.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize)
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: list notification dead-letters", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load dead-letters.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
malformed, err := h.notifications.AdminListMalformed(ctx, 1, consoleSnapshotPageSize)
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: list malformed intents", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load malformed intents.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
h.render(c, http.StatusOK, "notifications", "mail", "Notifications", toNotificationsData(notifications, dead, malformed))
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastForm renders GET /_gm/broadcast.
|
||||
func (h *AdminConsoleHandlers) BroadcastForm() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.diplomail == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
h.render(c, http.StatusOK, "broadcast", "mail", "Broadcast", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastSend handles POST /_gm/broadcast — multi-game admin broadcast.
|
||||
func (h *AdminConsoleHandlers) BroadcastSend() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.diplomail == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
username, _ := basicauth.UsernameFromContext(c.Request.Context())
|
||||
gameIDs, err := parseGameIDList(c.PostForm("game_ids"))
|
||||
if err != nil {
|
||||
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "Game IDs must be valid UUIDs.", "bad", "/_gm/broadcast")
|
||||
return
|
||||
}
|
||||
_, total, err := h.diplomail.SendAdminMultiGameBroadcast(c.Request.Context(), diplomail.SendMultiGameBroadcastInput{
|
||||
CallerUsername: username,
|
||||
Scope: strings.TrimSpace(c.PostForm("scope")),
|
||||
GameIDs: gameIDs,
|
||||
RecipientScope: strings.TrimSpace(c.PostForm("recipients")),
|
||||
Subject: strings.TrimSpace(c.PostForm("subject")),
|
||||
Body: c.PostForm("body"),
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, diplomail.ErrInvalidInput) {
|
||||
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "The broadcast was rejected: check the scope, recipients, and body.", "bad", "/_gm/broadcast")
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin console: broadcast", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "mail", "Broadcast failed", "Failed to send the broadcast.", "bad", "/_gm/broadcast")
|
||||
return
|
||||
}
|
||||
h.renderMessage(c, http.StatusOK, "mail", "Broadcast sent", fmt.Sprintf("Broadcast delivered to %d recipients.", total), "ok", "/_gm/broadcast")
|
||||
}
|
||||
}
|
||||
|
||||
// parseConsoleDeliveryID parses the delivery_id path parameter, rendering a
|
||||
// console message page on failure.
|
||||
func parseConsoleDeliveryID(c *gin.Context, h *AdminConsoleHandlers) (uuid.UUID, bool) {
|
||||
parsed, err := uuid.Parse(c.Param("delivery_id"))
|
||||
if err != nil {
|
||||
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "delivery_id must be a valid UUID.", "bad", "/_gm/mail")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// parseGameIDList parses a comma-separated list of UUIDs, ignoring blanks.
|
||||
func parseGameIDList(raw string) ([]uuid.UUID, error) {
|
||||
fields := strings.Split(raw, ",")
|
||||
ids := make([]uuid.UUID, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
field = strings.TrimSpace(field)
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
parsed, err := uuid.Parse(field)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, parsed)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func toMailData(deliveries mail.AdminListDeliveriesPage, dead mail.AdminListDeadLettersPage) adminconsole.MailData {
|
||||
data := adminconsole.MailData{
|
||||
Deliveries: make([]adminconsole.MailDeliveryRow, 0, len(deliveries.Items)),
|
||||
DeadLetters: make([]adminconsole.MailDeadLetterRow, 0, len(dead.Items)),
|
||||
Page: deliveries.Page,
|
||||
PageSize: deliveries.PageSize,
|
||||
Total: deliveries.Total,
|
||||
PrevPage: deliveries.Page - 1,
|
||||
NextPage: deliveries.Page + 1,
|
||||
HasPrev: deliveries.Page > 1,
|
||||
HasNext: int64(deliveries.Page*deliveries.PageSize) < deliveries.Total,
|
||||
}
|
||||
for _, d := range deliveries.Items {
|
||||
data.Deliveries = append(data.Deliveries, adminconsole.MailDeliveryRow{
|
||||
DeliveryID: d.DeliveryID.String(),
|
||||
Template: d.TemplateID,
|
||||
Status: d.Status,
|
||||
Attempts: d.Attempts,
|
||||
NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt),
|
||||
Created: fmtConsoleTime(d.CreatedAt),
|
||||
})
|
||||
}
|
||||
for _, d := range dead.Items {
|
||||
data.DeadLetters = append(data.DeadLetters, adminconsole.MailDeadLetterRow{
|
||||
DeliveryID: d.DeliveryID.String(),
|
||||
Reason: d.Reason,
|
||||
Archived: fmtConsoleTime(d.ArchivedAt),
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func toMailDeliveryDetail(d mail.Delivery, attempts []mail.Attempt) adminconsole.MailDeliveryDetail {
|
||||
detail := adminconsole.MailDeliveryDetail{
|
||||
DeliveryID: d.DeliveryID.String(),
|
||||
Template: d.TemplateID,
|
||||
Status: d.Status,
|
||||
Attempts: d.Attempts,
|
||||
NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt),
|
||||
LastError: d.LastError,
|
||||
Created: fmtConsoleTime(d.CreatedAt),
|
||||
Sent: fmtConsoleTimePtr(d.SentAt),
|
||||
DeadLettered: fmtConsoleTimePtr(d.DeadLetteredAt),
|
||||
CanResend: d.Status != mail.StatusSent,
|
||||
AttemptRows: make([]adminconsole.MailAttemptRow, 0, len(attempts)),
|
||||
}
|
||||
for _, a := range attempts {
|
||||
detail.AttemptRows = append(detail.AttemptRows, adminconsole.MailAttemptRow{
|
||||
AttemptNo: a.AttemptNo,
|
||||
Outcome: a.Outcome,
|
||||
Started: fmtConsoleTime(a.StartedAt),
|
||||
Finished: fmtConsoleTimePtr(a.FinishedAt),
|
||||
Error: a.Error,
|
||||
})
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
func toNotificationsData(notifications notification.AdminListNotificationsPage, dead notification.AdminListDeadLettersPage, malformed notification.AdminListMalformedPage) adminconsole.NotificationsData {
|
||||
data := adminconsole.NotificationsData{
|
||||
Notifications: make([]adminconsole.NotificationRow, 0, len(notifications.Items)),
|
||||
DeadLetters: make([]adminconsole.NotificationDeadLetterRow, 0, len(dead.Items)),
|
||||
Malformed: make([]adminconsole.MalformedRow, 0, len(malformed.Items)),
|
||||
}
|
||||
for _, n := range notifications.Items {
|
||||
data.Notifications = append(data.Notifications, adminconsole.NotificationRow{
|
||||
NotificationID: n.NotificationID.String(),
|
||||
Kind: n.Kind,
|
||||
UserID: optionalUUID(n.UserID),
|
||||
Created: fmtConsoleTime(n.CreatedAt),
|
||||
})
|
||||
}
|
||||
for _, d := range dead.Items {
|
||||
data.DeadLetters = append(data.DeadLetters, adminconsole.NotificationDeadLetterRow{
|
||||
NotificationID: d.NotificationID.String(),
|
||||
RouteID: d.RouteID.String(),
|
||||
Reason: d.Reason,
|
||||
Archived: fmtConsoleTime(d.ArchivedAt),
|
||||
})
|
||||
}
|
||||
for _, m := range malformed.Items {
|
||||
data.Malformed = append(data.Malformed, adminconsole.MalformedRow{
|
||||
ID: m.ID.String(),
|
||||
Reason: m.Reason,
|
||||
Received: fmtConsoleTime(m.ReceivedAt),
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// optionalUUID renders a nullable user id; system-scoped rows have none.
|
||||
func optionalUUID(id *uuid.UUID) string {
|
||||
if id == nil {
|
||||
return "—"
|
||||
}
|
||||
return id.String()
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/adminconsole"
|
||||
"galaxy/backend/internal/diplomail"
|
||||
"galaxy/backend/internal/mail"
|
||||
"galaxy/backend/internal/notification"
|
||||
"galaxy/backend/internal/server/middleware/basicauth"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type fakeMailAdmin struct {
|
||||
deliveries mail.AdminListDeliveriesPage
|
||||
dead mail.AdminListDeadLettersPage
|
||||
delivery mail.Delivery
|
||||
getErr error
|
||||
attempts []mail.Attempt
|
||||
resendCalls int
|
||||
}
|
||||
|
||||
func (f *fakeMailAdmin) AdminListDeliveries(context.Context, int, int) (mail.AdminListDeliveriesPage, error) {
|
||||
return f.deliveries, nil
|
||||
}
|
||||
func (f *fakeMailAdmin) AdminGetDelivery(context.Context, uuid.UUID) (mail.Delivery, error) {
|
||||
return f.delivery, f.getErr
|
||||
}
|
||||
func (f *fakeMailAdmin) AdminListAttempts(context.Context, uuid.UUID) ([]mail.Attempt, error) {
|
||||
return f.attempts, nil
|
||||
}
|
||||
func (f *fakeMailAdmin) AdminResendDelivery(context.Context, uuid.UUID) (mail.Delivery, error) {
|
||||
f.resendCalls++
|
||||
return f.delivery, nil
|
||||
}
|
||||
func (f *fakeMailAdmin) AdminListDeadLetters(context.Context, int, int) (mail.AdminListDeadLettersPage, error) {
|
||||
return f.dead, nil
|
||||
}
|
||||
|
||||
type fakeNotificationAdmin struct {
|
||||
notifications notification.AdminListNotificationsPage
|
||||
dead notification.AdminListDeadLettersPage
|
||||
malformed notification.AdminListMalformedPage
|
||||
}
|
||||
|
||||
func (f *fakeNotificationAdmin) AdminListNotifications(context.Context, int, int) (notification.AdminListNotificationsPage, error) {
|
||||
return f.notifications, nil
|
||||
}
|
||||
func (f *fakeNotificationAdmin) AdminListDeadLetters(context.Context, int, int) (notification.AdminListDeadLettersPage, error) {
|
||||
return f.dead, nil
|
||||
}
|
||||
func (f *fakeNotificationAdmin) AdminListMalformed(context.Context, int, int) (notification.AdminListMalformedPage, error) {
|
||||
return f.malformed, nil
|
||||
}
|
||||
|
||||
type fakeDiplomailAdmin struct {
|
||||
total int
|
||||
err error
|
||||
broadcastCalls int
|
||||
last diplomail.SendMultiGameBroadcastInput
|
||||
}
|
||||
|
||||
func (f *fakeDiplomailAdmin) SendAdminMultiGameBroadcast(_ context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error) {
|
||||
f.broadcastCalls++
|
||||
f.last = in
|
||||
if f.err != nil {
|
||||
return nil, 0, f.err
|
||||
}
|
||||
return nil, f.total, nil
|
||||
}
|
||||
|
||||
func mailConsoleRouter(t *testing.T, m MailAdmin, n NotificationAdmin, d DiplomailAdmin) (http.Handler, *adminconsole.CSRF) {
|
||||
t.Helper()
|
||||
csrf := adminconsole.NewCSRF([]byte("test-key"))
|
||||
handler, err := NewRouter(RouterDependencies{
|
||||
Logger: zap.NewNop(),
|
||||
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
||||
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Mail: m, Notifications: n, Diplomail: d}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
}
|
||||
return handler, csrf
|
||||
}
|
||||
|
||||
func TestConsoleMailPage(t *testing.T) {
|
||||
id := uuid.New()
|
||||
m := &fakeMailAdmin{
|
||||
deliveries: mail.AdminListDeliveriesPage{
|
||||
Items: []mail.Delivery{{DeliveryID: id, TemplateID: "auth.login_code", Status: "pending", CreatedAt: time.Now()}},
|
||||
Page: 1, PageSize: 50, Total: 1,
|
||||
},
|
||||
dead: mail.AdminListDeadLettersPage{
|
||||
Items: []mail.DeadLetter{{DeliveryID: uuid.New(), Reason: "smtp 550", ArchivedAt: time.Now()}},
|
||||
},
|
||||
}
|
||||
router, _ := mailConsoleRouter(t, m, nil, nil)
|
||||
|
||||
rec := consoleGet(t, router, "/_gm/mail")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, want := range []string{"auth.login_code", "pending", "Dead-letters", "smtp 550"} {
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Errorf("mail page missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleMailDeliveryDetail(t *testing.T) {
|
||||
id := uuid.New()
|
||||
m := &fakeMailAdmin{
|
||||
delivery: mail.Delivery{DeliveryID: id, TemplateID: "auth.login_code", Status: "pending", Attempts: 2},
|
||||
attempts: []mail.Attempt{{AttemptNo: 1, Outcome: "transient_failure", StartedAt: time.Now(), Error: "timeout"}},
|
||||
}
|
||||
router, _ := mailConsoleRouter(t, m, nil, nil)
|
||||
|
||||
rec := consoleGet(t, router, "/_gm/mail/deliveries/"+id.String())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, want := range []string{id.String(), "auth.login_code", "Attempts", "transient_failure", "Resend"} {
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Errorf("delivery detail missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleMailDeliveryDetailNotFound(t *testing.T) {
|
||||
m := &fakeMailAdmin{getErr: mail.ErrDeliveryNotFound}
|
||||
router, _ := mailConsoleRouter(t, m, nil, nil)
|
||||
|
||||
rec := consoleGet(t, router, "/_gm/mail/deliveries/"+uuid.New().String())
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleMailResend(t *testing.T) {
|
||||
id := uuid.New()
|
||||
m := &fakeMailAdmin{delivery: mail.Delivery{DeliveryID: id}}
|
||||
router, csrf := mailConsoleRouter(t, m, nil, nil)
|
||||
|
||||
rec := consolePost(t, router, "/_gm/mail/deliveries/"+id.String()+"/resend", "_csrf="+csrf.Token("ops"))
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", rec.Code)
|
||||
}
|
||||
if m.resendCalls != 1 {
|
||||
t.Errorf("AdminResendDelivery called %d times, want 1", m.resendCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleMailResendRejectsBadCSRF(t *testing.T) {
|
||||
id := uuid.New()
|
||||
m := &fakeMailAdmin{delivery: mail.Delivery{DeliveryID: id}}
|
||||
router, _ := mailConsoleRouter(t, m, nil, nil)
|
||||
|
||||
rec := consolePost(t, router, "/_gm/mail/deliveries/"+id.String()+"/resend", "")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
if m.resendCalls != 0 {
|
||||
t.Error("resend must not run without a CSRF token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleNotificationsPage(t *testing.T) {
|
||||
n := &fakeNotificationAdmin{
|
||||
notifications: notification.AdminListNotificationsPage{Items: []notification.Notification{{NotificationID: uuid.New(), Kind: "lobby.invite.received"}}},
|
||||
dead: notification.AdminListDeadLettersPage{Items: []notification.DeadLetter{{NotificationID: uuid.New(), RouteID: uuid.New(), Reason: "push gone"}}},
|
||||
malformed: notification.AdminListMalformedPage{Items: []notification.MalformedIntent{{ID: uuid.New(), Reason: "bad shape"}}},
|
||||
}
|
||||
router, _ := mailConsoleRouter(t, nil, n, nil)
|
||||
|
||||
rec := consoleGet(t, router, "/_gm/notifications")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, want := range []string{"lobby.invite.received", "push gone", "bad shape", "Malformed intents"} {
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Errorf("notifications page missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleBroadcastForm(t *testing.T) {
|
||||
router, _ := mailConsoleRouter(t, nil, nil, &fakeDiplomailAdmin{})
|
||||
|
||||
rec := consoleGet(t, router, "/_gm/broadcast")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Send broadcast") {
|
||||
t.Error("broadcast form missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleBroadcastSend(t *testing.T) {
|
||||
d := &fakeDiplomailAdmin{total: 5}
|
||||
router, csrf := mailConsoleRouter(t, nil, nil, d)
|
||||
|
||||
form := "_csrf=" + csrf.Token("ops") + "&scope=all_running&recipients=active&body=hello"
|
||||
rec := consolePost(t, router, "/_gm/broadcast", form)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "5 recipients") {
|
||||
t.Errorf("broadcast result missing recipient count; body=%s", rec.Body.String())
|
||||
}
|
||||
if d.broadcastCalls != 1 || d.last.Scope != "all_running" || d.last.Body != "hello" || d.last.CallerUsername != "ops" {
|
||||
t.Errorf("broadcast input = %+v (calls=%d)", d.last, d.broadcastCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleBroadcastSendBadGameIDs(t *testing.T) {
|
||||
d := &fakeDiplomailAdmin{}
|
||||
router, csrf := mailConsoleRouter(t, nil, nil, d)
|
||||
|
||||
form := "_csrf=" + csrf.Token("ops") + "&scope=selected&game_ids=not-a-uuid&body=hello"
|
||||
rec := consolePost(t, router, "/_gm/broadcast", form)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
if d.broadcastCalls != 0 {
|
||||
t.Error("broadcast must not run with invalid game ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleMailUnavailable(t *testing.T) {
|
||||
router, _ := mailConsoleRouter(t, nil, nil, nil)
|
||||
|
||||
rec := consoleGet(t, router, "/_gm/mail")
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/backend/internal/admin"
|
||||
"galaxy/backend/internal/adminconsole"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// OperatorAdmin is the subset of the admin-account service the console uses.
|
||||
// *admin.Service satisfies it.
|
||||
type OperatorAdmin interface {
|
||||
List(ctx context.Context) ([]admin.Admin, error)
|
||||
Create(ctx context.Context, in admin.CreateInput) (admin.Admin, error)
|
||||
Disable(ctx context.Context, username string) (admin.Admin, error)
|
||||
Enable(ctx context.Context, username string) (admin.Admin, error)
|
||||
ResetPassword(ctx context.Context, username, password string) (admin.Admin, error)
|
||||
}
|
||||
|
||||
// OperatorsList renders GET /_gm/operators.
|
||||
func (h *AdminConsoleHandlers) OperatorsList() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.operators == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
admins, err := h.operators.List(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: list operators", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "operators", "Operators", "Failed to load operators.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
h.render(c, http.StatusOK, "operators", "operators", "Operators", toOperatorsData(admins))
|
||||
}
|
||||
}
|
||||
|
||||
// OperatorCreate handles POST /_gm/operators.
|
||||
func (h *AdminConsoleHandlers) OperatorCreate() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.operators == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
_, err := h.operators.Create(c.Request.Context(), admin.CreateInput{
|
||||
Username: strings.TrimSpace(c.PostForm("username")),
|
||||
Password: c.PostForm("password"),
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, admin.ErrUsernameTaken):
|
||||
h.renderMessage(c, http.StatusConflict, "operators", "Username taken", "That username is already in use.", "bad", "/_gm/operators")
|
||||
case errors.Is(err, admin.ErrInvalidInput):
|
||||
h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "Username and password are required.", "bad", "/_gm/operators")
|
||||
default:
|
||||
h.logger.Error("admin console: create operator", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "operators", "Create failed", "Failed to create the operator.", "bad", "/_gm/operators")
|
||||
}
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, "/_gm/operators")
|
||||
}
|
||||
}
|
||||
|
||||
// OperatorDisable handles POST /_gm/operators/:username/disable.
|
||||
func (h *AdminConsoleHandlers) OperatorDisable() gin.HandlerFunc {
|
||||
return h.operatorAction("disable", func(ctx context.Context, username string) error {
|
||||
_, err := h.operators.Disable(ctx, username)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// OperatorEnable handles POST /_gm/operators/:username/enable.
|
||||
func (h *AdminConsoleHandlers) OperatorEnable() gin.HandlerFunc {
|
||||
return h.operatorAction("enable", func(ctx context.Context, username string) error {
|
||||
_, err := h.operators.Enable(ctx, username)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// OperatorResetPassword handles POST /_gm/operators/:username/reset-password.
|
||||
func (h *AdminConsoleHandlers) OperatorResetPassword() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.operators == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
username := c.Param("username")
|
||||
password := c.PostForm("password")
|
||||
if strings.TrimSpace(password) == "" {
|
||||
h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "A new password is required.", "bad", "/_gm/operators")
|
||||
return
|
||||
}
|
||||
if _, err := h.operators.ResetPassword(c.Request.Context(), username, password); err != nil {
|
||||
if errors.Is(err, admin.ErrNotFound) {
|
||||
h.renderMessage(c, http.StatusNotFound, "operators", "Operator not found", "No such operator.", "bad", "/_gm/operators")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrInvalidInput) {
|
||||
h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "The password was rejected.", "bad", "/_gm/operators")
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin console: reset operator password", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "operators", "Reset failed", "Failed to reset the password.", "bad", "/_gm/operators")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, "/_gm/operators")
|
||||
}
|
||||
}
|
||||
|
||||
// operatorAction is the shared shape for operator POST actions that take only
|
||||
// the username and redirect back to the list.
|
||||
func (h *AdminConsoleHandlers) operatorAction(label string, run func(context.Context, string) error) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.operators == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
if err := run(c.Request.Context(), c.Param("username")); err != nil {
|
||||
if errors.Is(err, admin.ErrNotFound) {
|
||||
h.renderMessage(c, http.StatusNotFound, "operators", "Operator not found", "No such operator.", "bad", "/_gm/operators")
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin console: operator "+label, zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "operators", "Action failed", "The "+label+" action failed.", "bad", "/_gm/operators")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, "/_gm/operators")
|
||||
}
|
||||
}
|
||||
|
||||
// toOperatorsData maps admin accounts into the operators view model.
|
||||
func toOperatorsData(admins []admin.Admin) adminconsole.OperatorsData {
|
||||
data := adminconsole.OperatorsData{Items: make([]adminconsole.OperatorRow, 0, len(admins))}
|
||||
for _, a := range admins {
|
||||
data.Items = append(data.Items, adminconsole.OperatorRow{
|
||||
Username: a.Username,
|
||||
CreatedAt: fmtConsoleTime(a.CreatedAt),
|
||||
LastUsedAt: fmtConsoleTimePtr(a.LastUsedAt),
|
||||
Disabled: a.DisabledAt != nil,
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"galaxy/backend/internal/admin"
|
||||
"galaxy/backend/internal/adminconsole"
|
||||
"galaxy/backend/internal/server/middleware/basicauth"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type fakeOperatorAdmin struct {
|
||||
list []admin.Admin
|
||||
createErr error
|
||||
|
||||
created admin.CreateInput
|
||||
createCalls int
|
||||
disableCalls int
|
||||
enableCalls int
|
||||
resetCalls int
|
||||
lastResetUser string
|
||||
lastResetPass string
|
||||
}
|
||||
|
||||
func (f *fakeOperatorAdmin) List(context.Context) ([]admin.Admin, error) { return f.list, nil }
|
||||
func (f *fakeOperatorAdmin) Create(_ context.Context, in admin.CreateInput) (admin.Admin, error) {
|
||||
f.createCalls++
|
||||
f.created = in
|
||||
if f.createErr != nil {
|
||||
return admin.Admin{}, f.createErr
|
||||
}
|
||||
return admin.Admin{Username: in.Username}, nil
|
||||
}
|
||||
func (f *fakeOperatorAdmin) Disable(_ context.Context, username string) (admin.Admin, error) {
|
||||
f.disableCalls++
|
||||
return admin.Admin{Username: username}, nil
|
||||
}
|
||||
func (f *fakeOperatorAdmin) Enable(_ context.Context, username string) (admin.Admin, error) {
|
||||
f.enableCalls++
|
||||
return admin.Admin{Username: username}, nil
|
||||
}
|
||||
func (f *fakeOperatorAdmin) ResetPassword(_ context.Context, username, password string) (admin.Admin, error) {
|
||||
f.resetCalls++
|
||||
f.lastResetUser = username
|
||||
f.lastResetPass = password
|
||||
return admin.Admin{Username: username}, nil
|
||||
}
|
||||
|
||||
func operatorsRouter(t *testing.T, operators OperatorAdmin) (http.Handler, *adminconsole.CSRF) {
|
||||
t.Helper()
|
||||
csrf := adminconsole.NewCSRF([]byte("test-key"))
|
||||
handler, err := NewRouter(RouterDependencies{
|
||||
Logger: zap.NewNop(),
|
||||
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
||||
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Operators: operators}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
}
|
||||
return handler, csrf
|
||||
}
|
||||
|
||||
func TestConsoleOperatorsList(t *testing.T) {
|
||||
fake := &fakeOperatorAdmin{list: []admin.Admin{{Username: "root"}}}
|
||||
router, _ := operatorsRouter(t, fake)
|
||||
|
||||
rec := consoleGet(t, router, "/_gm/operators")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, want := range []string{"root", "Create operator", "Reset"} {
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Errorf("operators page missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleOperatorCreate(t *testing.T) {
|
||||
fake := &fakeOperatorAdmin{}
|
||||
router, csrf := operatorsRouter(t, fake)
|
||||
|
||||
rec := consolePost(t, router, "/_gm/operators", "_csrf="+csrf.Token("ops")+"&username=mod&password=s3cret")
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if fake.createCalls != 1 || fake.created.Username != "mod" || fake.created.Password != "s3cret" {
|
||||
t.Errorf("create recorded %d username=%q", fake.createCalls, fake.created.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleOperatorCreateConflict(t *testing.T) {
|
||||
fake := &fakeOperatorAdmin{createErr: admin.ErrUsernameTaken}
|
||||
router, csrf := operatorsRouter(t, fake)
|
||||
|
||||
rec := consolePost(t, router, "/_gm/operators", "_csrf="+csrf.Token("ops")+"&username=root&password=x")
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleOperatorDisableEnable(t *testing.T) {
|
||||
fake := &fakeOperatorAdmin{}
|
||||
router, csrf := operatorsRouter(t, fake)
|
||||
|
||||
if rec := consolePost(t, router, "/_gm/operators/root/disable", "_csrf="+csrf.Token("ops")); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("disable status = %d, want 303", rec.Code)
|
||||
}
|
||||
if rec := consolePost(t, router, "/_gm/operators/root/enable", "_csrf="+csrf.Token("ops")); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("enable status = %d, want 303", rec.Code)
|
||||
}
|
||||
if fake.disableCalls != 1 || fake.enableCalls != 1 {
|
||||
t.Errorf("disable=%d enable=%d, want 1/1", fake.disableCalls, fake.enableCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleOperatorResetPassword(t *testing.T) {
|
||||
fake := &fakeOperatorAdmin{}
|
||||
router, csrf := operatorsRouter(t, fake)
|
||||
|
||||
rec := consolePost(t, router, "/_gm/operators/root/reset-password", "_csrf="+csrf.Token("ops")+"&password=newpass")
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", rec.Code)
|
||||
}
|
||||
if fake.resetCalls != 1 || fake.lastResetUser != "root" || fake.lastResetPass != "newpass" {
|
||||
t.Errorf("reset recorded %d user=%q", fake.resetCalls, fake.lastResetUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleOperatorResetPasswordMissing(t *testing.T) {
|
||||
fake := &fakeOperatorAdmin{}
|
||||
router, csrf := operatorsRouter(t, fake)
|
||||
|
||||
rec := consolePost(t, router, "/_gm/operators/root/reset-password", "_csrf="+csrf.Token("ops"))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
if fake.resetCalls != 0 {
|
||||
t.Error("reset must not run without a password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleOperatorRejectsBadCSRF(t *testing.T) {
|
||||
fake := &fakeOperatorAdmin{}
|
||||
router, _ := operatorsRouter(t, fake)
|
||||
|
||||
rec := consolePost(t, router, "/_gm/operators/root/disable", "")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
if fake.disableCalls != 0 {
|
||||
t.Error("disable must not run without a CSRF token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleOperatorsUnavailable(t *testing.T) {
|
||||
router, _ := operatorsRouter(t, nil)
|
||||
|
||||
rec := consoleGet(t, router, "/_gm/operators")
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"galaxy/backend/internal/adminconsole"
|
||||
"galaxy/backend/internal/opsstatus"
|
||||
"galaxy/backend/internal/server/middleware/basicauth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// fakeMonitor is a static opsstatus.Reader for dashboard rendering tests.
|
||||
type fakeMonitor struct {
|
||||
snapshot opsstatus.Snapshot
|
||||
}
|
||||
|
||||
func (f fakeMonitor) Collect(context.Context) opsstatus.Snapshot {
|
||||
return f.snapshot
|
||||
}
|
||||
|
||||
func newConsoleTestRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
handler, err := NewRouter(RouterDependencies{
|
||||
Logger: zap.NewNop(),
|
||||
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
||||
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: adminconsole.NewCSRF([]byte("test-key"))}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
func TestAdminConsoleRequiresAuth(t *testing.T) {
|
||||
router := newConsoleTestRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/_gm/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("WWW-Authenticate"); !strings.Contains(got, "Basic") {
|
||||
t.Fatalf("WWW-Authenticate = %q, want a Basic challenge", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminConsoleDashboardRenders(t *testing.T) {
|
||||
router := newConsoleTestRouter(t)
|
||||
|
||||
for _, path := range []string{"/_gm", "/_gm/"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.SetBasicAuth("ops", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s status = %d, want 200; body=%s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Errorf("GET %s content-type = %q, want text/html", path, ct)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Dashboard") {
|
||||
t.Errorf("GET %s body missing the dashboard heading", path)
|
||||
}
|
||||
if !strings.Contains(body, "ops") {
|
||||
t.Errorf("GET %s body missing the operator name", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminConsoleDashboardShowsMonitoring(t *testing.T) {
|
||||
monitor := fakeMonitor{snapshot: opsstatus.Snapshot{
|
||||
PostgresHealthy: true,
|
||||
Runtimes: []opsstatus.StatusCount{{Status: "running", Count: 3}, {Status: "stopped", Count: 1}},
|
||||
MailDeliveries: []opsstatus.StatusCount{{Status: "pending", Count: 2}},
|
||||
NotificationRoutes: []opsstatus.StatusCount{{Status: "published", Count: 9}},
|
||||
NotificationMalformed: 4,
|
||||
Errors: []string{"notification route counts: boom"},
|
||||
}}
|
||||
|
||||
handler, err := NewRouter(RouterDependencies{
|
||||
Logger: zap.NewNop(),
|
||||
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
||||
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{
|
||||
CSRF: adminconsole.NewCSRF([]byte("test-key")),
|
||||
Monitor: monitor,
|
||||
Ready: func() bool { return true },
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/_gm/", nil)
|
||||
req.SetBasicAuth("ops", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"Game runtimes", "running", "stopped",
|
||||
"Mail deliveries", "pending",
|
||||
"Notification routes", "published",
|
||||
"Malformed notifications",
|
||||
"notification route counts: boom",
|
||||
"healthy",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("dashboard body missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminConsoleDashboardWithoutMonitor(t *testing.T) {
|
||||
router := newConsoleTestRouter(t) // no monitor wired
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/_gm/", nil)
|
||||
req.SetBasicAuth("ops", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Monitoring is not wired") {
|
||||
t.Error("dashboard without a monitor should note that monitoring is unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminConsoleServesAsset(t *testing.T) {
|
||||
router := newConsoleTestRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/_gm/assets/console.css", nil)
|
||||
req.SetBasicAuth("ops", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("asset status = %d, want 200", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/css") {
|
||||
t.Errorf("asset content-type = %q, want text/css", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminConsoleRequireCSRF(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
csrf := adminconsole.NewCSRF([]byte("test-key"))
|
||||
console := NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf})
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(func(c *gin.Context) {
|
||||
c.Request = c.Request.WithContext(basicauth.WithUsername(c.Request.Context(), "ops"))
|
||||
c.Next()
|
||||
})
|
||||
engine.Use(console.RequireCSRF())
|
||||
engine.GET("/x", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
engine.POST("/x", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
token := csrf.Token("ops")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
form string
|
||||
origin string
|
||||
host string
|
||||
want int
|
||||
}{
|
||||
{"get is a safe method", http.MethodGet, "", "", "galaxy.lan", http.StatusOK},
|
||||
{"valid token, same origin", http.MethodPost, "_csrf=" + token, "https://galaxy.lan", "galaxy.lan", http.StatusOK},
|
||||
{"valid token, no origin header", http.MethodPost, "_csrf=" + token, "", "galaxy.lan", http.StatusOK},
|
||||
{"missing token", http.MethodPost, "", "https://galaxy.lan", "galaxy.lan", http.StatusForbidden},
|
||||
{"wrong token", http.MethodPost, "_csrf=bogus", "https://galaxy.lan", "galaxy.lan", http.StatusForbidden},
|
||||
{"cross-origin", http.MethodPost, "_csrf=" + token, "https://evil.example", "galaxy.lan", http.StatusForbidden},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var body io.Reader
|
||||
if tc.form != "" {
|
||||
body = strings.NewReader(tc.form)
|
||||
}
|
||||
req := httptest.NewRequest(tc.method, "/x", body)
|
||||
if tc.form != "" {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
if tc.origin != "" {
|
||||
req.Header.Set("Origin", tc.origin)
|
||||
}
|
||||
req.Host = tc.host
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tc.want {
|
||||
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, tc.want, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/adminconsole"
|
||||
"galaxy/backend/internal/server/middleware/basicauth"
|
||||
"galaxy/backend/internal/user"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// UserAdmin is the subset of the user service the operator console depends on.
|
||||
// *user.Service satisfies it; tests supply a fake so the console pages render
|
||||
// without a database.
|
||||
type UserAdmin interface {
|
||||
ListAccounts(ctx context.Context, page, pageSize int) (user.AccountPage, error)
|
||||
GetAccount(ctx context.Context, userID uuid.UUID) (user.Account, error)
|
||||
ApplySanction(ctx context.Context, input user.ApplySanctionInput) (user.Account, error)
|
||||
ApplyEntitlement(ctx context.Context, input user.ApplyEntitlementInput) (user.Account, error)
|
||||
SoftDelete(ctx context.Context, userID uuid.UUID, actor user.ActorRef) error
|
||||
}
|
||||
|
||||
// consoleTiers lists the selectable entitlement tiers in display order.
|
||||
var consoleTiers = []string{user.TierFree, user.TierMonthly, user.TierYearly, user.TierPermanent}
|
||||
|
||||
// UsersList renders GET /_gm/users — the paginated account list.
|
||||
func (h *AdminConsoleHandlers) UsersList() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.users == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||
|
||||
result, err := h.users.ListAccounts(c.Request.Context(), page, pageSize)
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: list users", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "users", "Users", "Failed to load users.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
h.render(c, http.StatusOK, "users", "users", "Users", toUsersListData(result))
|
||||
}
|
||||
}
|
||||
|
||||
// UserDetail renders GET /_gm/users/:user_id.
|
||||
func (h *AdminConsoleHandlers) UserDetail() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.users == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
userID, ok := parseUserIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
account, err := h.users.GetAccount(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, user.ErrAccountNotFound) {
|
||||
h.renderMessage(c, http.StatusNotFound, "users", "User not found", "No such user, or the account has been soft-deleted.", "bad", "/_gm/users")
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin console: get user", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "users", "Users", "Failed to load the user.", "bad", "/_gm/users")
|
||||
return
|
||||
}
|
||||
h.render(c, http.StatusOK, "user_detail", "users", account.Email, toUserDetailData(account))
|
||||
}
|
||||
}
|
||||
|
||||
// UserBlock handles POST /_gm/users/:user_id/block — applies a permanent block.
|
||||
func (h *AdminConsoleHandlers) UserBlock() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.users == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
userID, ok := parseUserIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/users/" + userID.String()
|
||||
reason := strings.TrimSpace(c.PostForm("reason_code"))
|
||||
if reason == "" {
|
||||
h.renderMessage(c, http.StatusBadRequest, "users", "Invalid input", "A reason is required to block a user.", "bad", back)
|
||||
return
|
||||
}
|
||||
_, err := h.users.ApplySanction(c.Request.Context(), user.ApplySanctionInput{
|
||||
UserID: userID,
|
||||
SanctionCode: user.SanctionCodePermanentBlock,
|
||||
Scope: "account",
|
||||
ReasonCode: reason,
|
||||
Actor: actorFromContext(c),
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: block user", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "users", "Block failed", "Failed to block the user.", "bad", back)
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, back)
|
||||
}
|
||||
}
|
||||
|
||||
// UserEntitlement handles POST /_gm/users/:user_id/entitlement.
|
||||
func (h *AdminConsoleHandlers) UserEntitlement() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.users == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
userID, ok := parseUserIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/users/" + userID.String()
|
||||
tier := strings.TrimSpace(c.PostForm("tier"))
|
||||
source := strings.TrimSpace(c.PostForm("source"))
|
||||
if source == "" {
|
||||
source = "admin"
|
||||
}
|
||||
_, err := h.users.ApplyEntitlement(c.Request.Context(), user.ApplyEntitlementInput{
|
||||
UserID: userID,
|
||||
Tier: tier,
|
||||
Source: source,
|
||||
Actor: actorFromContext(c),
|
||||
ReasonCode: strings.TrimSpace(c.PostForm("reason_code")),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, user.ErrInvalidInput) {
|
||||
h.renderMessage(c, http.StatusBadRequest, "users", "Invalid input", "The entitlement request was rejected: check the tier.", "bad", back)
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin console: apply entitlement", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "users", "Entitlement failed", "Failed to update the entitlement.", "bad", back)
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, back)
|
||||
}
|
||||
}
|
||||
|
||||
// UserSoftDelete handles POST /_gm/users/:user_id/soft-delete.
|
||||
func (h *AdminConsoleHandlers) UserSoftDelete() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.users == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
userID, ok := parseUserIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.users.SoftDelete(c.Request.Context(), userID, actorFromContext(c)); err != nil {
|
||||
if errors.Is(err, user.ErrAccountNotFound) {
|
||||
h.renderMessage(c, http.StatusNotFound, "users", "User not found", "No such user.", "bad", "/_gm/users")
|
||||
return
|
||||
}
|
||||
// A cascade error does not undo the soft delete; log and proceed.
|
||||
h.logger.Warn("admin console: soft-delete cascade returned error", zap.Error(err))
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, "/_gm/users")
|
||||
}
|
||||
}
|
||||
|
||||
// actorFromContext builds the admin ActorRef for audit trails from the
|
||||
// authenticated operator username stored by the Basic Auth middleware.
|
||||
func actorFromContext(c *gin.Context) user.ActorRef {
|
||||
username, _ := basicauth.UsernameFromContext(c.Request.Context())
|
||||
return user.ActorRef{Type: "admin", ID: username}
|
||||
}
|
||||
|
||||
// toUsersListData maps an account page into the users list view model.
|
||||
func toUsersListData(page user.AccountPage) adminconsole.UsersListData {
|
||||
data := adminconsole.UsersListData{
|
||||
Items: make([]adminconsole.UserRow, 0, len(page.Items)),
|
||||
Page: page.Page,
|
||||
PageSize: page.PageSize,
|
||||
Total: page.Total,
|
||||
PrevPage: page.Page - 1,
|
||||
NextPage: page.Page + 1,
|
||||
HasPrev: page.Page > 1,
|
||||
HasNext: page.Page*page.PageSize < page.Total,
|
||||
}
|
||||
for _, account := range page.Items {
|
||||
data.Items = append(data.Items, adminconsole.UserRow{
|
||||
UserID: account.UserID.String(),
|
||||
Email: account.Email,
|
||||
UserName: account.UserName,
|
||||
DisplayName: account.DisplayName,
|
||||
Tier: account.Entitlement.Tier,
|
||||
Blocked: account.PermanentBlock,
|
||||
Deleted: account.DeletedAt != nil,
|
||||
CreatedAt: fmtConsoleTime(account.CreatedAt),
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// toUserDetailData maps an account aggregate into the detail view model.
|
||||
func toUserDetailData(account user.Account) adminconsole.UserDetailData {
|
||||
data := adminconsole.UserDetailData{
|
||||
UserID: account.UserID.String(),
|
||||
Email: account.Email,
|
||||
UserName: account.UserName,
|
||||
DisplayName: account.DisplayName,
|
||||
PreferredLanguage: account.PreferredLanguage,
|
||||
TimeZone: account.TimeZone,
|
||||
DeclaredCountry: account.DeclaredCountry,
|
||||
Blocked: account.PermanentBlock,
|
||||
Deleted: account.DeletedAt != nil,
|
||||
CreatedAt: fmtConsoleTime(account.CreatedAt),
|
||||
UpdatedAt: fmtConsoleTime(account.UpdatedAt),
|
||||
Tier: account.Entitlement.Tier,
|
||||
IsPaid: account.Entitlement.IsPaid,
|
||||
EntitlementSource: account.Entitlement.Source,
|
||||
EntitlementReason: account.Entitlement.ReasonCode,
|
||||
EntitlementEnds: fmtConsoleTimePtr(account.Entitlement.EndsAt),
|
||||
Tiers: consoleTiers,
|
||||
}
|
||||
for _, sanction := range account.ActiveSanctions {
|
||||
data.Sanctions = append(data.Sanctions, adminconsole.SanctionView{
|
||||
SanctionCode: sanction.SanctionCode,
|
||||
Scope: sanction.Scope,
|
||||
ReasonCode: sanction.ReasonCode,
|
||||
AppliedAt: fmtConsoleTime(sanction.AppliedAt),
|
||||
ExpiresAt: fmtConsoleTimePtr(sanction.ExpiresAt),
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// fmtConsoleTime renders a timestamp for display in the console.
|
||||
func fmtConsoleTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04 UTC")
|
||||
}
|
||||
|
||||
// fmtConsoleTimePtr renders an optional timestamp, returning "" when nil.
|
||||
func fmtConsoleTimePtr(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return fmtConsoleTime(*t)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"galaxy/backend/internal/adminconsole"
|
||||
"galaxy/backend/internal/server/middleware/basicauth"
|
||||
"galaxy/backend/internal/user"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// fakeUserAdmin records calls so the console handlers can be exercised without
|
||||
// a database.
|
||||
type fakeUserAdmin struct {
|
||||
page user.AccountPage
|
||||
account user.Account
|
||||
getErr error
|
||||
|
||||
sanctionCalls int
|
||||
lastSanction user.ApplySanctionInput
|
||||
entitlementCall int
|
||||
lastEntitlement user.ApplyEntitlementInput
|
||||
softDeleteCalls int
|
||||
lastSoftActor user.ActorRef
|
||||
}
|
||||
|
||||
func (f *fakeUserAdmin) ListAccounts(context.Context, int, int) (user.AccountPage, error) {
|
||||
return f.page, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserAdmin) GetAccount(context.Context, uuid.UUID) (user.Account, error) {
|
||||
return f.account, f.getErr
|
||||
}
|
||||
|
||||
func (f *fakeUserAdmin) ApplySanction(_ context.Context, in user.ApplySanctionInput) (user.Account, error) {
|
||||
f.sanctionCalls++
|
||||
f.lastSanction = in
|
||||
return f.account, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserAdmin) ApplyEntitlement(_ context.Context, in user.ApplyEntitlementInput) (user.Account, error) {
|
||||
f.entitlementCall++
|
||||
f.lastEntitlement = in
|
||||
return f.account, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserAdmin) SoftDelete(_ context.Context, _ uuid.UUID, actor user.ActorRef) error {
|
||||
f.softDeleteCalls++
|
||||
f.lastSoftActor = actor
|
||||
return nil
|
||||
}
|
||||
|
||||
func newUsersConsoleRouter(t *testing.T, users UserAdmin) (http.Handler, *adminconsole.CSRF) {
|
||||
t.Helper()
|
||||
csrf := adminconsole.NewCSRF([]byte("test-key"))
|
||||
handler, err := NewRouter(RouterDependencies{
|
||||
Logger: zap.NewNop(),
|
||||
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
||||
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Users: users}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
}
|
||||
return handler, csrf
|
||||
}
|
||||
|
||||
func TestConsoleUsersList(t *testing.T) {
|
||||
fake := &fakeUserAdmin{page: user.AccountPage{
|
||||
Items: []user.Account{
|
||||
{UserID: uuid.New(), Email: "alice@example.test", UserName: "alice"},
|
||||
{UserID: uuid.New(), Email: "bob@example.test", UserName: "bob", PermanentBlock: true},
|
||||
},
|
||||
Page: 1, PageSize: 50, Total: 2,
|
||||
}}
|
||||
router, _ := newUsersConsoleRouter(t, fake)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/_gm/users", nil)
|
||||
req.SetBasicAuth("ops", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"alice@example.test", "bob@example.test", "blocked", "page 1"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("users list missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleUserDetailRendersForms(t *testing.T) {
|
||||
id := uuid.New()
|
||||
fake := &fakeUserAdmin{account: user.Account{
|
||||
UserID: id, Email: "alice@example.test", UserName: "alice",
|
||||
Entitlement: user.EntitlementSnapshot{Tier: user.TierFree},
|
||||
}}
|
||||
router, csrf := newUsersConsoleRouter(t, fake)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/_gm/users/"+id.String(), nil)
|
||||
req.SetBasicAuth("ops", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"alice@example.test",
|
||||
"Permanently block",
|
||||
"Update entitlement",
|
||||
"Soft-delete account",
|
||||
csrf.Token("ops"),
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("user detail missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleUserDetailNotFound(t *testing.T) {
|
||||
fake := &fakeUserAdmin{getErr: user.ErrAccountNotFound}
|
||||
router, _ := newUsersConsoleRouter(t, fake)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/_gm/users/"+uuid.New().String(), nil)
|
||||
req.SetBasicAuth("ops", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "not found") {
|
||||
t.Error("expected a not-found message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleUserBlock(t *testing.T) {
|
||||
id := uuid.New()
|
||||
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
|
||||
router, csrf := newUsersConsoleRouter(t, fake)
|
||||
|
||||
form := "_csrf=" + csrf.Token("ops") + "&reason_code=spam"
|
||||
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader(form))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Origin", "https://galaxy.lan")
|
||||
req.SetBasicAuth("ops", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if fake.sanctionCalls != 1 {
|
||||
t.Fatalf("ApplySanction called %d times, want 1", fake.sanctionCalls)
|
||||
}
|
||||
if fake.lastSanction.SanctionCode != user.SanctionCodePermanentBlock {
|
||||
t.Errorf("sanction code = %q, want permanent_block", fake.lastSanction.SanctionCode)
|
||||
}
|
||||
if fake.lastSanction.Scope != "account" {
|
||||
t.Errorf("scope = %q, want account", fake.lastSanction.Scope)
|
||||
}
|
||||
if fake.lastSanction.ReasonCode != "spam" {
|
||||
t.Errorf("reason = %q, want spam", fake.lastSanction.ReasonCode)
|
||||
}
|
||||
if fake.lastSanction.Actor.Type != "admin" || fake.lastSanction.Actor.ID != "ops" {
|
||||
t.Errorf("actor = %+v, want admin/ops", fake.lastSanction.Actor)
|
||||
}
|
||||
if fake.lastSanction.UserID != id {
|
||||
t.Errorf("sanction user id = %s, want %s", fake.lastSanction.UserID, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleUserBlockMissingReason(t *testing.T) {
|
||||
id := uuid.New()
|
||||
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
|
||||
router, csrf := newUsersConsoleRouter(t, fake)
|
||||
|
||||
form := "_csrf=" + csrf.Token("ops")
|
||||
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader(form))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Origin", "https://galaxy.lan")
|
||||
req.SetBasicAuth("ops", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
if fake.sanctionCalls != 0 {
|
||||
t.Errorf("ApplySanction must not be called without a reason")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleUserBlockRejectsBadCSRF(t *testing.T) {
|
||||
id := uuid.New()
|
||||
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
|
||||
router, _ := newUsersConsoleRouter(t, fake)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader("reason_code=spam"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Origin", "https://galaxy.lan")
|
||||
req.SetBasicAuth("ops", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
if fake.sanctionCalls != 0 {
|
||||
t.Errorf("ApplySanction must not run when the CSRF token is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleUserEntitlement(t *testing.T) {
|
||||
id := uuid.New()
|
||||
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
|
||||
router, csrf := newUsersConsoleRouter(t, fake)
|
||||
|
||||
form := "_csrf=" + csrf.Token("ops") + "&tier=monthly&source=admin&reason_code=promo"
|
||||
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/entitlement", strings.NewReader(form))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Origin", "https://galaxy.lan")
|
||||
req.SetBasicAuth("ops", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if fake.entitlementCall != 1 {
|
||||
t.Fatalf("ApplyEntitlement called %d times, want 1", fake.entitlementCall)
|
||||
}
|
||||
if fake.lastEntitlement.Tier != user.TierMonthly {
|
||||
t.Errorf("tier = %q, want monthly", fake.lastEntitlement.Tier)
|
||||
}
|
||||
if fake.lastEntitlement.Actor.ID != "ops" {
|
||||
t.Errorf("actor id = %q, want ops", fake.lastEntitlement.Actor.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleUserSoftDelete(t *testing.T) {
|
||||
id := uuid.New()
|
||||
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
|
||||
router, csrf := newUsersConsoleRouter(t, fake)
|
||||
|
||||
form := "_csrf=" + csrf.Token("ops")
|
||||
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/soft-delete", strings.NewReader(form))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Origin", "https://galaxy.lan")
|
||||
req.SetBasicAuth("ops", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Location"); got != "/_gm/users" {
|
||||
t.Errorf("redirect Location = %q, want /_gm/users", got)
|
||||
}
|
||||
if fake.softDeleteCalls != 1 {
|
||||
t.Fatalf("SoftDelete called %d times, want 1", fake.softDeleteCalls)
|
||||
}
|
||||
if fake.lastSoftActor.ID != "ops" {
|
||||
t.Errorf("soft-delete actor = %q, want ops", fake.lastSoftActor.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleUsersUnavailable(t *testing.T) {
|
||||
router, _ := newUsersConsoleRouter(t, nil) // no user service wired
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/_gm/users", nil)
|
||||
req.SetBasicAuth("ops", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -39,55 +39,6 @@ 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,6 +86,15 @@ 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,
|
||||
|
||||
@@ -81,6 +81,13 @@ type RouterDependencies struct {
|
||||
AdminGeo *AdminGeoHandlers
|
||||
InternalSessions *InternalSessionsHandlers
|
||||
InternalUsers *InternalUsersHandlers
|
||||
|
||||
// AdminConsole, when non-nil, mounts the server-rendered operator
|
||||
// console under the `/_gm` route group behind the same admin Basic
|
||||
// Auth verifier as `/api/v1/admin`. A nil value leaves the console
|
||||
// unmounted, which keeps routers built without console wiring (the
|
||||
// contract test, most unit tests) unchanged.
|
||||
AdminConsole *AdminConsoleHandlers
|
||||
}
|
||||
|
||||
// NewRouter constructs the backend gin engine wired with the documented
|
||||
@@ -123,6 +130,7 @@ func NewRouter(deps RouterDependencies) (http.Handler, error) {
|
||||
registerUserRoutes(router, instruments, deps)
|
||||
registerAdminRoutes(router, instruments, deps)
|
||||
registerInternalRoutes(router, instruments, deps)
|
||||
registerAdminConsoleRoutes(router, deps)
|
||||
|
||||
router.NoMethod(func(c *gin.Context) {
|
||||
if allow := allowedMethodsForPath(c.Request.URL.Path); allow != "" {
|
||||
@@ -270,7 +278,6 @@ 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())
|
||||
@@ -365,6 +372,57 @@ func registerInternalRoutes(router *gin.Engine, instruments *metrics.Instruments
|
||||
users.GET("/:user_id/account-internal", deps.InternalUsers.GetAccountInternal())
|
||||
}
|
||||
|
||||
// registerAdminConsoleRoutes mounts the server-rendered operator console under
|
||||
// `/_gm` when deps.AdminConsole is wired. The group reuses the same admin Basic
|
||||
// Auth verifier as `/api/v1/admin`; the CSRF guard then protects every
|
||||
// state-changing request. A nil AdminConsole leaves the surface unmounted.
|
||||
func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) {
|
||||
if deps.AdminConsole == nil {
|
||||
return
|
||||
}
|
||||
|
||||
group := router.Group("/_gm")
|
||||
group.Use(basicauth.Middleware(deps.AdminVerifier, adminBasicAuthRealm))
|
||||
group.Use(deps.AdminConsole.RequireCSRF())
|
||||
|
||||
group.GET("/assets/*filepath", deps.AdminConsole.Asset())
|
||||
group.GET("", deps.AdminConsole.Dashboard())
|
||||
group.GET("/", deps.AdminConsole.Dashboard())
|
||||
|
||||
group.GET("/users", deps.AdminConsole.UsersList())
|
||||
group.GET("/users/:user_id", deps.AdminConsole.UserDetail())
|
||||
group.POST("/users/:user_id/block", deps.AdminConsole.UserBlock())
|
||||
group.POST("/users/:user_id/entitlement", deps.AdminConsole.UserEntitlement())
|
||||
group.POST("/users/:user_id/soft-delete", deps.AdminConsole.UserSoftDelete())
|
||||
|
||||
group.GET("/games", deps.AdminConsole.GamesList())
|
||||
group.POST("/games", deps.AdminConsole.GameCreate())
|
||||
group.GET("/games/:game_id", deps.AdminConsole.GameDetail())
|
||||
group.POST("/games/:game_id/force-start", deps.AdminConsole.GameForceStart())
|
||||
group.POST("/games/:game_id/force-stop", deps.AdminConsole.GameForceStop())
|
||||
group.POST("/games/:game_id/ban-member", deps.AdminConsole.GameBanMember())
|
||||
group.POST("/games/:game_id/runtime/restart", deps.AdminConsole.RuntimeRestart())
|
||||
group.POST("/games/:game_id/runtime/patch", deps.AdminConsole.RuntimePatch())
|
||||
group.POST("/games/:game_id/runtime/force-next-turn", deps.AdminConsole.RuntimeForceNextTurn())
|
||||
|
||||
group.GET("/engine-versions", deps.AdminConsole.EngineVersionsList())
|
||||
group.POST("/engine-versions", deps.AdminConsole.EngineVersionRegister())
|
||||
group.POST("/engine-versions/:version/disable", deps.AdminConsole.EngineVersionDisable())
|
||||
|
||||
group.GET("/operators", deps.AdminConsole.OperatorsList())
|
||||
group.POST("/operators", deps.AdminConsole.OperatorCreate())
|
||||
group.POST("/operators/:username/disable", deps.AdminConsole.OperatorDisable())
|
||||
group.POST("/operators/:username/enable", deps.AdminConsole.OperatorEnable())
|
||||
group.POST("/operators/:username/reset-password", deps.AdminConsole.OperatorResetPassword())
|
||||
|
||||
group.GET("/mail", deps.AdminConsole.MailPage())
|
||||
group.GET("/mail/deliveries/:delivery_id", deps.AdminConsole.MailDeliveryDetail())
|
||||
group.POST("/mail/deliveries/:delivery_id/resend", deps.AdminConsole.MailResend())
|
||||
group.GET("/notifications", deps.AdminConsole.NotificationsPage())
|
||||
group.GET("/broadcast", deps.AdminConsole.BroadcastForm())
|
||||
group.POST("/broadcast", deps.AdminConsole.BroadcastSend())
|
||||
}
|
||||
|
||||
// allowedMethodsForPath returns the comma-separated list of methods
|
||||
// the gin router accepts on requestPath. Only the probe paths declare
|
||||
// a non-empty list so NoMethod can advertise a useful `Allow` header
|
||||
|
||||
+8
-40
@@ -265,7 +265,12 @@ 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`.
|
||||
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.
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
@@ -285,6 +290,8 @@ paths:
|
||||
$ref: "#/components/schemas/LobbyGameDetail"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"403":
|
||||
$ref: "#/components/responses/ForbiddenError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
@@ -974,37 +981,6 @@ 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]
|
||||
@@ -3531,14 +3507,6 @@ 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
|
||||
|
||||
+59
-5
@@ -142,7 +142,9 @@ 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`. Public games carry `owner_user_id IS NULL`; the partial
|
||||
`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
|
||||
index on `(owner_user_id) WHERE visibility = 'private'` keeps the
|
||||
private-owner lookup efficient.
|
||||
- **Authenticated lobby commands** flow through the gateway envelope
|
||||
@@ -373,9 +375,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
|
||||
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`).
|
||||
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`).
|
||||
`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
|
||||
@@ -400,6 +402,14 @@ 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)
|
||||
|
||||
@@ -571,6 +581,36 @@ directly.
|
||||
`/api/v1/admin/notifications/*`) reuse the per-domain logic of the
|
||||
module they target.
|
||||
|
||||
### 14.1 Operator console (`/_gm`)
|
||||
|
||||
`backend` also serves a server-rendered operator console under the `/_gm`
|
||||
route group — the human-facing surface for the admin operations otherwise
|
||||
exposed as JSON under `/api/v1/admin/*`. It reuses the `admin_accounts`
|
||||
Basic Auth verifier and renders pages with the standard library's
|
||||
`html/template` (navigation by path and query, Post/Redirect/Get on
|
||||
writes; no client framework or build step).
|
||||
|
||||
Unlike the internal-only JSON admin API, the console is reachable from the
|
||||
public edge: Caddy routes `/_gm/*` to the gateway public listener, which
|
||||
classifies it as the `admin` anti-abuse class (per-IP rate limit, body and
|
||||
method limits) and reverse-proxies it to `backend`'s `/_gm` surface. The
|
||||
gateway preserves the inbound `Host` and relays the backend's 401 Basic
|
||||
Auth challenge unchanged, so the browser shows its native credential
|
||||
dialog. Authentication is enforced by `backend`; the gateway contributes
|
||||
only the edge anti-abuse layer.
|
||||
|
||||
State-changing requests are guarded against CSRF by a stateless token
|
||||
(HMAC-SHA256 over the authenticated username, keyed by
|
||||
`BACKEND_ADMIN_CONSOLE_CSRF_KEY`; a per-process random key is used when the
|
||||
variable is unset) plus a same-origin `Origin`/`Referer` check.
|
||||
|
||||
The console landing page is a dashboard that surfaces backend-visible
|
||||
operational signals — database reachability, per-status game-runtime counts,
|
||||
and mail/notification queue depths — read directly through the persistence
|
||||
layer; richer historical metrics come from the Prometheus exporters on
|
||||
`backend` and `gateway` (see [§17](#17-observability)). See
|
||||
`backend/docs/admin-console.md` for the console design.
|
||||
|
||||
## 15. Transport Security Model (gateway boundary)
|
||||
|
||||
This section describes the secure exchange model between client and
|
||||
@@ -813,7 +853,8 @@ business validation and authorisation.
|
||||
| Session revocation propagation | backend → gateway | `session_invalidation` over the gRPC push stream flips the gateway-side cache entry to revoked and closes any active push stream. |
|
||||
| Authorisation, ownership, state transitions | backend | `X-User-ID` is the sole identity input on the user surface. |
|
||||
| Edge rate limiting | gateway | Backend has no rate-limit responsibility in MVP. |
|
||||
| Admin authentication | backend | Basic Auth against `admin_accounts`. |
|
||||
| Admin authentication | backend | Basic Auth against `admin_accounts`; the `/_gm` operator console reuses the same verifier. |
|
||||
| Admin console CSRF | backend | Stateless HMAC token (`BACKEND_ADMIN_CONSOLE_CSRF_KEY`) + same-origin `Origin`/`Referer` check on `/_gm` writes. |
|
||||
| Engine API authentication | network | Engine listens only on the trusted network; backend is the only caller. |
|
||||
|
||||
### Backend ↔ Gateway trust
|
||||
@@ -847,6 +888,19 @@ addition.
|
||||
- Health probes are unauthenticated `GET /healthz` (process liveness) and
|
||||
`GET /readyz` (Postgres reachable, migrations applied, gRPC listener
|
||||
bound). Probes are excluded from anti-replay and rate limiting.
|
||||
- **Collection (dev, production mirror).** The long-lived dev environment
|
||||
(`tools/dev-deploy/`) runs a full metrics + logs + traces stack on its
|
||||
internal network with no host ports: Prometheus scrapes the backend
|
||||
(`:9100`) and gateway (`:9191`) endpoints plus `node-exporter` and
|
||||
cAdvisor; Tempo ingests OTLP traces from backend and gateway; Loki
|
||||
stores container logs shipped by promtail (Docker service-discovery on
|
||||
the `galaxy.stack=dev-deploy` label). Grafana (provisioned datasources
|
||||
+ dashboards) and the Mailpit capture UI are reached only through the
|
||||
operator console's single `/_gm` Basic Auth gate (§14.1) — at
|
||||
`/_gm/grafana/` and `/_gm/mailpit/` — so one password covers the
|
||||
console and both UIs. Retention is tuned small (Prometheus 15d, Loki
|
||||
7d, Tempo 3d). The same compose fragment is meant to back production.
|
||||
See `tools/dev-deploy/monitoring/README.md`.
|
||||
|
||||
## 18. CI and Environments
|
||||
|
||||
|
||||
+82
-15
@@ -363,6 +363,18 @@ 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.
|
||||
@@ -607,10 +619,10 @@ not duplicated here.
|
||||
|
||||
### 6.2 Backend's role: pass-through with authorisation
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -636,6 +648,20 @@ 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
|
||||
@@ -645,7 +671,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 command/order handlers
|
||||
The user-games 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
|
||||
@@ -678,14 +704,26 @@ 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, 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.
|
||||
(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.
|
||||
|
||||
The Bombings section is a flat read-only table — one row per
|
||||
bombing event, columns for `attacker`, `attack_power`, `wiped`
|
||||
@@ -808,8 +846,12 @@ 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) plus the torus / no-wrap radio that switches
|
||||
the renderer mode while preserving the camera centre.
|
||||
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.
|
||||
|
||||
LOCAL planets are always rendered — they have no toggle. Every
|
||||
other toggle defaults to ON. Hiding a planet cascades onto every
|
||||
@@ -1120,6 +1162,31 @@ operator's password manager can match it across deployments.
|
||||
After the first deployment, the bootstrap password should be
|
||||
rotated through the admin surface.
|
||||
|
||||
### 10.2.1 Operator console (`/_gm`)
|
||||
|
||||
Administrators drive these operations either programmatically through
|
||||
the JSON admin API or through a server-rendered web console at `/_gm`.
|
||||
The console authenticates with the same Basic Auth credentials: opening
|
||||
any `/_gm` page prompts the browser's native credential dialog, and the
|
||||
operator stays signed in for the session. Navigation is by ordinary
|
||||
links and query parameters; every change is submitted as a form and
|
||||
answered with a redirect back to the affected page.
|
||||
|
||||
The console is the only admin surface reachable from outside the trusted
|
||||
network. It is fronted by the gateway, so it inherits the same edge rate
|
||||
limiting and request limits as the public API, and it carries an
|
||||
anti-CSRF token on every change. The JSON admin API stays internal to
|
||||
the deployment.
|
||||
|
||||
The console landing page is a dashboard that summarises operational
|
||||
health: whether the backend is ready and the database reachable, how many
|
||||
game runtimes sit in each state, and the depth of the mail and
|
||||
notification queues. It is a read-only point-in-time view for quick
|
||||
triage, not a metrics history. The console nav also links to Grafana
|
||||
(metrics, logs and traces) and the Mailpit capture UI, which the
|
||||
deployment serves under the same `/_gm` Basic Auth gate — one sign-in
|
||||
covers the console and both UIs.
|
||||
|
||||
### 10.3 Admin account management
|
||||
|
||||
Existing admins can list other admins, create new ones, look up a
|
||||
|
||||
+80
-14
@@ -377,6 +377,18 @@ 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, не на
|
||||
@@ -625,9 +637,9 @@ Wire-формат команд, приказов и отчётов — собс
|
||||
### 6.2 Роль backend: pass-through с авторизацией
|
||||
|
||||
Подписанный конвейер аутентифицированного edge для in-game-трафика
|
||||
использует четыре message types на аутентифицированной поверхности —
|
||||
`user.games.command`, `user.games.order`, `user.games.order.get`,
|
||||
`user.games.report` — у каждого типизированный FlatBuffers-payload.
|
||||
использует три message types на аутентифицированной поверхности —
|
||||
`user.games.order`, `user.games.order.get`, `user.games.report` —
|
||||
у каждого типизированный FlatBuffers-payload.
|
||||
Gateway транскодирует FB-запрос в JSON-форму, которую ждёт backend,
|
||||
форвардит её REST'ом в соответствующий
|
||||
`/api/v1/user/games/{game_id}/*` endpoint, после чего транскодирует
|
||||
@@ -654,6 +666,20 @@ 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
|
||||
|
||||
Запущенная игра постоянно чередуется между окном приёма команд
|
||||
@@ -696,14 +722,26 @@ Backend авторизует вызывающего и форвардит зап
|
||||
нет ни кэширования, ни денормализации.
|
||||
|
||||
Web-клиент рендерит отчёт как одну секцию на каждый FBS-массив
|
||||
(общие сведения, голоса, статус игроков, мои / чужие науки, мои /
|
||||
чужие классы кораблей, сражения, бомбардировки, приближающиеся
|
||||
группы, мои / чужие / необитаемые / неопознанные планеты, корабли в
|
||||
производстве, грузовые маршруты, мои флоты, мои / чужие /
|
||||
неопознанные группы кораблей). Пустые секции получают явную копию
|
||||
empty-state. Якоря секций отображены в sticky-TOC (на мобильном —
|
||||
`<select>`); позиция скролла сохраняется при переключении активного
|
||||
представления через SvelteKit `Snapshot` API.
|
||||
(общие сведения, скоро покидающие игру расы, голоса, статус
|
||||
игроков, мои / чужие науки, мои / чужие классы кораблей, сражения,
|
||||
бомбардировки, приближающиеся группы, мои / чужие / необитаемые /
|
||||
неопознанные планеты, корабли в производстве, грузовые маршруты,
|
||||
мои флоты, мои / чужие / неопознанные группы кораблей). Пустые
|
||||
секции получают явную копию empty-state; исключение — секция
|
||||
«скоро покидающие игру расы»: она полностью скрывается, когда ни
|
||||
одна раса не близка к исключению. Если же близка к исключению за
|
||||
неактивность сама локальная раса (осталось не более пяти ходов),
|
||||
над списком секций показывается персональный
|
||||
баннер-предупреждение (стиль danger) с числом оставшихся ходов;
|
||||
публичная секция «скоро покидающие игру расы» перечисляет все
|
||||
прочие расы, до исключения которых осталось не более трёх ходов.
|
||||
Навигация по секциям — sticky icon-popup в правом
|
||||
верхнем углу колонки отчёта (анкорный popover на десктопе и фикс.
|
||||
bottom-sheet на мобильном); подпись на кнопке отслеживает раздел,
|
||||
который сейчас в зоне видимости, выбор пункта меню — скролл к
|
||||
нужной секции. При возврате в активный вью отчёт перемонтируется,
|
||||
позиция скролла сбрасывается к началу, а IntersectionObserver
|
||||
заново рассчитывает подсветку при прокрутке.
|
||||
|
||||
Секция бомбардировок — это плоская read-only-таблица: одна строка на
|
||||
событие, колонки `attacker`, `attack_power`, признак `wiped` и
|
||||
@@ -828,9 +866,12 @@ Directory-промоушен ([Раздел 5](#5-реестр-названий-
|
||||
объединения окружностей
|
||||
`VisibilityDistance(localPlayerDrive)` вокруг LOCAL-планет;
|
||||
LOCAL-планеты всегда вне фильтра — тоггл назван по видимой
|
||||
области карты, а не по затемнённой) плюс радиогруппа
|
||||
«торус / без переноса», переключающая режим рендерера с
|
||||
сохранением центра камеры.
|
||||
области карты, а не по затемнённой). Рендерер всегда работает
|
||||
в торическом режиме; прежняя радиогруппа «торус / без
|
||||
переноса» была удалена в полишинге F8 (issue #48 п.8),
|
||||
поскольку топология карты — серверная сущность, а не
|
||||
per-session UI-настройка. Код-путь без переноса в рендерере
|
||||
оставлен на день, когда движок выставит режим bounded plane.
|
||||
|
||||
LOCAL-планеты отрисовываются всегда — для них тоггла нет.
|
||||
Остальные тогглы по умолчанию включены. Скрытие планеты
|
||||
@@ -1156,6 +1197,31 @@ deployments.
|
||||
После первого деплоя bootstrap-пароль должен быть ротирован
|
||||
через admin-surface.
|
||||
|
||||
### 10.2.1 Операторская консоль (`/_gm`)
|
||||
|
||||
Администраторы выполняют эти операции либо программно через JSON
|
||||
admin-API, либо через серверно-рендеримую веб-консоль на `/_gm`.
|
||||
Консоль аутентифицируется теми же Basic Auth-учётными данными:
|
||||
открытие любой страницы `/_gm` вызывает нативный диалог браузера для
|
||||
ввода учётных данных, и оператор остаётся залогинен на время сессии.
|
||||
Навигация — обычными ссылками и query-параметрами; каждое изменение
|
||||
отправляется формой и завершается редиректом обратно на затронутую
|
||||
страницу.
|
||||
|
||||
Консоль — единственная admin-поверхность, достижимая извне
|
||||
доверенной сети. Она проксируется через gateway, поэтому наследует те
|
||||
же edge-rate-limiting и лимиты запросов, что и публичный API, и несёт
|
||||
анти-CSRF-токен на каждом изменении. JSON admin-API остаётся
|
||||
внутренним для деплоя.
|
||||
|
||||
Стартовая страница консоли — дашборд, сводящий операционное
|
||||
здоровье: готов ли backend и доступна ли БД, сколько игровых рантаймов
|
||||
в каждом состоянии, какова глубина очередей почты и уведомлений. Это
|
||||
read-only-срез на текущий момент для быстрой диагностики, не история
|
||||
метрик. Навигация консоли также ведёт в Grafana (метрики, логи и
|
||||
трейсы) и в UI захвата почты Mailpit, которые деплой отдаёт под тем же
|
||||
шлюзом Basic Auth `/_gm` — один вход покрывает консоль и оба UI.
|
||||
|
||||
### 10.3 Управление admin-аккаунтами
|
||||
|
||||
Существующие админы могут перечислять других админов, создавать
|
||||
|
||||
+36
-17
@@ -43,11 +43,10 @@ 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 the race roster. |
|
||||
| Admin (GM-only) | `POST /api/v1/admin/init` | `Game Master` | Initialise the engine with a canonical `gameId` and 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. |
|
||||
@@ -65,6 +64,24 @@ 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
|
||||
@@ -85,9 +102,13 @@ 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. The engine-side mechanics of `banish` (what
|
||||
exactly happens to the race's planets, fleets, and pending orders) are
|
||||
owned by the engine maintainers.
|
||||
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.
|
||||
|
||||
### `GET /healthz`
|
||||
|
||||
@@ -148,19 +169,17 @@ 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 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.
|
||||
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`.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Build
|
||||
|
||||
|
||||
+10
-2
@@ -4,17 +4,25 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"galaxy/game/internal/controller"
|
||||
"galaxy/game/internal/router"
|
||||
"galaxy/game/internal/router/handler"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if _, err := handler.ResolveStoragePath(); err != nil {
|
||||
path, err := handler.ResolveStoragePath()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
r := router.NewRouter()
|
||||
svc, err := controller.NewService(path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
r := router.NewRouter(svc)
|
||||
if err := r.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"iter"
|
||||
"maps"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
@@ -19,8 +18,9 @@ type Battle struct {
|
||||
InitialNumbers map[int]uint // Initial number of ships in the group
|
||||
Protocol []BattleAction
|
||||
|
||||
shipAmmo map[int]uint
|
||||
attacker map[int]map[int]float64 // a group able to attack and destroy an opponent with some probability > 0
|
||||
// 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
|
||||
}
|
||||
|
||||
type BattleAction struct {
|
||||
@@ -32,14 +32,23 @@ type BattleAction struct {
|
||||
func CollectPlanetGroups(c *Cache) map[uint]map[int]bool {
|
||||
planetGroup := make(map[uint]map[int]bool)
|
||||
for groupIndex := range c.ShipGroupsIndex() {
|
||||
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
|
||||
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
|
||||
}
|
||||
if _, ok := planetGroup[planetNumber]; !ok {
|
||||
planetGroup[planetNumber] = make(map[int]bool)
|
||||
}
|
||||
planetGroup[planetNumber][groupIndex] = false
|
||||
}
|
||||
for pl := range planetGroup {
|
||||
if len(planetGroup[pl]) < 2 {
|
||||
@@ -50,7 +59,18 @@ 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 { return c.ShipGroup(groupIndex).State() != game.StateInOrbit })
|
||||
return slices.DeleteFunc(slices.Collect(maps.Keys(groups)), func(groupIndex int) bool {
|
||||
// Everything physically present at the planet fights: ships in orbit,
|
||||
// ships being upgraded, and ships ordered to depart that have not yet
|
||||
// entered hyperspace (Launched). Only ships already in hyperspace are
|
||||
// out of reach.
|
||||
switch c.ShipGroup(groupIndex).State() {
|
||||
case game.StateInOrbit, game.StateUpgrade, game.StateLaunched:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func FilterBattleOpponents(c *Cache, attIdx, defIdx int, cacheProbability map[int]map[int]float64) bool {
|
||||
@@ -65,12 +85,20 @@ 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(),
|
||||
c.ShipGroupShipClass(defIdx).Shields.F(),
|
||||
c.ShipGroup(defIdx).TechLevel(game.TechShields).F(),
|
||||
c.ShipGroup(defIdx).FullMass(c.ShipGroupShipClass(defIdx)),
|
||||
defSt.Shields.F(),
|
||||
defSg.TechLevel(game.TechShields).F(),
|
||||
defSg.FullMass(defSt)/float64(defSg.Number),
|
||||
)
|
||||
// Exclude opponent's group which cannot be probably destroyed
|
||||
if p <= 0 {
|
||||
@@ -108,7 +136,6 @@ 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
|
||||
@@ -126,12 +153,11 @@ 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
|
||||
}
|
||||
@@ -145,78 +171,111 @@ 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) {
|
||||
roundShooters := make(map[int]bool)
|
||||
for len(b.attacker) > 0 {
|
||||
// список участников раунда
|
||||
clear(roundShooters)
|
||||
for sgi := range b.attacker {
|
||||
roundShooters[sgi] = true
|
||||
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)
|
||||
}
|
||||
}
|
||||
if len(shooters) == 0 {
|
||||
return
|
||||
}
|
||||
rand.Shuffle(len(shooters), func(i, j int) { shooters[i], shooters[j] = shooters[j], shooters[i] })
|
||||
|
||||
for len(roundShooters) > 0 {
|
||||
// attacke group id among round participants
|
||||
attIdx := randomValue(maps.Keys(roundShooters))
|
||||
delete(roundShooters, attIdx)
|
||||
// fired counts, per group, how many of its ships have already shot
|
||||
// this round; a token beyond the group's current (post-casualty) ship
|
||||
// count belongs to a ship destroyed earlier in the round and is skipped.
|
||||
fired := make(map[int]uint)
|
||||
progressed := false
|
||||
for _, attIdx := range shooters {
|
||||
if fired[attIdx] >= c.ShipGroup(attIdx).Number {
|
||||
continue
|
||||
}
|
||||
fired[attIdx]++
|
||||
|
||||
for range 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")
|
||||
for range c.ShipGroupShipClass(attIdx).Armament {
|
||||
defIdx, ok := c.pickTargetShip(b, attIdx)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
|
||||
progressed = true
|
||||
destroyed := destructionRoll(b.attacker[attIdx][defIdx])
|
||||
b.Protocol = append(b.Protocol, BattleAction{
|
||||
Attacker: attIdx,
|
||||
Defender: defIdx,
|
||||
Destroyed: destroyed,
|
||||
})
|
||||
|
||||
if destroyed {
|
||||
c.ShipGroupDestroyItem(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)
|
||||
}
|
||||
if c.ShipGroup(defIdx).Number == 0 {
|
||||
b.removeFromBattle(defIdx)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func randomValue(v iter.Seq[int]) int {
|
||||
ids := slices.Collect(v)
|
||||
return ids[rand.IntN(len(ids))]
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,11 @@ 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)
|
||||
assert.InDelta(t, 0.396222, cacheProbability[0][2], 0.0000001)
|
||||
// Group 2 holds 15 ships, but a shot targets a single ship, so the
|
||||
// defending mass is the per-ship full mass (group mass / 15), which
|
||||
// yields a far lower destruction probability than the pre-fix group-mass
|
||||
// calculation (which read ~0.396).
|
||||
assert.InDelta(t, 0.07064783, cacheProbability[0][2], 0.0000001)
|
||||
assert.False(t, controller.FilterBattleOpponents(c, 2, 0, cacheProbability))
|
||||
assert.Contains(t, cacheProbability, 2)
|
||||
assert.Contains(t, cacheProbability[2], 0)
|
||||
@@ -271,3 +275,107 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"galaxy/game/internal/model/game"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -13,8 +17,14 @@ func (c *Cache) ProduceBombings() []*game.Bombing {
|
||||
if !p.Owned() {
|
||||
continue
|
||||
}
|
||||
for ri, groups := range enemies {
|
||||
br := c.bombingReport(p, ri, groups)
|
||||
// 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])
|
||||
report = append(report, br)
|
||||
if br.Wiped {
|
||||
break
|
||||
@@ -22,7 +32,11 @@ 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 {
|
||||
// Если на планете остались также и колонисты, то они превращаются в население,
|
||||
// а накопленная промышленность возмещает потери производства.
|
||||
@@ -33,13 +47,16 @@ func (c *Cache) ProduceBombings() []*game.Bombing {
|
||||
return report
|
||||
}
|
||||
|
||||
func (c *Cache) bombingReport(p *game.Planet, ri int, groups []int) *game.Bombing {
|
||||
attackPower := 0.
|
||||
func (c *Cache) bombingPower(groups []int) float64 {
|
||||
var power float64
|
||||
for _, i := range groups {
|
||||
sg := c.ShipGroup(i)
|
||||
st := c.ShipGroupShipClass(i)
|
||||
attackPower += sg.BombingPower(st)
|
||||
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)
|
||||
r := &game.Bombing{
|
||||
ID: uuid.New(),
|
||||
PlanetOwnedID: *p.Owner,
|
||||
|
||||
@@ -141,3 +141,59 @@ func TestProduceBombings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBombingOrderByPower checks that attacking races are accounted from the
|
||||
// strongest bombing power downwards (the report order), not in the random map
|
||||
// iteration order the engine used before.
|
||||
func TestBombingOrderByPower(t *testing.T) {
|
||||
c, g := newCache()
|
||||
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
|
||||
weakIdx, _ := c.AddRace("Weakling")
|
||||
assert.NoError(t, c.UpdateRelation(weakIdx, Race_0_idx, game.RelationWar))
|
||||
assert.NoError(t, c.ShipClassCreate(weakIdx, "Pebble", 1, 1, 1, 1, 0))
|
||||
|
||||
// Planet_0 (Race_0) survives both attacks.
|
||||
c.MustPlanet(R0_Planet_0_num).Population = 1000
|
||||
|
||||
// Strong: one Race_1 gunship (~358.9 power); weak: one Pebble (~1.1 power).
|
||||
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R0_Planet_0_num, 1)
|
||||
c.CreateShipsUnsafe_T(weakIdx, c.MustShipClass(weakIdx, "Pebble").ID, R0_Planet_0_num, 1)
|
||||
|
||||
reports := c.ProduceBombings()
|
||||
assert.Len(t, reports, 2)
|
||||
assert.Equal(t, Race_1.Name, reports[0].Attacker, "strongest attacker comes first")
|
||||
assert.Equal(t, "Weakling", reports[1].Attacker)
|
||||
assert.Greater(t, reports[0].AttackPower.F(), reports[1].AttackPower.F())
|
||||
}
|
||||
|
||||
// TestBombingWipeZeroesIndustry checks that a planet bombed to extinction loses
|
||||
// its industry but keeps its material and capital stockpiles for the next
|
||||
// colonist (rules "Бомбардировка планет").
|
||||
func TestBombingWipeZeroesIndustry(t *testing.T) {
|
||||
c, _ := newCache()
|
||||
bomberIdx, _ := c.AddRace("Bomber")
|
||||
assert.NoError(t, c.UpdateRelation(bomberIdx, Race_0_idx, game.RelationWar))
|
||||
// Bombing power ~106.5 (W=60, A=1, weapons tech 1.0): wipes pop 50 while
|
||||
// only partly converting industry, so the leftover industry is observable.
|
||||
assert.NoError(t, c.ShipClassCreate(bomberIdx, "Reaper", 1, 1, 60, 1, 0))
|
||||
|
||||
p := c.MustPlanet(R0_Planet_0_num)
|
||||
p.Population = 50
|
||||
p.Industry = 200
|
||||
p.Capital = 30
|
||||
p.Material = 20
|
||||
p.Colonists = 0
|
||||
|
||||
c.CreateShipsUnsafe_T(bomberIdx, c.MustShipClass(bomberIdx, "Reaper").ID, R0_Planet_0_num, 1)
|
||||
|
||||
reports := c.ProduceBombings()
|
||||
assert.Len(t, reports, 1)
|
||||
assert.True(t, reports[0].Wiped)
|
||||
|
||||
pl := c.MustPlanet(R0_Planet_0_num)
|
||||
assert.False(t, pl.Owned())
|
||||
assert.Equal(t, 0., pl.Population.F())
|
||||
assert.Equal(t, 0., pl.Industry.F(), "industry collapses on wipe")
|
||||
assert.Equal(t, 30., pl.Capital.F(), "capital stockpile survives")
|
||||
assert.InDelta(t, 126.476, pl.Material.F(), 0.01, "material keeps the converted industry")
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
e "galaxy/error"
|
||||
"galaxy/game/internal/model/game"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -14,162 +16,147 @@ import (
|
||||
"galaxy/game/internal/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)
|
||||
// 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 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)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Service{repo: r}, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if err = ec.Repo.Lock(); err != nil {
|
||||
|
||||
return s.GameState()
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, ec.Repo.Release())
|
||||
if err == nil {
|
||||
s, err = GameState(configure)
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
return err
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = NewGame(ec.Repo, races)
|
||||
return
|
||||
return c.saveState()
|
||||
})
|
||||
}
|
||||
|
||||
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 ec.loadReport(actor, turn)
|
||||
}
|
||||
|
||||
func ExecuteCommand(configure func(*Param), consumer func(c Ctrl) error) (err error) {
|
||||
ec, err := NewRepoController(configure)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ec.executeCommand(func(c *Controller) error { return consumer(c) })
|
||||
}
|
||||
|
||||
func ValidateOrder(configure func(*Param), actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) {
|
||||
ec, err := NewRepoController(configure)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ec.validateOrder(actor, cmd...)
|
||||
}
|
||||
|
||||
func FetchOrder(configure func(*Param), actor string, turn uint) (order *order.UserGamesOrder, ok bool, err error) {
|
||||
ec, err := NewRepoController(configure)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return ec.fetchOrder(actor, turn)
|
||||
}
|
||||
|
||||
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 ec.banishRace(actor)
|
||||
}
|
||||
|
||||
func GameState(configure func(*Param)) (s game.State, err error) {
|
||||
ec, err := NewRepoController(configure)
|
||||
if err != nil {
|
||||
return game.State{}, err
|
||||
}
|
||||
|
||||
g, err := ec.Repo.LoadStateSafe()
|
||||
// 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()
|
||||
if err != nil {
|
||||
return game.State{}, err
|
||||
}
|
||||
@@ -207,149 +194,26 @@ func GameState(configure func(*Param)) (s game.State, err error) {
|
||||
return *result, nil
|
||||
}
|
||||
|
||||
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()
|
||||
// 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()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = consumer(g.Turn, ec.NewGameController(g))
|
||||
return
|
||||
}
|
||||
|
||||
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 consumer(g.Turn, &Controller{repo: s.repo, Cache: NewCache(g)})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
*RepoController
|
||||
repo *repo.Repo
|
||||
Cache *Cache
|
||||
}
|
||||
|
||||
type Param struct {
|
||||
StoragePath string
|
||||
func (c *Controller) saveState() error {
|
||||
return c.repo.SaveLastState(c.Cache.g)
|
||||
}
|
||||
|
||||
@@ -149,3 +149,7 @@ func (c *Cache) WipeRace(ri int) {
|
||||
func (c *Cache) UnsafeDeleteShipGroup(sgi int) {
|
||||
c.unsafeDeleteShipGroup(sgi)
|
||||
}
|
||||
|
||||
func DestructionRoll(probability float64) bool {
|
||||
return destructionRoll(probability)
|
||||
}
|
||||
|
||||
@@ -131,8 +131,7 @@ func newGame() *game.Game {
|
||||
|
||||
func newCache() (*controller.Cache, *controller.Controller) {
|
||||
ctl := &controller.Controller{
|
||||
RepoController: nil,
|
||||
Cache: controller.NewCache(newGame()),
|
||||
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))
|
||||
|
||||
@@ -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.ErrRaceExinct))
|
||||
e.GenericErrorText(e.ErrRaceExtinct))
|
||||
assert.ErrorContains(t,
|
||||
g.FleetSend(Race_0.Name, "UnknownFleet", 2),
|
||||
e.GenericErrorText(e.ErrInputEntityNotExists))
|
||||
|
||||
@@ -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.ErrRaceExinct))
|
||||
e.GenericErrorText(e.ErrRaceExtinct))
|
||||
|
||||
// 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.ErrRaceExinct))
|
||||
e.GenericErrorText(e.ErrRaceExtinct))
|
||||
|
||||
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.ErrRaceExinct))
|
||||
e.GenericErrorText(e.ErrRaceExtinct))
|
||||
|
||||
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, fleetSourceOne, c.ShipGroup(0).ID))
|
||||
|
||||
|
||||
@@ -7,22 +7,26 @@ import (
|
||||
|
||||
"galaxy/game/internal/generator"
|
||||
"galaxy/game/internal/model/game"
|
||||
"galaxy/game/internal/repo"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func NewGame(r Repo, races []string) (uuid.UUID, error) {
|
||||
// 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) {
|
||||
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, races, m)
|
||||
return newGameOnMap(r, gameID, races, m)
|
||||
}
|
||||
|
||||
func newGameOnMap(r Repo, races []string, m generator.Map) (uuid.UUID, error) {
|
||||
g, err := buildGameOnMap(races, m)
|
||||
func newGameOnMap(r *repo.Repo, gameID uuid.UUID, races []string, m generator.Map) (uuid.UUID, error) {
|
||||
g, err := buildGameOnMap(gameID, races, m)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
@@ -38,14 +42,10 @@ func newGameOnMap(r Repo, races []string, m generator.Map) (uuid.UUID, error) {
|
||||
return g.ID, nil
|
||||
}
|
||||
|
||||
func buildGameOnMap(races []string, m generator.Map) (*game.Game, error) {
|
||||
func buildGameOnMap(gameID uuid.UUID, 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,
|
||||
|
||||
@@ -28,16 +28,17 @@ func TestNewGame(t *testing.T) {
|
||||
for i := range players {
|
||||
races[i] = fmt.Sprintf("race_%02d", i)
|
||||
}
|
||||
assert.NoError(t, r.Lock())
|
||||
gameID, err := controller.NewGame(r, races)
|
||||
requestedID := uuid.New()
|
||||
gameID, err := controller.NewGame(r, requestedID, 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, gameID, g.ID)
|
||||
assert.Equal(t, requestedID, g.ID, "persisted game.ID must match the supplied gameID")
|
||||
assert.Equal(t, uint(0), g.Turn)
|
||||
assert.Equal(t, players, len(g.Race))
|
||||
|
||||
@@ -65,6 +66,38 @@ func TestNewGame(t *testing.T) {
|
||||
numShuffled = numShuffled || p.Number != uint(i)
|
||||
}
|
||||
assert.True(t, numShuffled)
|
||||
|
||||
assert.NoError(t, r.Release())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -20,25 +20,29 @@ func (c *Controller) MakeTurn() error {
|
||||
c.Cache.g.Turn += 1
|
||||
c.Cache.g.Stage = 0
|
||||
|
||||
// 01. Вышедшие расы удаляются из списка участвующих рас перед началом просчета очередного хода
|
||||
// 01. Вышедшие расы удаляются из списка участвующих рас перед началом просчёта очередного хода.
|
||||
c.Cache.TurnWipeExtinctRaces()
|
||||
|
||||
// 02. Товары загружаются на корабли, находящиеся в начале грузовых маршрутов, и корабли входят в гиперпространство (но ещё не полетели)
|
||||
c.Cache.SendRoutedGroups()
|
||||
|
||||
// 03. Корабли, где это возможно, объединяются в группы.
|
||||
// 02. Корабли, где это возможно, объединяются в группы (до боя и до отправки по маршрутам).
|
||||
c.Cache.TurnMergeEqualShipGroups()
|
||||
|
||||
// 04. Враждующие корабли вступают в схватку.
|
||||
// 03. Враждующие корабли вступают в схватку у планеты отправления. Корабли, которым отдан
|
||||
// приказ на отлёт (статус Launched), ещё стоят на планете и участвуют в бою; в
|
||||
// гиперпространство уходят только уцелевшие — так нельзя уклониться от боя.
|
||||
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. Корабли бомбят вражеские планеты.
|
||||
@@ -67,7 +71,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 {
|
||||
@@ -107,7 +111,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
|
||||
@@ -118,12 +122,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
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")
|
||||
@@ -13,17 +13,24 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (c *Controller) ValidateOrder(actor string, commands ...order.DecodableCommand) (err error) {
|
||||
// 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 {
|
||||
for i := range commands {
|
||||
if _, ok := commands[i].(order.CommandRaceQuit); ok && i != len(commands)-1 {
|
||||
err = e.NewQuitCommandFollowedByCommandError()
|
||||
if _, ok := commands[i].(*order.CommandRaceQuit); ok && i != len(commands)-1 {
|
||||
return e.NewQuitCommandFollowedByCommandError()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = errors.Join(err, c.applyCommand(actor, commands[i]))
|
||||
// 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])
|
||||
}
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) applyCommand(actor string, cmd order.DecodableCommand) (err error) {
|
||||
@@ -102,11 +109,11 @@ func (c *Controller) applyCommand(actor string, cmd order.DecodableCommand) (err
|
||||
}
|
||||
|
||||
if ge, ok := errors.AsType[*e.GenericError](err); ok {
|
||||
m.Result(ge.Code)
|
||||
m.Result(ge.Code, ge.Error())
|
||||
} else if err != nil {
|
||||
panic(fmt.Errorf("error applying command has unknown origin: %w", err))
|
||||
} else {
|
||||
m.Result(0)
|
||||
m.Result(0, "")
|
||||
}
|
||||
|
||||
return
|
||||
@@ -120,7 +127,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
|
||||
}
|
||||
@@ -159,7 +166,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],
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
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)
|
||||
}
|
||||
@@ -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 += sg.StateUpgrade.Cost()
|
||||
busyResources += c.upgradeCostNow(sg)
|
||||
}
|
||||
return p.ProductionCapacity() - busyResources
|
||||
}
|
||||
@@ -181,10 +181,15 @@ func (c *Cache) TurnPlanetProductions() {
|
||||
ri := c.RaceIndex(*p.Owner)
|
||||
r := &c.g.Race[ri]
|
||||
|
||||
// upgrade groups and return to in_orbit state
|
||||
productionAvailable := c.PlanetProductionCapacity(pn)
|
||||
// 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()
|
||||
for sg := range c.shipGroupsInUpgrade(p.Number) {
|
||||
cost := sg.StateUpgrade.Cost()
|
||||
cost := c.upgradeCostNow(sg)
|
||||
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()))
|
||||
|
||||
@@ -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.ErrRaceExinct))
|
||||
e.GenericErrorText(e.ErrRaceExtinct))
|
||||
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.ErrRaceExinct))
|
||||
e.GenericErrorText(e.ErrRaceExtinct))
|
||||
assert.ErrorContains(t,
|
||||
g.PlanetProduce(Race_0.Name, pn, "Hyperdrive", ""),
|
||||
e.GenericErrorText(e.ErrInputProductionInvalid))
|
||||
@@ -208,7 +208,40 @@ 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())
|
||||
|
||||
assert.Equal(t, 4346.676567656759, c.MustPlanet(R0_Planet_0_num).Material.F())
|
||||
// The pending upgrade is now charged once (not twice) against the planet's
|
||||
// production potential, so MAT production keeps the budget it previously
|
||||
// lost to the double charge (the pre-fix value here was ~4346.68).
|
||||
assert.InDelta(t, 7173.3432, c.MustPlanet(R0_Planet_0_num).Material.F(), 0.001)
|
||||
}
|
||||
|
||||
// TestUpgradeDoesNotDoubleChargeProduction guards that a pending upgrade is
|
||||
// paid for once out of the planet's production potential, leaving the rest for
|
||||
// the turn's production. The pre-fix code subtracted the upgrade cost twice
|
||||
// (PlanetProductionCapacity already nets it out for the report, and the apply
|
||||
// loop netted it again), which both starved production and could skip
|
||||
// affordable upgrades.
|
||||
func TestUpgradeDoesNotDoubleChargeProduction(t *testing.T) {
|
||||
c, _ := newCache()
|
||||
p := c.MustPlanet(R0_Planet_0_num)
|
||||
p.Population = 1000
|
||||
p.Industry = 1000 // ProductionCapacity = 1000*0.75 + 1000*0.25 = 1000
|
||||
p.Resources = 1 // material produced == leftover production budget
|
||||
p.Colonists = 0
|
||||
p.Material = 0
|
||||
assert.NoError(t, c.PlanetProduce(Race_0_idx, int(R0_Planet_0_num), game.ProductionMaterial, ""))
|
||||
|
||||
// One Cruiser with a pending drive upgrade 1.1 -> 2.0:
|
||||
// block cost = (1 - 1.1/2.0) * 10 * 15 = 67.5 for the single ship.
|
||||
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1))
|
||||
c.ShipGroup(0).StateUpgrade = &game.InUpgrade{
|
||||
UpgradeTech: []game.UpgradePreference{{Tech: game.TechDrive, Level: 2.0}},
|
||||
}
|
||||
|
||||
c.TurnPlanetProductions()
|
||||
|
||||
assert.InDelta(t, 2.0, c.ShipGroup(0).TechLevel(game.TechDrive).F(), 0.0001)
|
||||
// 1000 - 67.5 = 932.5; the pre-fix double charge would have left 865.
|
||||
assert.InDelta(t, 932.5, c.MustPlanet(R0_Planet_0_num).Material.F(), 0.01)
|
||||
}
|
||||
|
||||
func TestProduceShip(t *testing.T) {
|
||||
|
||||
@@ -98,7 +98,7 @@ func (c *Cache) validRace(name string) (int, error) {
|
||||
return -1, err
|
||||
}
|
||||
if c.g.Race[i].Extinct {
|
||||
return -1, e.NewRaceExinctError(name)
|
||||
return -1, e.NewRaceExtinctError(name)
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
@@ -117,13 +117,34 @@ func (c *Cache) raceTechLevel(ri int, t game.Tech, v float64) {
|
||||
}
|
||||
|
||||
func (c *Cache) TurnWipeExtinctRaces() {
|
||||
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) {
|
||||
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)) {
|
||||
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]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controller_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
e "galaxy/error"
|
||||
@@ -28,10 +29,10 @@ func TestRaceVote(t *testing.T) {
|
||||
e.GenericErrorText(e.ErrInputUnknownRace))
|
||||
assert.ErrorContains(t,
|
||||
g.RaceVote(Race_0.Name, Race_Extinct.Name),
|
||||
e.GenericErrorText(e.ErrRaceExinct))
|
||||
e.GenericErrorText(e.ErrRaceExtinct))
|
||||
assert.ErrorContains(t,
|
||||
g.RaceVote(Race_Extinct.Name, Race_1.Name),
|
||||
e.GenericErrorText(e.ErrRaceExinct))
|
||||
e.GenericErrorText(e.ErrRaceExtinct))
|
||||
}
|
||||
|
||||
func TestRaceRelation(t *testing.T) {
|
||||
@@ -54,10 +55,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.ErrRaceExinct))
|
||||
e.GenericErrorText(e.ErrRaceExtinct))
|
||||
assert.ErrorContains(t,
|
||||
g.RaceRelation(Race_Extinct.Name, Race_0.Name, "War"),
|
||||
e.GenericErrorText(e.ErrRaceExinct))
|
||||
e.GenericErrorText(e.ErrRaceExtinct))
|
||||
}
|
||||
|
||||
func TestRaceQuit(t *testing.T) {
|
||||
@@ -69,7 +70,7 @@ func TestRaceQuit(t *testing.T) {
|
||||
|
||||
assert.ErrorContains(t,
|
||||
g.RaceQuit(Race_Extinct.Name),
|
||||
e.GenericErrorText(e.ErrRaceExinct))
|
||||
e.GenericErrorText(e.ErrRaceExtinct))
|
||||
|
||||
assert.NoError(t, g.RaceQuit(Race_0.Name))
|
||||
assert.Equal(t, 3, int(c.Race(Race_0_idx).TTL))
|
||||
@@ -84,9 +85,45 @@ 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.ErrRaceExinct))
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrRaceExtinct))
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -129,13 +129,16 @@ 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)
|
||||
c.ReportOtherShipClass(ri, rep, battles)
|
||||
|
||||
// battles
|
||||
c.ReportBattle(ri, rep, battles)
|
||||
@@ -177,6 +180,36 @@ func (c *Cache) ReportRace(ri int, rep *mr.Report, battles []*mr.BattleReport, b
|
||||
c.ReportUnidentifiedGroup(ri, rep)
|
||||
}
|
||||
|
||||
// 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]
|
||||
@@ -249,7 +282,7 @@ func (c *Cache) ReportLocalShipClass(ri int, report *mr.Report) {
|
||||
slices.SortFunc(report.LocalShipClass, func(a, b mr.ShipClass) int { return cmp.Compare(a.Name, b.Name) })
|
||||
}
|
||||
|
||||
func (c *Cache) ReportOtherShipClass(ri int, rep *mr.Report) {
|
||||
func (c *Cache) ReportOtherShipClass(ri int, rep *mr.Report, battles []*mr.BattleReport) {
|
||||
c.validateRaceIndex(ri)
|
||||
r := &c.g.Race[ri]
|
||||
|
||||
@@ -273,26 +306,46 @@ func (c *Cache) ReportOtherShipClass(ri int, rep *mr.Report) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 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++
|
||||
// }
|
||||
// }
|
||||
// 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 ships from owned and observed planets
|
||||
for pn := range rep.OnPlanetGroupCache {
|
||||
@@ -392,13 +445,30 @@ func (c *Cache) ReportIncomingGroup(ri int, rep *mr.Report) {
|
||||
if sg.OwnerID == r.ID || sg.State() != game.StateInSpace {
|
||||
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
|
||||
}
|
||||
|
||||
distance := calc.ShortDistance(c.g.Map.Width, c.g.Map.Height, p1.X.F(), p1.Y.F(), p2.X.F(), p2.Y.F())
|
||||
// Remaining distance is measured from the group's current position in
|
||||
// hyperspace to the destination, not from its origin planet.
|
||||
distance := calc.ShortDistance(c.g.Map.Width, c.g.Map.Height, sg.StateInSpace.X.F(), sg.StateInSpace.Y.F(), p2.X.F(), p2.Y.F())
|
||||
var speed, mass float64
|
||||
if sg.FleetID != nil {
|
||||
speed, mass = c.FleetSpeedAndMass(c.MustFleetIndex(*sg.FleetID))
|
||||
@@ -541,12 +611,12 @@ func (c *Cache) ReportShipProduction(ri int, rep *mr.Report) {
|
||||
st := c.MustShipType(ri, *p.Production.SubjectID)
|
||||
|
||||
sliceIndexValidate(&rep.ShipProduction, i)
|
||||
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())
|
||||
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())
|
||||
i++
|
||||
}
|
||||
}
|
||||
@@ -685,33 +755,44 @@ 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]
|
||||
flightDistance := r.FlightDistance()
|
||||
visibility := r.VisibilityDistance()
|
||||
|
||||
clear(rep.UnidentifiedGroup)
|
||||
|
||||
i := 0
|
||||
for sgi := range rep.InSpaceGroupRangeCache {
|
||||
sg := c.ShipGroup(sgi)
|
||||
if sg.OwnerID == rep.RaceID {
|
||||
if sg.OwnerID == r.ID {
|
||||
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 {
|
||||
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 v, ok := rep.InSpaceGroupRangeCache[sgi][p.Number]; ok && v <= visibility {
|
||||
visible = true
|
||||
break
|
||||
}
|
||||
}
|
||||
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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,6 +814,7 @@ 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) {
|
||||
|
||||
@@ -3,8 +3,10 @@ package controller_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"galaxy/game/internal/model/game"
|
||||
"galaxy/model/report"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -86,3 +88,142 @@ func TestReportLocalShipClass(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportIncomingGroupVisibility checks that a group heading to one of the
|
||||
// recipient's planets is reported only while within the recipient's visibility
|
||||
// range (driveTech*30); beyond it the group is hidden even though it is inbound.
|
||||
func TestReportIncomingGroupVisibility(t *testing.T) {
|
||||
c, _ := newCache()
|
||||
gi := c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R1_Planet_1_num, 5)
|
||||
c.ShipGroup(gi).Destination = R0_Planet_0_num
|
||||
|
||||
// Within Race_0 visibility (driveTech 1.1 -> 33 ly), near Planet_2 (3,3).
|
||||
c.ShipGroup(gi).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(5), Y: floatRef(5)}
|
||||
rep := c.InitReport(1)
|
||||
c.ReportIncomingGroup(Race_0_idx, rep)
|
||||
assert.Len(t, rep.IncomingGroup, 1)
|
||||
|
||||
// Beyond the visibility of every Race_0 planet: hidden.
|
||||
c.ShipGroup(gi).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(40), Y: floatRef(40)}
|
||||
rep = c.InitReport(1)
|
||||
c.ReportIncomingGroup(Race_0_idx, rep)
|
||||
assert.Len(t, rep.IncomingGroup, 0)
|
||||
}
|
||||
|
||||
// TestReportUnidentifiedGroup checks the three rules for the unidentified list:
|
||||
// groups heading to the recipient's planets are excluded (they are "incoming"),
|
||||
// only groups within visibility (driveTech*30) appear, and each group appears
|
||||
// once even when several owned planets are in range.
|
||||
func TestReportUnidentifiedGroup(t *testing.T) {
|
||||
c, _ := newCache()
|
||||
cls := c.MustShipClass(Race_1_idx, Race_1_Gunship).ID
|
||||
|
||||
// Not inbound to Race_0, within visibility of BOTH Planet_0 and Planet_2.
|
||||
g0 := c.CreateShipsUnsafe_T(Race_1_idx, cls, R1_Planet_1_num, 1)
|
||||
c.ShipGroup(g0).Destination = Uninhabited_Planet_3_num
|
||||
c.ShipGroup(g0).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(5), Y: floatRef(5)}
|
||||
|
||||
// Inbound to a Race_0 planet -> reported as incoming, not unidentified.
|
||||
g1 := c.CreateShipsUnsafe_T(Race_1_idx, cls, R1_Planet_1_num, 1)
|
||||
c.ShipGroup(g1).Destination = R0_Planet_0_num
|
||||
c.ShipGroup(g1).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(5), Y: floatRef(5)}
|
||||
|
||||
// Not inbound, beyond visibility -> hidden.
|
||||
g2 := c.CreateShipsUnsafe_T(Race_1_idx, cls, R1_Planet_1_num, 1)
|
||||
c.ShipGroup(g2).Destination = Uninhabited_Planet_3_num
|
||||
c.ShipGroup(g2).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(40), Y: floatRef(40)}
|
||||
|
||||
rep := c.InitReport(1)
|
||||
c.ReportUnidentifiedGroup(Race_0_idx, rep)
|
||||
assert.Len(t, rep.UnidentifiedGroup, 1)
|
||||
}
|
||||
|
||||
// TestReportOtherShipClassFromBattle checks that the class of a foreign ship
|
||||
// met in a battle the recipient witnessed is surfaced in OtherShipClass, with
|
||||
// its design looked up from the owner race's ship types, while the recipient's
|
||||
// own class is skipped.
|
||||
func TestReportOtherShipClassFromBattle(t *testing.T) {
|
||||
c, _ := newCache()
|
||||
br := &report.BattleReport{
|
||||
Races: map[int]uuid.UUID{0: Race_0.ID, 1: Race_1.ID},
|
||||
Ships: map[int]report.BattleReportGroup{
|
||||
0: {Race: Race_1.Name, ClassName: Race_1_Gunship},
|
||||
1: {Race: Race_0.Name, ClassName: Race_0_Gunship}, // recipient's own -> skipped
|
||||
},
|
||||
}
|
||||
rep := c.InitReport(1)
|
||||
c.ReportOtherShipClass(Race_0_idx, rep, []*report.BattleReport{br})
|
||||
|
||||
assert.Len(t, rep.OtherShipClass, 1)
|
||||
g := rep.OtherShipClass[0]
|
||||
assert.Equal(t, Race_1.Name, g.Race)
|
||||
assert.Equal(t, Race_1_Gunship, g.Name)
|
||||
assert.Equal(t, report.F(60.), g.Drive)
|
||||
assert.Equal(t, uint(3), g.Armament)
|
||||
assert.Equal(t, report.F(30.), g.Weapons)
|
||||
assert.Equal(t, report.F(100.), g.Shields)
|
||||
assert.Equal(t, report.F(0.), g.Cargo)
|
||||
assert.Equal(t, report.F(220.), g.Mass)
|
||||
}
|
||||
|
||||
// TestReportShipProductionIndex guards the report index: when the only
|
||||
// ship-producing planet is not the first planet in the map, its entry must
|
||||
// land at the compacted report index, not the planet's map index (which would
|
||||
// write out of the grown slice and panic).
|
||||
func TestReportShipProductionIndex(t *testing.T) {
|
||||
c, _ := newCache()
|
||||
assert.NoError(t, c.PlanetProduce(Race_0_idx, int(R0_Planet_2_num), game.ProductionShip, ShipType_Cruiser))
|
||||
|
||||
rep := c.InitReport(1)
|
||||
c.ReportShipProduction(Race_0_idx, rep)
|
||||
assert.Len(t, rep.ShipProduction, 1)
|
||||
assert.Equal(t, R0_Planet_2_num, rep.ShipProduction[0].Planet)
|
||||
}
|
||||
|
||||
// TestReportIncomingGroupRemainingDistance checks the reported distance is the
|
||||
// remaining distance from the group's current hyperspace position to the
|
||||
// destination, not the full origin-to-destination route.
|
||||
func TestReportIncomingGroupRemainingDistance(t *testing.T) {
|
||||
c, _ := newCache()
|
||||
gi := c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R1_Planet_1_num, 1)
|
||||
c.ShipGroup(gi).Destination = R0_Planet_0_num // Planet_0 at (1,1)
|
||||
c.ShipGroup(gi).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(5), Y: floatRef(5)}
|
||||
|
||||
rep := c.InitReport(1)
|
||||
c.ReportIncomingGroup(Race_0_idx, rep)
|
||||
assert.Len(t, rep.IncomingGroup, 1)
|
||||
// current (5,5) -> dest (1,1) = sqrt(32) ≈ 5.657; the origin (2,2) -> dest
|
||||
// route would be sqrt(2) ≈ 1.414.
|
||||
assert.InDelta(t, 5.657, rep.IncomingGroup[0].Distance.F(), 0.01)
|
||||
}
|
||||
|
||||
// TestReportExitWarnings checks the inactivity-removal warnings: the recipient
|
||||
// gets a personal countdown only at TTL 1..5, other non-extinct races within 3
|
||||
// turns are listed publicly, the recipient is excluded from its own public
|
||||
// list, and extinct races never appear.
|
||||
func TestReportExitWarnings(t *testing.T) {
|
||||
c, _ := newCache()
|
||||
c.Race(Race_0_idx).TTL = 5
|
||||
c.Race(Race_1_idx).TTL = 2
|
||||
c.Race(2).TTL = 2 // Race_Extinct: extinct, must never appear publicly
|
||||
|
||||
// Race_0's report: personal countdown 5; only Race_1 (TTL 2) is public.
|
||||
r0 := &report.Report{}
|
||||
c.ReportExitWarnings(Race_0_idx, r0)
|
||||
assert.Equal(t, uint(5), r0.PersonalExitWarning)
|
||||
assert.Len(t, r0.RacesLeavingSoon, 1)
|
||||
assert.Equal(t, Race_1.Name, r0.RacesLeavingSoon[0].Race)
|
||||
assert.Equal(t, uint(2), r0.RacesLeavingSoon[0].TurnsLeft)
|
||||
|
||||
// Race_1's report: personal countdown 2; Race_0 (TTL 5 > 3) is not public.
|
||||
r1 := &report.Report{}
|
||||
c.ReportExitWarnings(Race_1_idx, r1)
|
||||
assert.Equal(t, uint(2), r1.PersonalExitWarning)
|
||||
assert.Empty(t, r1.RacesLeavingSoon)
|
||||
|
||||
// TTL above the 5-turn window → no personal warning.
|
||||
c.Race(Race_0_idx).TTL = 6
|
||||
r0b := &report.Report{}
|
||||
c.ReportExitWarnings(Race_0_idx, r0b)
|
||||
assert.Zero(t, r0b.PersonalExitWarning)
|
||||
}
|
||||
|
||||
@@ -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.ErrRaceExinct))
|
||||
e.GenericErrorText(e.ErrRaceExtinct))
|
||||
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.ErrRaceExinct))
|
||||
e.GenericErrorText(e.ErrRaceExtinct))
|
||||
assert.ErrorContains(t,
|
||||
g.PlanetRouteRemove(Race_0.Name, "IND", 0),
|
||||
e.GenericErrorText(e.ErrInputCargoTypeInvalid))
|
||||
|
||||
@@ -2,6 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"galaxy/util"
|
||||
@@ -36,7 +37,9 @@ func (c *Cache) ScienceCreate(ri int, name string, drive, weapons, shileds, carg
|
||||
return e.NewCargoValueError(cargo)
|
||||
}
|
||||
sum := drive + weapons + shileds + cargo
|
||||
if sum != 1 {
|
||||
// The proportions must add up to one; a small tolerance keeps inputs like
|
||||
// 0.1+0.2+0.3+0.4 (which is 1 only up to float rounding) from being rejected.
|
||||
if math.Abs(sum-1) > 1e-9 {
|
||||
return e.NewScienceSumValuesError("D=%f W=%f S=%f C=%f sum=%f", drive, weapons, shileds, cargo, sum)
|
||||
}
|
||||
c.g.Race[ri].Sciences = append(c.g.Race[ri].Sciences, game.Science{
|
||||
|
||||
@@ -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.ErrRaceExinct))
|
||||
e.GenericErrorText(e.ErrRaceExtinct))
|
||||
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.ErrRaceExinct))
|
||||
e.GenericErrorText(e.ErrRaceExtinct))
|
||||
assert.ErrorContains(t,
|
||||
g.ScienceRemove(Race_0.Name, first),
|
||||
e.GenericErrorText(e.ErrInputEntityNotExists))
|
||||
@@ -136,3 +136,14 @@ 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))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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")
|
||||
}
|
||||
@@ -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.ErrRaceExinct))
|
||||
e.GenericErrorText(e.ErrRaceExtinct))
|
||||
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.ErrRaceExinct))
|
||||
e.GenericErrorText(e.ErrRaceExtinct))
|
||||
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.ErrRaceExinct))
|
||||
e.GenericErrorText(e.ErrRaceExtinct))
|
||||
assert.ErrorContains(t,
|
||||
g.ShipClassRemove(Race_0.Name, "Elephant"),
|
||||
e.GenericErrorText(e.ErrInputEntityNotExists))
|
||||
|
||||
@@ -177,6 +177,12 @@ 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)
|
||||
@@ -408,7 +414,7 @@ func (c *Cache) ShipGroupBreak(ri int, groupID, newID uuid.UUID, quantity uint)
|
||||
}
|
||||
|
||||
if c.ShipGroup(sgi).Number < quantity {
|
||||
return e.NewBeakGroupNumberNotEnoughError("%d<%d", c.ShipGroup(sgi).Number, quantity)
|
||||
return e.NewBreakGroupNumberNotEnoughError("%d<%d", c.ShipGroup(sgi).Number, quantity)
|
||||
}
|
||||
|
||||
if quantity > 0 && quantity < c.ShipGroup(sgi).Number {
|
||||
@@ -491,7 +497,7 @@ func (c *Cache) shipGroupsInUpgrade(planetNumber uint) iter.Seq[*game.ShipGroup]
|
||||
}
|
||||
}
|
||||
slices.SortFunc(result, func(a, b int) int {
|
||||
return cmp.Compare(c.g.ShipGroups[b].StateUpgrade.Cost(), c.g.ShipGroups[a].StateUpgrade.Cost())
|
||||
return cmp.Compare(c.upgradeCostNow(&c.g.ShipGroups[b]), c.upgradeCostNow(&c.g.ShipGroups[a]))
|
||||
})
|
||||
for i := range result {
|
||||
if !yield(&c.g.ShipGroups[result[i]]) {
|
||||
|
||||
@@ -34,15 +34,20 @@ func (c *Cache) MoveShipGroups() {
|
||||
|
||||
func (c *Cache) moveShipGroup(i int, delta float64) {
|
||||
sg := c.ShipGroup(i)
|
||||
originX, originY, ok := sg.Coord()
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("ship group state invalid: %v", sg.State()))
|
||||
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()))
|
||||
}
|
||||
destPlanet := c.MustPlanet(sg.Destination)
|
||||
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)
|
||||
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,3 +45,20 @@ 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
Reference in New Issue
Block a user