- ARCHITECTURE §17: the dev (production-mirror) collection stack
(Prometheus / Loki / Tempo / promtail / node-exporter / cAdvisor) and
the single /_gm Basic Auth gate fronting Grafana and the Mailpit UI.
- tools/dev-deploy/monitoring/README.md (new): services, what is
collected, Grafana-behind-the-gate access, config delivery, tuning.
- tools/dev-deploy/README.md: an Observability section; the Mailpit UI
under /_gm/mailpit/; Networking diagram and Files list updated.
- FUNCTIONAL §10.2.1 (+ ru mirror): the operator console nav links to
Grafana and Mailpit under the same /_gm gate, one sign-in for all.
Consolidate the operator console and the observability / captured-mail
UIs behind a single Basic Auth gate, so one password (the admin-console
account, dev: gm/gm-dev-password) unlocks all three, with links in the
console nav:
- Caddyfile.dev: a single basic_auth on /_gm/* fronts nested routes —
/_gm/grafana/ -> Grafana, /_gm/mailpit/ -> Mailpit, catch-all -> the
gateway/backend console. Caddy forwards the same Authorization header,
which the backend console also accepts, so there is one prompt. The
former top-level /grafana/ and /mailpit/ routes are removed.
- Grafana: served under /_gm/grafana/ (sub-path) as anonymous Admin with
the login form and basic auth disabled, so it relies solely on the
/_gm gate and ignores the forwarded credentials.
- Mailpit: MP_WEBROOT=/_gm/mailpit (and the healthcheck path) so its UI
lives under the gate.
- Operator console: add Grafana and Mailpit links to the nav.
MP_WEBROOT=/mailpit prefixes every Mailpit HTTP route, including the
/livez health endpoint. The container healthcheck still probed
http://localhost:8025/livez, which now 404s, so Mailpit reported
unhealthy; the backend depends_on it with condition: service_healthy
and never started, cascading to the gateway and Caddy and failing
`docker compose up --wait`. Point the healthcheck at /mailpit/livez.
Deploy wiring for the observability stack (the services and collector
config landed in the previous commit):
- Caddyfile.dev: route /grafana/* to galaxy-grafana:3000 (Caddy
sub-path mode, Grafana keeps its own login) and /mailpit/* to
galaxy-mailpit:8025 behind dev basic-auth, so the captured-mail UI
(every message, relayed or not) and Grafana are reachable through the
single dev origin.
- dev-deploy.yaml: seed the monitoring config tree to a stable,
reboot-surviving host path (GALAXY_DEV_MONITORING_DIR) before bringing
the stack up, and inject the Grafana admin password from a Gitea
secret (GALAXY_DEV_GRAFANA_ADMIN_PASSWORD; empty falls back to the
compose default).
Stand up a production-mirror monitoring stack in the long-lived dev
contour, all on galaxy-dev-internal with no host ports (reached only via
the in-repo galaxy-dev-caddy):
- Prometheus scrapes backend:9100, gateway:9191, node-exporter and
cadvisor (30s interval, 15d retention); Loki (7d) + promtail (Docker
service discovery by the galaxy.stack=dev-deploy label) for logs;
Tempo (3d) for traces.
- Backend and gateway now export OTLP traces to Tempo over plaintext
gRPC on the internal network (OTEL_EXPORTER_OTLP_INSECURE).
- Grafana provisioned as code (Prometheus/Loki/Tempo datasources plus a
starter dashboard), served under /grafana/ via Caddy sub-path mode;
admin password from the GALAXY_DEV_GRAFANA_ADMIN_PASSWORD secret.
- Expose the Mailpit capture UI under /mailpit/ (Caddy basic-auth +
MP_WEBROOT) so every captured message is readable regardless of relay.
- dev-deploy.yaml seeds the monitoring config to a stable, reboot-
surviving host path and injects the Grafana admin secret.
Per-service memory limits keep the footprint within budget. All
collector config lives under tools/dev-deploy/monitoring/ for dev/prod
parity.
Keep Mailpit as the backend's SMTP submission point and turn on its
relay so OTP/notification mail addressed to the owner reaches a real
Gmail inbox, while everything else stays captured-only.
- mailpit gains --smtp-relay-config + --smtp-relay-matching (default
non-routable, so an unconfigured stack only captures); relay.conf is
mounted from a new galaxy-dev-mailpit-config volume
- tools/dev-deploy/mailpit/relay.conf.tmpl + a dev-deploy.yaml step that
renders it from Gitea secrets (Gmail App Password, never committed)
and seeds the volume; the GALAXY_DEV_MAIL_RELAY_MATCH var drives the
relay-matching recipient
- backend SMTP config unchanged (still -> galaxy-mailpit:1025)
- dev-deploy README documents the relay + required secrets/vars
Verified locally: compose config valid; the rendered relay.conf is
accepted by mailpit v1.21.8 (relay + recipient-matching enabled).
Real Gmail delivery is verified at the dev-deploy preview once the
owner sets the secrets.
Stage 2 of the dev-as-prod-mirror rework. The legacy-report (synthetic)
report loader is already available in the dev-deploy UI: it is gated by
the build-time flag VITE_GALAXY_DEV_AFFORDANCES (set "true" in
dev-deploy.yaml line 89, unset in prod-build.yaml so prod strips it),
not by import.meta.env.DEV. Correct the stale header comment that
claimed import.meta.env.DEV. No functional change — the desired
"loader in dev, absent in prod" posture already holds.
Stage 1 of the dev-as-prod-mirror rework. The auto-provisioned "Dev
Sandbox" game and dummy users are removed so the dev contour starts
empty like prod; the separate legacy-report loader stays as the
test-data path.
- delete backend/internal/devsandbox (package + tests)
- drop the bootstrap call + DevSandboxConfig (struct, Config field,
BACKEND_DEV_SANDBOX_* env, defaults, loader, validation)
- strip BACKEND_DEV_SANDBOX_* from dev-deploy + local-dev compose and
.env.example; the generic engine-recycle / prune-broken-engines logic
stays (it serves real games)
- update tooling docs (dev-deploy README + KNOWN-ISSUES, local-dev
README + Makefile) and stale comments; DeleteGame and
InsertMembershipDirect remain (exercised by lobby integration tests)
No app behaviour change beyond not auto-creating the sandbox game.
Add the mail, notifications, and broadcast pages over the mail, notification,
and diplomail services (no new business logic), completing the operator console.
- GET /_gm/mail deliveries (paginated) + dead-letters
- GET /_gm/mail/deliveries/{id} delivery detail + attempts
- POST /_gm/mail/deliveries/{id}/resend re-enqueue a non-sent delivery
- GET /_gm/notifications notifications + dead-letters + malformed
- GET/POST /_gm/broadcast multi-game admin diplomatic broadcast
Console depends on MailAdmin / NotificationAdmin / DiplomailAdmin interfaces
(satisfied by the concrete services); pages render in tests without a database.
Delivery detail and dead-letters live under /_gm/mail/deliveries/* and
/_gm/mail/... static segments to avoid a param/static route conflict. Resend
and broadcast flow through the CSRF guard.
Tests: mail page, delivery detail (+ not-found), resend (+ bad-CSRF),
notifications overview, broadcast form + send (input assertions) + bad game
ids, and unavailable. Plus an integration test that drives /_gm end to end
through the real gateway → backend (401 challenge + authenticated dashboard).
Docs: backend/docs/admin-console.md page inventory completed.
Add the operator-management page over *admin.Service (no new business logic).
- GET/POST /_gm/operators list + create operator
- POST /_gm/operators/{user}/disable|enable toggle access
- POST /_gm/operators/{user}/reset-password set a new password
Console depends on an OperatorAdmin interface (satisfied by *admin.Service) so
the page renders in tests without a database. Create POST is mounted on the
collection path; per-row disable/enable/reset are guarded by the CSRF middleware
and redirect back. Passwords are never logged.
Tests: list render, create (+ username/password assertions), username-taken
conflict, disable/enable, reset (+ password assertion), missing-password 400,
bad-CSRF 403, and unavailable 503.
Docs: backend/docs/admin-console.md page inventory extended.
Add the games, runtime, and engine-version pages over the existing lobby,
runtime, and engine-version services (no new business logic).
- GET/POST /_gm/games list + create public game
- GET /_gm/games/{id} detail incl. runtime snapshot
- POST /_gm/games/{id}/force-start|stop game state actions
- POST /_gm/games/{id}/ban-member ban a member (uuid + reason)
- POST /_gm/games/{id}/runtime/restart|patch|force-next-turn
- GET/POST /_gm/engine-versions registry + register
- POST /_gm/engine-versions/{ver}/disable disable a version
Console depends on GameAdmin / RuntimeAdmin / EngineVersionAdmin interfaces
(satisfied by the concrete services) so the pages render in tests without a
database. Collection-mutating POSTs are mounted on the collection path to avoid
a static-vs-param route conflict in gin. Writes flow through the CSRF guard and
redirect back; the create form parses datetime-local as UTC.
Tests: list/detail (with and without a runtime), create (visibility/owner/time
assertions), force-start (+ bad-CSRF), ban-member (+ bad uuid), runtime patch
(+ missing version), engine-version list/register/disable, and unavailable.
Docs: backend/docs/admin-console.md page inventory extended.
Add the operator console's user-administration pages over the existing
*user.Service (no new business logic).
- GET /_gm/users paginated account list
- GET /_gm/users/{id} account detail: profile, entitlement, sanctions
- POST /_gm/users/{id}/block apply permanent_block (reason required)
- POST /_gm/users/{id}/entitlement set the entitlement tier
- POST /_gm/users/{id}/soft-delete soft-delete the account (cascades)
The console depends on a UserAdmin interface (satisfied by *user.Service) so the
pages render in tests without a database. All writes flow through the CSRF
guard, carry the operator as the audit actor, and answer with a 303 redirect;
a generic message page handles not-found, validation, and failure notices.
Unblock is intentionally absent — the admin API exposes no remove-sanction
endpoint.
Tests: list/detail render, not-found, block (with actor/scope/reason
assertions), missing-reason 400, bad-CSRF 403, entitlement, soft-delete
redirect, and the service-unavailable path.
Docs: backend/docs/admin-console.md gains the page inventory.
Turn the console landing page into an operational dashboard.
- new internal/opsstatus: read-only Postgres projection via go-jet — ping +
per-status COUNT/GROUP BY on runtime_records, mail_deliveries,
notification_routes, and a malformed-intent count; degrades per-probe into
Snapshot.Errors rather than failing the page
- dashboard renders backend readiness, database health, the three status
tables, the malformed count, and any collection errors; falls back to a
"monitoring not wired" note when no reader is injected
- AdminConsoleHandlers now takes an AdminConsoleDeps struct (Monitor + Ready
added) so later stages add service refs without churning the signature
Tests: opsstatus store test against a Postgres testcontainer (empty schema +
one enqueued delivery); dashboard render tests with a fake reader (with and
without monitoring).
Docs: ARCHITECTURE 14.1 + FUNCTIONAL 10.2.1 (+ru) describe the dashboard.
(Prometheus /metrics exporters were already enabled in dev-deploy in Stage 1.)
site/rules.md is a faithful English mirror of the authoritative Russian
site/ru/rules.md — the same section anchors (so the in-page cross-links
and the RU/EN structure line up), the same LaTeX formulas with English
labels, and the same tables and engine nuances. Rewrite the English home
intro to match the Russian one and link to the rules, and register Rules
in the English sidebar. Completes the bilingual rules.
The duplicate-then-disappearing formulas were a Vue hydration mismatch,
not a CSS problem. markdown-it-mathjax3 v5 pulls the `mathxyjax3` fork,
which emits each formula's CSS as an in-content `<style>` block scoped to
a per-container `#mjx-<id>` that the static build never sets. The orphaned
scoped CSS left the screen-reader MathML twin visible (the duplicate), and
the in-`<main>` `<style>` elements break VitePress/Vue hydration
("Hydration completed but contains mismatches"), which strips the SVG
glyph `<path>`s and blanks every formula after the page finishes loading.
Downgrade to markdown-it-mathjax3 ^4.3.2 — the mathjax-full-based version
VitePress officially supports. It uses `juice` to inline all CSS into the
element `style` attributes (no in-content `<style>`), so hydration is
clean (glyphs survive) and the MathML twin is hidden by its own inlined
style (no duplicate). This also drops the earlier custom.css workaround,
which only treated the symptom and itself blanked the formulas.
Verified with a headless Chromium render of the built /ru/rules: all 10
formulas keep their glyph paths after hydration, no console mismatch, no
duplicate copies.
markdown-it-mathjax3 renders each formula as a visible SVG plus a MathML
twin (<mjx-assistive-mml>) for screen readers, hidden via CSS scoped to a
per-container #mjx-<id> selector. In the static build the containers carry
no id, so that scoped rule matches nothing and the twin renders as a
second, oversized (theme-monospaced) copy of every formula, in every
browser. Add a global visually-hidden rule for mjx-assistive-mml in the
theme CSS: the twin stays in the DOM for assistive tech but is removed
from view and from layout.
Editorial pass over site/ru/rules.md (on top of the verbatim port):
- moved the lore intro to the RU home page, rewritten in a modern voice;
- fixed typos, replaced the TODO/WTF cargo-tech note and the abandoned
(---ссылка---) marker with the verified mechanic and a real cross-link,
dropped the report TODO row;
- wove organic intra-page cross-links (#combat, #movement, #victory, ...);
- documented engine nuances verified against the code: ore auto-farming
and the capital / "запасы промышленности" store (industry capped at
population); cargo lost with ships destroyed in battle; and that a
losing race's colonists at a neutral planet are NOT lost — they stay
aboard (this corrects the audit note, verified in route.go).
Migration: delete game/rules.txt (its content now lives, authoritative,
in site/ru/rules.md) and repoint every reference to it (ui/frontend code
comments + tests, ui/docs, tools, ui/PLAN.md links). Record the
RU-authoritative rule in site/README.md and CLAUDE.md. The English
site/rules.md mirror follows in a separate stage.
Faithful Markdown rendering of game/rules.txt for the site: headings with
stable anchors, GFM tables and LaTeX formulas — the text itself is
unchanged (typos, the TODO/WTF notes, the broken (---ссылка---) marker and
the lore intro are all preserved as-is). The editorial pass (clarity,
nuances, organic cross-links, intro moved to the home page) follows in a
separate commit so its diff isolates exactly what changed relative to the
original. Registers the page in the RU sidebar.
In host-mode the ui-test job runs as root, so vite (test:pwa),
svelte-kit and Playwright write build/, .svelte-kit/, test-results/ and
playwright-report/ root-owned into the shared host workspace. The
act_runner (non-root) then cannot remove them at teardown
("unlinkat ui/frontend/build: permission denied"), which spuriously
marks this or a sibling job that inherits the dirty workspace as failed
— it hit go-unit on the #83 merge even though every test passed.
Add an `if: always()` step that removes those generated dirs while the
job still has root, after the artifact uploads. Keeps the shared
workspace clean for the runner's own teardown and for later jobs.
The committed FlatBuffers bindings were generated by flatc 25.x (the TS
runtime is flatbuffers@25.9.23), but nothing pinned the compiler, so a
regen on a box with an older flatc (Debian apt ships 23.5.26) silently
churns output and flips nullable-scalar builder defaults. PR #82 hit this
and shipped 5 report files from the wrong compiler.
Unify the whole toolchain on 25.9.23 (the only version available as an
npm package, a prebuilt flatc binary, and a Go tag) and make the bindings
reproducible:
- Downgrade the flatbuffers Go module 25.12.19 -> 25.9.23 (schema,
transcoder, gateway, integration) so compiler and both runtimes match.
- Regenerate every schema with flatc 25.9.23. The only resulting change
is order/command-item.ts: the lone straggler still on the old
optional-scalar builder default (cmd_applied/cmd_error_code: 0 -> null).
Inert in practice — the TS side never builds those response-only fields
(the engine sets them in Go); the reader is unchanged.
- Pin the version in tooling: a flatc-check guard in ui/Makefile (fbs-ts)
and a new pkg/schema/fbs/Makefile (fbs-go); both refuse a mismatched
flatc and point at the release binary. Fix the stale apt install hint.
- Add a path-filtered CI guard (.gitea/workflows/fbs-codegen.yaml) that
regenerates with the pinned flatc and fails on any diff.
- Document the pinned version and the regen commands in the schema README.
No wire-format change: Go build/vet, transcoder roundtrip + engine tests,
pnpm check and the full vitest suite (888) stay green.
Surface the inactivity-removal countdown the rules promise but the
engine never reported. A race within five turns of being auto-removed
for inactivity gets a personal warning in its own report; every race
within three turns is listed publicly to all participants.
- model: Report.PersonalExitWarning + RacesLeavingSoon ([]RaceExitNotice)
- fbs: RaceExitNotice table + Report.personal_exit_warning /
races_leaving_soon (regenerated Go + TS bindings)
- transcoder: encode/decode both fields
- engine: ReportExitWarnings fills the recipient's TTL (1..5) and lists
other non-extinct races with TTL 1..3, excluding the recipient itself
- ui: danger-styled personal banner + "races leaving soon" section
(hidden when empty), wired into the report view, EN/RU i18n
- docs: rules.txt report-section list, FUNCTIONAL.md 6.4 + RU mirror
Voluntary quit and idle timeout share the TTL countdown and are not
distinguished, per the agreed scope.
A bundle of small rules-vs-engine corrections:
- Science proportions: accept a sum that equals 1 only up to float
rounding (was an exact != 1 comparison); the rules example is reworded
so it is unambiguous that proportions are fractions summing to 1.
- Generation: super-big planets get a resource strictly above 0 (minimum
0.001, was a hard 0.1); the rules table is fixed for big planets (1-10,
not 0.1-10) and the false "0.1-20 / average 1.5" resource claim removed.
- Dismantle over a neutral planet now unloads the colonists and settles
it (the planet becomes the race's); over a foreign planet they are
still lost. The rules clause is clarified for own / neutral / foreign.
- Report: ship-production entries are written at the compacted report
index (was the planet's map index, which could write past the grown
slice and panic); the incoming-group "remaining distance" is measured
from the group's current hyperspace position, not its origin planet
(matching OtherGroup).
- validator: the cargo-value error now carries the cargo value, not the
shields value.
Tests added for each behavioural fix; rules.txt updated in the same patch.
TurnWipeExtinctRaces iterated only non-extinct races, so an
administratively banished race (flagged extinct, TTL untouched) was never
wiped: its planets stayed owned and its ships lingered, while the race
itself could no longer act. The loop now covers every race and wipes when
either an active race's TTL has run out (idle / quit) or an extinct race
still holds assets (banish). The asset check makes repeated passes
idempotent.
wipeRace already matched the rules for exclusion (ships removed, planets
uninhabited, industry and capital cleared, material retained), so the
behaviour is just documented in game/README.md.
Tests: banish releases planets and ships on the next turn (and is
idempotent); idle-timeout wipe still fires under the new iterator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per the rules ("Бомбардировка планет"), a planet is bombed from the
strongest attacking power downwards, and a planet bombed to extinction
keeps its material and capital stockpiles but loses its working industry.
ProduceBombings now sorts attacking races by total bombing power
(descending) instead of iterating the attacker map in random order, and
on a wipe zeroes the planet's industry (Free already keeps capital and
material). bombingPower is extracted as a shared helper.
The rules already describe both, so no documentation change. Tests:
bombing order by power, and industry collapse with capital/material kept
on a wipe.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring the report's foreign-group and foreign-class visibility in line
with the rules (game/rules.txt "Движение" and the report sections):
- incoming groups (heading to one of the recipient's planets) are shown
only within the recipient's visibility range (driveTech*30); beyond it
a group is hidden even though it is inbound;
- the unidentified-group list now uses the visibility range (it used the
flight range, driveTech*40), excludes groups heading to the recipient's
planets (those belong to the incoming list), and reports each group
once (it previously emitted an entry per in-range owned planet);
- ship classes met in a battle the recipient took part in or witnessed
now appear in OtherShipClass, with the design looked up from the owner
race's ship types (the battle report carries only the class name).
The rules already describe this behaviour and the report wire shape is
unchanged, so no documentation change. Tests added for all three.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TurnPlanetProductions started its production budget from
PlanetProductionCapacity, which already subtracts the reserved upgrade
cost, and then subtracted each applied upgrade's cost again in the apply
loop — charging every applied upgrade twice. That both starved the
planet's build/research budget and could skip upgrades that were in fact
affordable.
The budget now starts from the planet's full production potential and the
apply loop deducts each upgrade once; PlanetProductionCapacity stays the
report's net-of-upgrades "free L".
Test: TestUpgradeDoesNotDoubleChargeProduction; the TestProduceShips MAT
expectation is updated to the once-charged value.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per the documented turn order (game/rules.txt "Последовательность
действий"), no ship should dodge the pre-departure battle by slipping
into hyperspace. MakeTurn now runs merge -> battle -> load+launch routed
groups -> fly -> merge -> battle, so:
- ships ordered to depart (Launched) and ships being upgraded now take
part in the pre-departure battle at their planet (CollectPlanetGroups /
FilterBattleGroups); only survivors then enter hyperspace;
- routed transports are loaded and launched AFTER that battle, so they
fight empty and cannot escape it.
A just-launched group has no stored hyperspace position, so moveShipGroup
starts its first leg from the origin planet; the previous code read the
nil launch coordinate and would panic.
Because upgrading groups can now lose ships in the battle, the pending
upgrade cost is recomputed from the group's current ship count instead of
the value stored when the order was validated.
Rules: reordered "Последовательность действий" and rewrote the combat
note that ordered/routed ships skip the battle.
Tests: launched-group move from origin, launched/upgrade groups taking
part in battle, upgrade cost tracking ship losses.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The battle engine diverged from the documented combat model
(game/rules.txt "Сражения") in three ways:
- the destruction roll was inverted (rand >= p), so a near-certain hit
destroyed its target only ~(1-p) of the time;
- a whole group fired as a single ship (Armament shots per round)
regardless of its ship count, so fleet size never affected offence;
- the defending mass used the whole group's full mass instead of one
target ship's, weakening grouped ships' shields by ~Number^(1/3).
SingleBattle now resolves ship by ship: every living ship fires once per
round in random order across all groups, each gun targets a random enemy
ship (weighted by group size), and the destruction roll matches the
documented probability. FilterBattleOpponents evaluates per-ship mass.
Also fixes opponent-map initialisation in ProduceBattles that kept only
an attacker's last opponent.
The rules already describe this model, so no documentation change is
needed. Tests: per-ship one-sided wipe, destruction-roll direction, and
the updated per-ship-mass probability expectation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up tidy after the cross-service /command removal (#73):
- Rename the router test double dummyExecutor -> fakeEngine (and the
newExecutor / setupRouterExecutor helpers -> newFakeEngine /
setupRouterEngine): it implements handler.Engine now, "executor" was a
leftover of the removed adapter. Test-only.
- Regenerate the ui/core canon signing golden onto user.games.order
(request_user_games_command.json -> request_user_games_order.json, fresh
canonical bytes + Ed25519 signature) and drop the last
user.games.command references from the Go/TS tests and docs.
- Align game openapi: CommandRequest.cmd no longer carries minItems: 1. It
is now used only by PUT /api/v1/order, which accepts an empty batch
(clearing the player's stored order, equivalent to removing every
command); the contract test freezes the empty-allowed shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend e2e tests (and, more rarely, service startup) intermittently
failed applying migrations with `driver: bad connection`: a freshly
started Postgres — notably a test container — can reset a pooled
connection moments after it reports ready, killing the migration
transaction. The harness already waits for the double "ready" log and
pings before migrating, yet goose can still draw a connection postgres
then resets.
ApplyMigrations now wraps the schema-create + goose run in a bounded
retry that fires only on transient connection errors (driver.ErrBadConn
and the connection-failure messages Postgres drivers surface); both
steps are idempotent, so a retry resumes cleanly. Deterministic SQL
errors still fail fast.
Fixes the intermittent TestDiplomailAsyncFallbackOnUnsupportedPair (and
the eight other testcontainer e2e harnesses that share ApplyMigrations).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three-stage refactor of the game-engine plumbing (game logic untouched):
Stage 1 — lock-free persistence + admin serialisation. Remove the file
lock from repo/fs (the .lock file, the Read/Write-vs-*Safe duality and the
dead ReadSafe polling) and replace the two-step rename with a single atomic
rename so concurrent reads are torn-free without a lock. Serialise the
state-mutating admin writers (init/turn/banish) with one shared router
LimitMiddleware, rewritten to block on the request context instead of a
racy shared 100ms timer.
Stage 2 — remove the obsolete immediate-command path end to end. Players
submit through PUT /api/v1/order; the legacy PUT /api/v1/command path is
deleted across game (route, handler, 24 command factories, Ctrl), backend
(Commands handler/route, engineclient.ExecuteCommands), gateway (dispatch +
executeUserGamesCommand + routing entry), the FlatBuffers/model contract
(UserGamesCommand[Response]) and transcoder, plus every affected
OpenAPI/README/FUNCTIONAL/ARCHITECTURE doc. The integration proxy test is
converted to the order path.
Stage 3 — flatten the REST->engine wrapper. Replace the executor adapter,
the controller package functions and RepoController with one concrete
controller.Service; drop the single-implementation Repo and Storage
interfaces (repo.Repo / fs.FS are now concrete). Handlers depend on a thin
handler.Engine seam and own the domain->REST projection; storage is
resolved once at startup instead of per request.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Engine no longer mints its own game UUID. The orchestrator (backend)
generates the game UUID at game-create time and passes it in the
admin/init request body as the required `gameId` field, so the value
that names the engine container and host bind-mount directory also
ends up inside the engine's state.json.
The engine rejects the zero UUID with 400 and any init that conflicts
with an existing state.json with 409 (a second init on the same gameId
is also a conflict; full idempotency is not part of the contract).
Updates rest.InitRequest, openapi.yaml (schema + 409 response),
controller.GenerateGame/NewGame/buildGameOnMap signatures, the engine
HTTP handler/executor, the backend runtime worker, and the relevant
unit and contract tests. Documentation in game/README.md,
docs/ARCHITECTURE.md, backend/README.md, and backend/docs/{runtime,flows}.md
is updated in the same patch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#59. Engine returns 202 + per-command cmdApplied/cmdErrorCode/cmdErrorMessage instead of blanket 500; pkg/error consts reshelved onto 1xxx/2xxx/3xxx; UI keeps sync banner green on per-command rejection, surfaces the engine reason inline, and hydrates per-command verdicts from the server on game re-entry. Dev-deploy now recycles game containers when galaxy-engine:dev SHA drifts.
The `rename-planet` and `ship-classes` rejected-submit specs broke on
the previous commit because:
1. `tests/e2e/fixtures/order-fbs.ts` builds the FBS response without
`forceDefaults(true)`, and flatbuffers@25's TS codegen now elides
`cmd_applied=false` against its int8 default of 0. The encoded
payload no longer carried the rejection, so the UI decoded the row
as `applied` and the assertions on the `rejected` status text
failed first. The production Go transcoder already force-slots
the field; mirror that behaviour in the e2e fixture.
2. The specs themselves still asserted the old blanket
`data-sync-status="error"` on per-command rejection. After the
previous commit's behaviour change the bar stays `synced` for
per-command rejection (only genuine transport failures keep the
red banner + Retry), so the assertions now read the row's inline
reason text instead.
`tests/e2e/fixtures/order-fbs.ts` also gains the `cmdErrorMessage`
field so future fixtures can mirror the engine's rejection reason
through the round trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three issues surfaced once the per-command rejection from the previous
commit actually reached the UI:
1. Sync banner falsely red. `OrderDraftStore.runSync` flipped
`syncStatus = "error"` whenever any command was rejected and
advertised a Retry button. A per-command rejection is a
player-correctable state — the round trip succeeded, the engine
just refused that command — so the retry can't help. Keep
`syncStatus = "synced"` on `success`; the red row highlight is
the visible cue.
2. Rejection reason missing. Add `cmd_error_message: string` to
`CommandItem` in `pkg/schema/fbs/order.fbs` (appended last to
preserve existing slot offsets) and regenerate the Go + TS stubs
for that one type. Plumb the message through `CommandMeta`,
`Controller.applyCommand`'s `m.Result(code, message)` call, the
Go transcoder, the UI decoders in `submit.ts` / `order-load.ts`,
and the `OrderDraftStore.errorMessages` map. `order-tab.svelte`
renders it as an italic danger-coloured line under rejected
commands, with new CSS for `.error-reason`.
3. Verdict lost on navigation. `order-load.ts.decodeCommand` never
read `cmdApplied`/`cmdErrorCode`, so `hydrateFromServer` fell
back to a blanket "applied" status — a previously-rejected
command came back green after a lobby → game round trip. Extend
the fetch decoder to populate `statuses`/`errorCodes`/
`errorMessages` maps and have `hydrateFromServer` use them.
Engine-side persistence already records the verdict on disk —
verified against the live `0000/order/<id>.json`.
`flatbuffers@25` elides default-int8/int64 fields on write; the Go
transcoder force-slots `cmd_applied=false` / `cmd_error_code=0`
already, the new test fixtures flip `builder.forceDefaults(true)` to
mirror that behaviour so the round trip survives.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`backend`'s reconciler adopts pre-existing `galaxy-game-*` containers
without comparing their image SHA against the freshly-built
`galaxy-engine:dev`, so a long-lived sandbox would otherwise keep
serving the previous engine code after a redeploy. Issue #59 surfaced
this: after the per-command-rejection fix was deployed via
`workflow_dispatch`, the running sandbox container was still on the
old image SHA and the browser kept seeing the 503/unavailable response.
Adds a `Recycle engine containers on image drift` step right before
`Reap stray dev-deploy containers`. The step compares the new
`galaxy-engine:dev` SHA against every running `galaxy-game-*`
container and, on drift, stops the backend, removes the container,
wipes the bind-mounted per-game state directory (Engine.Init() writes
turn-0 over any pre-existing `turn-N` files — silent state corruption
otherwise), and cascade-deletes the lobby `games` row. The
`dev-sandbox` bootstrap on the next backend boot finds no live
sandbox and provisions a fresh one on the new engine image.
When the engine sources are unchanged, the BuildKit cache hits and
the SHA stays the same — the recycle step is a no-op and the running
games keep their state across the deploy. Verified end-to-end against
the live dev environment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Validation of a player's order now applies every command against a
transient game-state snapshot and records the per-command outcome
(cmdApplied, cmdErrorCode) in each command's meta. The order is
persisted even when some commands are rejected, and the response is
202 + UserGamesOrder so clients can surface the partial failure
without the chain collapsing into "downstream service is unavailable".
Pkg/error consts are reshelved onto three explicit ranges with a
package doc and helpers (IsInternalCode/IsInputCode/IsGameStateCode):
1xxx internal/server (500/501), 2xxx structural input (400), 3xxx
game-state per-command rejection (400 when escaping HTTP, otherwise
recorded as cmdErrorCode). Two pre-existing typos fixed mechanically
(ErrBeakGroupNumberNotEnough -> ErrBreakGroupNumberNotEnough,
ErrRaceExinct -> ErrRaceExtinct) along with all callsites.
Engine errorResponse maps *GenericError by shelf rather than mapping
everything to 500. The Quit-not-last structural check in
Controller.ValidateOrder is preserved and its type assertion fixed
(was a value assertion against a pointer-typed command, so the check
silently never fired).
Backend, gateway and UI are unchanged — they were already correct on
the 202 path; only the engine collapsing per-command rejection into
500 was needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Profiling KNNTS041 (700 planets, 1283 primitives, 29 LOCAL fog
circles) flushed three independent costs out of the toggle path:
* `setVisibilityFog` rebuilt the inverse mask + 29 × 9 paint ops on
every effect run, even when the input was identical. Caches a
fingerprint of the circles + wrap mode and bails on a no-op
call — knocks ~1 ms off every flip, more on heavier maps.
* `paintLabelEntry` was split into `paintLabelLayout` (hit-area /
line positions / frame geometry — runs on every content change)
and `paintLabelSelection` (text fills + frame visibility — runs
only when the selection identity actually flips). The incremental
path now skips the 6300 redundant `Text.style.fill = ...` writes
it used to perform on every `planetNames` flip, which is what
forced Pixi to invalidate the underlying text textures.
* `applyLabelContent` no longer blanks `nameText.text` when the
toggle hides the name — it just flips `visible`. The cached text
texture survives, so the next paint frame skips ~700 texture
rebuilds.
Also enables Pixi-side culling on every per-copy primitive / outline
/ label container. With 9 torus copies × ~700 planets the scene
graph holds thousands of nodes, most of which sit outside the
visible viewport at any moment — the cullable flag lets Pixi skip
them in the per-frame traversal.
The legacy `KNNTS041` probe (chromium-desktop, headless) shows
`applyVisibilityState` collapsing from ~24 ms to ~5 ms after a
cache-warm flip; `app.render` drops from ~46 ms to ~22 ms. Reading
the toggle delay end-to-end inside the browser still measures
~460 ms in headless, which is consistent with the runner's RAF
cadence — owner can confirm on the real machine where the previous
~1 s delay was reported.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Max-zoom clamp: `MIN_VISIBLE_WORLD_AT_MAX_ZOOM = 5` world units on
the longest viewport axis. Tuned against the owner's
debug-overlay readings — mobile longest ≈ 412 px clamps at
scale ≈ 82, desktop longest ≈ 1200 px clamps at scale ≈ 240.
Same formula adapts to both shapes automatically; no separate
mobile / desktop branch.
* Planet-names toggle no longer rebuilds every Pixi.Text on a flip.
When `setPlanetLabels` sees the same planet set (which is the
common case — only the `name` lines toggling on / off), it walks
the live label containers and just retunes text content +
visibility instead of destroying and recreating 9 × N Text
instances. A 500-planet map flips the toggle inside a frame now.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Planet discs (and every other circle the renderer draws —
outlines, picker hover ring, reach / bombing rings, etc.) trace
a fixed 32-segment polygon instead of leaning on Pixi's adaptive
bezier subdivision. PixiJS v8 picks the segment count from the
world-space radius, which collapsed to 6-8 segments once the
parent container's scale climbed — so the planet read as a
visible polygon at high zoom. The custom path stays cheap (~64
floats per disc) and gives a perceptually round silhouette at
every zoom level.
* Opt-in dev overlay activated by `?debug=1` in the URL. A small
bottom-left panel shows the current `scale`, the
"whole world fits" reference scale, the current zoom ratio
(scale / scale_ref), and the world-units rectangle visible in
the viewport — so the owner can decide what `maxScale` to clamp
to on the next iteration without guessing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Hit-test: a click inside a planet's visible disc always picks the
planet, regardless of overlapping route shafts or battle X-crosses
with higher base `priority`. Closes the #1, #2, #4 reports
(picker hover would only catch the circumference, planet+routes
swallowed disc clicks, label click on a battled planet routed to
the battle viewer). Slop-only hits (cursor near a line but not on
any disc) still use the existing priority order.
* Labels and planet outlines render in all nine torus copies again
so they follow the player into wrap tiles — closes#3 (labels
vanished on the wrong half of the viewport whenever the camera
was panned past the wrap seam). The fingerprint guard keeps the
per-toggle / per-selection rebuild cheap.
* Pixi.Text gets a few px of `padding` so the rasteriser no longer
clips the last letter on a half-pixel measurement — closes#5.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Bug fix: theme flip no longer leaves planets oversized. The
camera-preserving remount now calls a new
`RendererHandle.refreshCameraDerivedDraws` explicitly after the
manual moveCenter/setZoom pair so the post-mount geometry tracks
`viewport.scaled` even if pixi-viewport's `'zoomed'` listener
races the next Ticker tick.
* Doc #3: clicks on a planet label route through the same hit-test
path as a click on the disc. The label `Container` now has a
pointer hit area sized to the text + frame padding; pointertap
simulates a click at the planet centre, so selection and
pick-mode resolution behave identically.
* Doc #4: battle X-crosses + cargo arrowhead wings grow
sub-linearly with zoom (PLANET_SIZE_ZOOM_ALPHA). New
`Style.softLengthAnchor` ('center' / 'start') makes the renderer
treat the recorded endpoints as the geometry "at the reference
scale" and rescale around the midpoint (X-cross) or the start
endpoint (arrow wings). Arrowhead base length is halved from 6
to 3 world units to match the owner's "in half" request.
* Doc #5: picker overlay loses the anchor ring at the source, the
cursor line drops to a cargo-route-thin 0.6 px stroke, and the
hover ring around the destination is replaced by a planet-style
outline (visible disc + 1 px padding) in the `pickHighlight`
accent — so candidate destinations read like selection in warm
yellow.
* Doc #6: regression test pins the in-disc hit zone.
* Perf #1: camera-driven redraws are throttled onto the next
Ticker tick. A rapid wheel / pinch burst now coalesces into at
most one `clear() + redraw` pass per painted frame, which keeps
the 500-planet map responsive on zoom and toggle flips.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Planet size formula moves to pixel-space:
`pointRadiusBasePx = 2 + 2 * cbrt(size / SIZE_NORMALIZER)`. The
on-screen disc now reads ~4-7 px at the reference zoom regardless
of how large the world rectangle is — the previous `world-units`
formulation blew up on small maps and made Source-class planets
swallow their neighbours.
* Labels + outlines live in the origin copy only. The 9× replication
across torus copies was the dominant cost on a 100+ planet map
(Pixi.Text creation + Graphics rebuilds on every zoom step); the
origin-copy layout is what the camera-wrap listener guarantees
the user actually sees.
* `setPlanetLabels` and `setPlanetOutlines` skip Pixi-object
rebuilds when the input fingerprint is unchanged — toggle flips
and selection changes now keep the existing Text / Graphics
instances alive and only repaint the affected pieces.
* `renderer.md` updated to the new contract.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* state-binding.ts: normalise planet size by the engine's typical
mid-range (`SIZE_NORMALIZER = 100`) so legacy fixtures recording
Size in the hundreds do not blow up the world-unit disc and start
overlapping neighbouring planets. The cube-root growth stays;
Size-800 reads twice as big as Size-100.
* cargo-routes.spec.ts: retire the selection-ring CirclePrim from
the expected primitive count (4 planets + 3 cargo arrow lines = 7).
* map-toggles.spec.ts: bombing-rings → planet outlines (the high-bit
0xc… range is permanently empty); planet-names persist test waits
for the renderer's debug providers and for the IndexedDB write to
flush before reload.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Honest pixel-space sizing for `pointRadiusPx` / `strokeWidthPx`: the
renderer divides by the current camera scale on every
`viewport.zoomed` so thin lines / small markers stay the same on-screen
size at any zoom.
* Known-size planets switch to `pointRadiusWorld`, softened against the
reference scale by `PLANET_SIZE_ZOOM_ALPHA = 0.33`; unidentified
planets pin to a 3-px disc.
* New planet label layer renders a two-line `name / #N` legend under
each planet (`#N` only for unidentified or when the new `planetNames`
toggle is off). Selection now paints an inverse-fill frame around the
selected planet's label plus an outline on the disc; the old
selection-ring primitive is retired.
* Bombing markers swap the separate CirclePrim for a planet-outline
overlay (damaged / wiped colour); the report deep-link moves to a
"view bombing report" link in the planet inspector.
* Docs + tests follow: `renderer.md` reflects the new sizing contract +
label / outline layers, vitest covers the sizing math, label
formatting, and the new toggle, and the map-toggles e2e adds a
persistence case for `planetNames`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a sortable battles list as a new entity under the existing
`view → table` submenu (entity slug `battles`), replacing the
standalone top-level `battle log` shortcut which always opened a
"battle not found" placeholder. The single-battle viewer stays put
and is reached only by clicking a row (or a battle marker on the
map), identical to the existing `section-battles.svelte` flow.
Columns are planet (via the shared `planetLabel` helper) and shots
(the per-battle action count carried by `BattleSummary`), sortable
both ways with shots-desc default. No backend / FBS / map changes:
the wire payload is unchanged. Participants / observers / total
mass require the full BattleReport and were intentionally dropped
to avoid N round trips per menu open.
The top-level `battle log` item is removed from `header/view-menu`
and `sidebar/bottom-tabs` (and their stale comment blocks updated);
the now-orphan `game.view.battle` i18n key is dropped from both
locales.
Polish pass after the first F8-10 walkthrough:
- table-planets: moved the `foreign` chip to the end of the row and
hid the race dropdown until `foreign` is on (it never made sense
to pick a race while the bucket itself was off).
- persistent per-table filter / sort state — extracted to
`table-{planets,ship-groups,fleets}-state.svelte.ts` singletons so
a row click → map → back to the table restores the prior chip /
dropdown / sort state. Held in memory only; an F5 still resets.
- table-ship-groups: the planet and class dropdowns now narrow to
the slice surviving the owner checkboxes, so toggling `foreign`
off removes planets / classes touched only by foreign rows.
- map.svelte: camera (centre + zoom) is captured on every dispose
path into a new `GameStateStore.lastCamera` and consumed on the
next mount, so leaving the map for any other active view and
coming back restores the prior pan / zoom. A pending focus from
the tables still wins for the centre point.
- table-ship-classes: `:disabled` now reads as disabled (muted
colour, no hover ring, not-allowed cursor) — the click was already
a no-op, only the visual was lying.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lights up three previously-stubbed table active views and tightens the
existing one:
- table-planets: 4 kind checkboxes (own / foreign / uninhabited /
unknown) + race dropdown that filters the foreign slice; row click
selects + centres the planet on the map.
- table-ship-groups: local + foreign groups in one grid, owner
checkboxes, planet dropdown (destination OR origin), class
dropdown; on-planet click focuses the destination planet, in-space
click focuses the ship group itself (camera follows interpolated
position).
- table-fleets: own fleets only with the shared planet dropdown;
on-planet click focuses the planet, in-space click centres the
camera on the interpolated fleet position without altering the
selection (no fleet variant in Selected).
- table-ship-classes: per-row Delete is disabled with a count tooltip
while at least one local ship group references the class. The
engine refuses the removal anyway; the UI pre-empts the surface.
Wires the click → map flow through a transient `SelectionStore.focus`
/ `focusPoint` channel that `map.svelte` consumes once on mount —
in-memory only, so an F5 does not re-centre.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Owner-reported regressions in Firefox + Safari on desktop after the
initial F8-09 patch landed:
1. The TOC trigger rode up with the page during scroll instead of
staying pinned to the viewport (mobile worked, desktop did not).
2. Clicking a popover item scrolled the matching section so its
heading went up under the chrome — only the table body was visible.
Root cause for (1): the in-game shell declares `overflow-y: auto`
on `.active-view-host` so mobile (where `.game-shell` is fixed at
`inset: 0`) has an internal scroll region. On desktop the host
grows with content, no overflow ever engages, and the document
body becomes the actual scroll container. Per CSS spec the host
remains the "scrollport" for any `position: sticky` descendant, so
the trigger inside the report column never sees the scroll event
and rides up with the body content.
Fix:
- Swap the trigger from `position: sticky` to `position: fixed`.
The component is mounted only while the report active view is on
screen, so the fixed element is naturally tied to the view's
lifetime. Anchor at `top: 4rem` (below the in-game header), and
on `min-width: 1024px` shift `right` by 18 rem to clear the
always-on sidebar; below 1024 px the sidebar is an overlay so
the default `right: 1.25rem` matches the report's right padding.
- Add `padding-top: 4.5rem` to `.report-view` (4rem mobile) so the
first section heading does not land under the trigger at scroll
position 0.
- Add `scroll-margin-top: 7.5rem` to every `<section
id="report-…">` so `scrollIntoView({ block: "start" })` lands
the heading below the trigger after a popover-driven jump.
- Sync `ui/docs/report-view.md` §"Table of contents and active
highlight" with the new positioning rationale.
Tests: `pnpm check`, `pnpm test` (821), `pnpm test:e2e
report-sections` (4 projects) all green.
Refs: #52 (#43 umbrella).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Replace the 14 rem sticky sidebar (and its mobile <select> twin)
with a single sticky icon-popup trigger pinned to the top-right
corner of the report column. Trigger shows `≡` followed by the
currently active section title (CSS-clamped with text-overflow:
ellipsis so long RU titles cannot bloat the button). Click opens
an anchored popover on desktop and a fixed bottom-sheet on
<768.98 px (mirrors lib/active-view/map-toggles.svelte).
- Each menuitem closes the popover and scrolls the matching
`<section id="report-<slug>">` into view. The scroll is deferred
one animation frame so the surface unmount + restoreFocus's
focus restoration on the (sticky) trigger commit first; otherwise
the focus call could cancel the just-started smooth/instant
scroll under desktop Chromium and WebKit.
- Drop the in-report "Back to map" button — the same affordance
lives in the app-shell view menu (tests/e2e/game-shell.spec.ts
covers it).
- Tighten the report grid to a single flex column so the section
body now occupies the full container width.
- i18n: remove game.report.back_to_map and
game.report.toc.mobile_label; add game.report.toc.open and
game.report.toc.close (mirrors game.map.toggles.open/close).
- Tests: Vitest report-toc.test.ts rewritten for the new icon-popup
contract; Playwright report-sections.spec.ts switches the anchor
loop to trigger → menuitem and adds a mobile bottom-sheet
assertion; game-shell-stubs.test.ts no longer asserts the
back-to-map button on the report orchestrator.
- Docs: ui/docs/report-view.md (TOC + i18n + test seams) and
docs/FUNCTIONAL{,_ru}.md §6.4 updated. The stale SvelteKit
Snapshot reference (the route file was removed by the single-URL
app-shell) is dropped at the same time.
Refs: #52 (#43 umbrella).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Issue #48 п.32 ("Stationed ship groups") shipped with a fragile race
fallback: when a foreign group sat on a non-`other`-kind planet the
inspector printed a generic "foreign" label, which collapsed the
race dropdown to a single uninformative bucket. The engine FBS
contract did not carry per-group race either, so live games hit the
same gap. This patch carries race authoritatively from the engine
through every layer down to the inspector.
Wire format & engine
- `pkg/schema/fbs/report.fbs`: add `race:string` to `OtherGroup` and
`LocalGroup` (additive — old clients ignore).
- `pkg/schema/fbs/report/`: regenerated Go bindings.
- `ui/frontend/src/proto/galaxy/fbs/report/`: regenerated TS bindings.
- `pkg/model/report.OtherGroup.Race`: new field; carried through
`LocalGroup` via the embedded `OtherGroup`.
- `pkg/transcoder/report.go`: encode + decode `race` on both
`LocalGroup` and `OtherGroup`.
- `game/internal/controller/report.go.otherGroup`: set `v.Race`
from `c.g.Race[c.RaceIndex(sg.OwnerID)].Name` so every emitted
group — own or foreign — carries the resolved race name.
Legacy parser
- `tools/local-dev/legacy-report/parser.go`: capture the
`<Race> Groups` header into `pendingOtherGroup.race`, fill local
group `Race` from `p.rep.Race`, propagate both into the
`report.OtherGroup` rows.
- Tests + smoke counts updated; regenerated `KNNTS{039,041}.json`
fixtures so the synthetic loader carries the new field.
UI
- `ui/frontend/src/api/`: `ReportShipGroupBase.race` field;
synthetic loader + FBS decoder populate it.
- `ui/frontend/src/lib/inspectors/planet/ship-groups.svelte`: the
stationed-groups inspector picks race directly from
`group.race` (own falls back to `localRace`, both finally to the
`race.unknown` placeholder). The planet-owner / "foreign"
heuristic is gone.
- Row label changes from "N ships mass M" to a compact
`<class>` | `<N ×>` | `<mass>` three-column layout: the count
cell is right-aligned tabular, the mass cell is right-aligned
monospace + tabular, matching the inspector / calculator number
conventions. Stale i18n keys removed
(`ship_groups.row.count`, `.row.mass`, `.race.foreign`).
- All affected unit tests (8 files) carry the new `race` field.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Owner-reported polish on top of #48, plus a legacy-parser gap that
prevented verifying stationed ship groups against a real .REP fixture.
UI:
- Production: drop the empty `(production)` placeholder option. Owned
planets always produce something, so the primary select now opens on
`industry` by default when `planet.production` is null/unknown,
keeping the row inside the four real production kinds at all times.
- Production: lock the row to a single line (no flex-wrap) and strip
border + padding from the ✓/✗ buttons so the apply/cancel icons read
as glyphs and the row no longer breaks into two visual rows for
Research / Ship contexts where both selects are present.
- Cargo routes: the placeholder option is now an `<option disabled>`
styled like a section header (greyed, italic) and reads "manage
routes" instead of "cargo routes". The wording shifts the intent
from a section label to an action prompt.
Legacy parser:
- F8-05 (#48 п.32) "Stationed ship groups" couldn't be verified against
the dg fixture because the legacy `<Race> Groups` blocks (outside
battles) and the `Unidentified Groups` block were dropped by the
parser — both are now wired up. Foreign group rows parse the
`# T D W S C T Q D P M` columns and resolve the destination against
the parsed planet tables (rows with an invisible destination drop,
matching the existing local-group convention). The legacy row
carries no origin / range columns, so foreign groups surface as
stationed at the destination.
- Smoke tests on every fixture extended with `otherGroups` and
`unidentifiedGroups` counts. New focused unit test
`TestParseOtherAndUnidentifiedGroups` covers the column layout, the
drop-on-unknown-destination rule, and the `X Y`-only unidentified
rows.
- `tools/local-dev/reports/dg/KNNTS039.json` and
`tools/local-dev/reports/dg/KNNTS041.json` regenerated so the
synthetic-loader fixtures carry the new arrays.
- README updated: the two sections move out of "Skipped sections" into
a "Foreign and unidentified groups" block; package doc-comment
reflects the broader scope.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- order-composer.md describes the production row's apply-gate (two
selects + ✓/✗) and the click-to-edit entry point for planetRename.
- cargo-routes-ux.md replaces the four-slot grid description with the
new single-row dropdown + contextual actions and notes the
"stays on the picked type" UX rule.
- science-designer-ux.md updates the production-picker integration
description to the dropdown pair and refreshes the e2e walkthrough
step.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drains six F8 polish items (parent #43) in one feature:
а) Chrome cleanup
- п.6 — remove the AccountMenu (settings/sessions/theme/language/logout
∼ rudimentary in-game) and replace it with a single icon-button
light/dark theme toggle. The toggle flips an in-memory `theme.override`;
game-shell unmount calls `theme.clearOverride()` so the lobby (and
any re-entry) re-projects the persisted lobby choice.
- п.8 — remove the wrap-scrolling radio from the map gear popover. The
per-game `wrapMode` store and the renderer's no-wrap path stay in
place for a future engine-side topology feature; only the UI surface
is dropped (wrap is a server-side concept, not a per-session UI
affordance).
б) Inspector compact rows (single idiom: select + ✓ apply / ✗ cancel,
or contextual edit/remove/add)
- п.13 — planet name is now click-to-edit: clicking the name opens an
inline `<input>` + ✓ confirm icon; Escape cancels; the explicit
Rename action button and Cancel button are gone.
- п.14 — production becomes one row: primary `<select>` picks
industry/materials/research/ship, conditional secondary `<select>`
picks the target (tech / science / ship class) for research and
ship contexts. Apply is gated until row state differs from the
planet's current effective production; auto-submit-on-click is
replaced by the apply-gate.
- п.16 — cargo routes collapse to one row: a single dropdown
(COL/CAP/MAT/EMP plus a placeholder that absorbs the old section
title) and contextual action buttons (add / edit + remove) to the
right. After a successful pick or remove the dropdown stays on the
type the user just acted on.
- п.32 — stationed ship groups hoist the race column into a dropdown
above the table. The dropdown seeds with the player's own race when
local groups are stationed here, otherwise the first race
alphabetically; rendered only when more than one race is in orbit.
The race column is dropped in both single- and multi-race modes —
the dropdown's value already names the active race.
Tests: unit and Playwright e2e updated for every changed test-id and
flow; new coverage added for `theme.override`, the in-game toggle, the
apply-gate behaviour, and the stationed-race dropdown. i18n keys for
the removed menu items, the wrap radios, the cargo title, and the
explicit `rename.cancel` are dropped from both locales; new
`game.shell.theme_toggle.*`, `production.main/target.*`,
`production.apply/cancel`, `cargo.placeholder`, and
`ship_groups.race_filter.aria` keys land.
Docs synced: `docs/FUNCTIONAL.md` §6.7 + `docs/FUNCTIONAL_ru.md`
mirror drop the torus / no-wrap radio mention; `ui/docs/design-system.md`
documents the lobby-owned persisted picker + the in-game ephemeral
override channel.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sciences.spec.ts: `sciences-cell-drive` now reads "25.0" (was "25") because
formatPercent always emits one fractional digit.
ship-classes.spec.ts: `ship-classes-cell-drive` now reads "1.000" (was "1")
because formatFloat always emits three fractional digits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Engine emits Floats at Fixed3 quantisation; UI now renders them as 3-decimal
fixed-point strings without thousand separators, monospaced via var(--font-mono)
on .numeric cells, and right-aligned in tables so columns line up on the
decimal point. Integer counts render with 0 decimals and no separators;
science fractions render as 1-decimal percent (matches the engine's third
decimal of precision).
Bug fixes from #51 (umbrella #43):
- Player Status drive/weapons/shields/cargo: were tech LEVELS rendered
through formatPercent (x100) — now use formatFloat (raw level).
- Races table: same bug, same fix.
Style/UX cleanups:
- Inspector field labels lose "stockpile" word ($ / M suffix carries it).
- Coordinates drop the parentheses (just "x, y").
- Inspector + report tables unify font sizes with calculator-tab
(values 0.85rem mono, labels 0.8rem).
Files:
- new util: ui/frontend/src/lib/util/number-format.ts
- report/format.ts becomes a thin re-export to keep section imports compact
- inspector planet / ship-group / actions: drop inline formatNumber,
mark numeric <dd> with class="numeric"
- table-races (+ bug fix), table-sciences, table-ship-classes,
designer-science: drop inline formatters, switch to util, add
class="numeric" on numeric <th>/<td>
- 17 report section files: class="numeric" on numeric th/td +
scoped CSS rule for mono+right-align
- i18n en/ru: drop "stockpile" word, drop "%" from tech-level column
headers in races + player_status (the "%" was the misleading bit
from the bug)
- tests/inspector-planet + tests/table-races: update assertions to
match the new format
Verification: pnpm test (814 passed), pnpm check (0 errors/warnings),
pnpm build clean.
Refs: #51 (#43 umbrella).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-up nits on the F8-04b sidebar:
1. The bare-`lobby` resolver (lobby-screen.svelte) redirected to
`games-recruitment` unconditionally on mount. With games already
in the player's roster the sidebar then highlighted the wrong
sub-page. The resolver now awaits the lobby fan-out + account
fetch, then hands off to the same `firstVisibleGamesScreen` helper
the sidebar uses — so a fresh entry with games lands on
`active-past`, the canonical-order fallback stays `recruitment`.
2. `games-invitations` was unconditionally visible in the sidebar.
Now it follows the `active-past` rule: hidden until the
pending-invites list reports >=1. The lobby shell's auto-kick
effect treats it symmetrically — accepting / declining the last
invite moves the player to the next visible sub-page once the
fan-out has resolved.
Acceptance order in games-invitations-screen.acceptInvite was also
swapped to setMyGames-before-removeInvitation: both mutations land
in the same microtask, so the new auto-kick sees the freshly added
game in `myGames` when invitations drop to zero and routes the
player to `active-past` instead of bouncing through `recruitment`.
The visibility predicates and canonical order live in the new
`src/lib/lobby-nav.ts` pure helper, shared between the sidebar and
the resolver so they cannot disagree. Unit tests cover every
combination of (hasMyGames, hasInvitations, isPaidOrDev).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spec described the overlay as a single Graphics in the origin tile,
which was both the bug source and out of date after the F8-07 fix.
Updates the Open / Tick steps to describe the nine-copy replication
and the torus-shortest line endpoint contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pick overlay (anchor ring, cursor line, hover outline) was drawn into a
single Pixi container — copies[ORIGIN_COPY_INDEX] — so any view of a wrap
copy lost it: picker from A1/A2 to the right (across the seam) showed no
hover highlight on A3's wrap copy, and the picker on A3 (x≈1.44, near the
left edge) put its anchor far left of the viewport. Fix replicates the
overlay across all nine torus copies (matching how primitives and fog
already render) and switches the cursor-line endpoint to torus-shortest
geometry via torusShortestDelta. Anchor and hover-outline coordinates
stay canonical; the per-copy replication renders them under the user's
view in whatever tile is on screen.
Also reduces cargo-route arrow strokes: COL/CAP/MAT 2->0.6 wu and EMP
1->0.4 wu (~3 / ~2 screen px at typical zoom) per the owner's request.
Tests cover the new torus path: source near the left edge with cursor on
the wrap copy across the seam (x axis), source near the top edge with
cursor across the y seam, and a guard that anchor / hover-outline coords
stay canonical regardless of the world argument.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The desktop submenu (.desktop-only) is CSS-hidden on mobile
viewports — the mobile sidebar tucks the same sub-panel entries
behind a dropdown popover. Assert `toBeAttached()` instead of
`toBeVisible()` so the dev-bundle smoke check works on every
viewport.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- lobby-create-screen: call lobbyData.refresh() after a successful
POST so the new game shows up in the private-games panel
immediately. The shared lobby-data store is otherwise lazy
(ensure-on-first-mount), which rendered a stale list across the
post-create navigation in the e2e suite.
- e2e tests that move between lobby sub-panels now go through
`window.__galaxyNav.go(...)` rather than clicking the sidebar
items. The mobile sidebar tucks the submenu behind a dropdown, so
testid-based clicks fail on the mobile-iphone-13 / pixel-5
viewports — the dev nav surface bypasses that UX (which has its
own coverage in `lobby-tier-gate` / future submenu specs).
- game-shell-map missing-membership test: assert
`lobby-account-name` instead of `lobby-create-button` on
drop-back-to-lobby (the button moved into the paid-only
private-games sub-panel; the identity strip is the constant lobby
chrome).
- inspector-ship-group + ship-group-send synthetic loader specs:
jump straight to the dev-only `synthetic-reports` top-level
screen via the dev nav surface before looking for the file
input (the loader moved off Overview in F8-04b).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`lobby-nav-overview` is replaced by `lobby-nav-games` (the new parent),
and the empty-games active-past sub-panel is hidden entirely so the
landing testid becomes `lobby-recruitment-empty` (the always-visible
sub-panel for a no-games session).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reshape the lobby UI from a single Overview into a two-level sidebar
(games · profile · DEV synthetic-reports) with four games sub-panels
(active-past · recruitment · invitations · private-games). Move the
`create new game` button into the private-games panel, merge the
applications section into recruitment cards as status chips, and add
DEV-only synthetic-report loader as a top-level screen.
Add a paid-tier gate at backend `lobby.game.create`: free callers get
`403 forbidden` before the lobby service is invoked. The UI hides the
private-games sub-panel + create button on free tier (DEV affordances
flag overrides). Update every integration test that creates a game to
use a new `testenv.PromoteToPaid` helper; add a new
`TestLobbyFlow_FreeUserCreateGameForbidden`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR-feedback round on #60:
- Time-zone field is now a continent-grouped <select> populated from
`Intl.supportedValuesOf("timeZone")`, with the browser-detected
zone pre-selected when no value is stored. A stored zone the
runtime no longer advertises is preserved as an "Other" entry.
- Saving the profile no longer kicks the user back to the lobby:
the form stays put and shows a transient `saved` notice, cleared
on the next edit. Only `cancel` returns to the lobby.
- New `lib/account-store.svelte.ts` caches `user.account.get` for
the session; lobby + profile share it through `account.ensure()`,
so navigating Overview ⇄ Profile no longer flashes the
"loading account…" placeholder or fires a second gateway call.
Profile save writes through to the store so the shell identity
strip picks up the new display name without refetching. Cleared
on logout to prevent identity bleed between accounts.
- e2e: existing 4 cases adjusted for save-stay; added two new ones
for the timezone dropdown and identity-strip stability across
navigation.
- Docs: `ui/docs/lobby.md` updated to describe the shared cache,
the new timezone picker shape, and the save-stay behaviour.
- Wrap lobby and profile in a shared `lobby-shell.svelte` chrome:
page-list sidebar (Overview/Profile) and a top "Player-xxxx"
identity strip mirroring the project site's monospace look.
- Strip the legacy `lobby.title`, device-session-id `<code>`, and
`lobby.greeting` paragraph; the identity strip both names the user
and opens the profile editor.
- Add a top-level `profile` AppScreen with a three-field form
(`display_name`, `preferred_language`, `time_zone`) backed by a new
`src/api/account.ts` wrapper around `user.account.get`,
`user.profile.update`, and `user.settings.update`. Saving switches
the active i18n locale in-place when the new preferred language is
one the UI ships translations for.
- Update e2e fixture + auth-flow / lobby-flow specs to use the new
`lobby-account-name` testid and wait for the loaded identity before
releasing pending `SubscribeEvents` (webkit revocation race). New
`profile-screen.spec.ts` covers navigation, edit-save, and cancel.
- Sync `ui/docs/lobby.md` and `ui/docs/navigation.md` to the new
layout.
Closes#47
- Remove the `delete <ship_class_name>` button (and `deleteClass`,
`canDelete`, `.delete` CSS, `game.calculator.action.delete` i18n key)
from the calculator. Delete-class lives in the ship-classes table —
the broader rework will land under #53.
- Bombing and cargo-capacity rows now reserve a hidden lock-slot
placeholder so their value column lines up vertically with the
mass/speed/attack/defence rows (which carry a lock button).
Owner feedback round 2 on PR #61:
- Pad every read-only calculator value to three decimals: tech labels,
derived results (mass, speed, attack, defence, bombing, cargo
capacity), planet MAT, planet build-rate, modernization cost, and
the full-cargo capacity label all read as "1.000" instead of "1",
matching the goal-seek back-solved input and the report. Drops
thousands grouping so the same `fmt()` string also embeds cleanly
in the read-only `<input type="number">` cell.
- Switch label and input styling onto the existing `--font-mono`
token (right-aligned, tabular-nums) so columns line up vertically
across rows like a financial table.
- Refuse a fourth decimal as the user types in every calculator
number input (DWSC blocks, tech, MAT, custom load, lock value,
modernization target tech): the `oninput` truncates the input text
past three decimal digits and explicitly writes the truncated
value back through `bind:value`, so Svelte's later reactive flush
cannot undo the cap.
- Doc + tests follow the rule (five new vitest cases covering the
3-decimal label format, the input cap on each input class, and
the integer-padding rule for derived results).
Owner review on PR #61:
- п.9 (option B). Hide the native spinner on EVERY numeric input in
the calculator (DWSC blocks, armament, tech, planet MAT, custom
load, lock value, modernization target tech) and drive every step
through ArrowUp / ArrowDown. The column widths stay stable and the
inputs read consistently across the whole row. The ship blocks
keep the smart (0 ↔ 1) jump on ArrowUp/ArrowDown; armament steps
±1 with a JS handler instead of relying on the native spinner.
Other inputs step by their natural grain (±0.001 for tech / lock,
±0.01 for MAT / load).
- п.10. Tech-level labels (`tech-val`) and the planet MAT label
(`mat-val`) now read through the same `Ceil3` formatter as the
derived results, so plain-text numeric values share the report's
3-decimal tabular formatting. The design-area component receives
`formatNumber` as a prop; the resolved (goal-seek) cell uses the
same formatter, so the read-only computed value matches the rest
of the row.
- п.12. `computeCalculator` now validates the back-solved block
against the same DWSC rule the live validator enforces (`0` or
`≥ 1`). When the solver lands in the `(0, 1)` gap (e.g. attack
0.5 / weaponsTech 1.5 → weapons 0.333…) the lock is flagged
infeasible — the lock input flips red and the claimed block is
NOT back-solved into the invalid range, so the design preview
keeps reading the user's own typed values instead of silently
showing a sub-1 block.
- new. Selecting an existing ship class from the name datalist now
loads it immediately. `change` fires only on blur in Firefox,
which is why the previous behaviour looked delayed; switching the
load to `oninput` with an `InputEvent.inputType` check makes the
load synchronous everywhere (datalist replacement carries
`"insertReplacementText"` in Chromium / WebKit, `undefined` in
Firefox; keyboard typing always carries a typing `inputType`).
Before loading we compare the live blocks to the previously
loaded class (or to the empty defaults) and, if they differ, ask
through a `window.confirm`. On decline we revert the name field
and leave the design untouched.
Tests: calculator-tab and calc-model gain six cases (armament
step, tech/MAT formatter labels, lock infeasible on (0, 1) for
both attack→weapons and emptyMass→cargo, lock-value Arrow step,
dropdown immediate load + confirm-blocks-load + confirm-allows-load),
all 779 vitest tests green. docs/calculator-ux.md follows the new
behaviour.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- pkg/calc: DriveForSpeed treats restMass==0 as a valid ceiling-only
case (every positive drive solves it), so locking the displayed
speed of a D=1, W=A=S=C=0 ship is no longer a phantom "infeasible".
- ship-design-area: drive/weapons/shields/cargo inputs use a JS-driven
smart step on ArrowUp/ArrowDown (0↔1 jump, otherwise ±0.1) and hide
the native spinner so it cannot produce invalid (0, 1) values;
armament keeps its native step 1.
- Tech and planet MAT cells follow the same lock idiom as goal-seek
locks: open padlock (🔓) over the inherited value → click to open
an input with a closed padlock (🔒). The padlock slot is always
reserved, so the column width is stable.
- Tech overrides (design area and modernization target) are floored
at the player's current tech on this turn — a lower value is
flagged as invalid.
Closes#46
F8-03 — повышаем читаемость карточки приказа: перенос длинного текста, статус в фоне карточки через --color-{success,danger,warning}-subtle, маленький угловой ✕ всегда видимый.
stabilise report-sections e2e
Owner review on PR #58:
- shrink the order-card body to 0.8rem (matching the calculator's body
text scale) so the order list reads as part of the sidebar's
density, not its own larger surface;
- shrink the delete ✕ to 0.95rem and glue it flush to the card's
top-right corner (no offset, sized to fit the corner padding-space);
- tighten the card padding to match the smaller text.
Independently — the same review asked to fix `report-sections › every
TOC anchor lands its section in view`, which had been a long-standing
e2e flake (run #366 on `development` already failed it twice before
passing on retry; my PR's run #367 simply exhausted all five retries).
The root cause is the smooth `scrollIntoView` settling slower than
Playwright's 5 s viewport wait under heavy CI load. The production
TOC already honours `prefers-reduced-motion: reduce` and swaps to an
instant scroll there; switching the Playwright config to that media
mode makes every spec deterministic without touching production code.
The order-tab row now wraps long labels (`overflow-wrap: anywhere`),
encodes status into the card background via the design-token subtle
palette (applied → success-subtle, invalid/rejected/conflict →
danger-subtle, draft/valid/submitting → warning-subtle), and exposes
a small framed `✕` delete button absolutely positioned in the
card's top-right corner — always visible, labelled by
`game.sidebar.order.command_delete` for assistive tech. The textual
status name remains in the DOM as an `.sr-only` node so screen
readers and the existing testids still observe it.
Refs #46
Clicking the current-turn row in the header turn navigator while
already viewing it routed through returnToCurrent() →
viewTurn(currentTurn), which re-fetches the live report and flips the
view through `loading`. At turn 0 the only row is the live turn, so
the dropdown always fired a pointless backend round-trip and redraw.
Guard goToTurn() against re-selecting the on-screen turn
(turn === viewedTurn): just close the popover and stop. Leaving
history is unaffected — there the viewed turn differs from the target.
Closes#45
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The two-step e-mail login now drops the cursor on each step's primary
field as it mounts — the e-mail field on load, the code field once the
e-mail step advances — via a small `use:` action. Focusing fires each
input's onfocus, which clears the readonly autofill guard, so the field
is editable straight away.
The code input now requests `autocomplete="one-time-code"` instead of
`new-password`. The latter is a password-manager hint and does not stop
Firefox saving the typed code to form history (it was offering the
previous code back in a dropdown). `one-time-code` is the semantic token
for a verification code; Firefox honours it specifically to keep the
value out of form history (Mozilla bug 1547294). The e-mail field keeps
`new-password` to fend off saved-login autofill.
Tests: new Vitest cases assert autofocus on both steps and the code
field's `one-time-code` token; a new Playwright case covers the same in
Chromium and WebKit (Safari engine). Firefox form history is owner
manual-QA — there is no Firefox project in the e2e matrix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The map view now selects a DARK_THEME or LIGHT_THEME palette from the
resolved app theme and threads it through every primitive builder, so
the canvas, planets, ship groups, cargo routes, battle/bombing markers,
fog, reach + selection rings, pending-Send tracks, and the pick overlay
all switch with the rest of the chrome. A theme flip remounts the
renderer preserving the camera — Pixi bakes the background at init and
every primitive bakes its colour at build, so a live re-tint is not
possible on the same instance.
This also fixes the reported bug: the gear-popover trigger and the
loading overlay hardcoded a dark navy background, so in light theme the
gear was invisible (dark icon on dark chip) until hover flipped it to a
white chip. Both now use the --color-surface-overlay token and read
correctly in both themes.
The light palette mirrors the dark one role-for-role, darkened /
saturated for contrast on a light background while keeping the incoming,
battle, and bombing accents vivid. The values are a first pass meant to
be refined during the F8 manual-QA loop.
Removes the now-dead "Phase 35" references from the code and lifts the
map-recoloring prohibition from the design-system / renderer docs; the
battle scene stays a fixed-palette data-viz surface.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ARCHITECTURE.md §15 "Verification order" specifies signature verification
(step 4) before payload_hash (step 5), but the authenticated-edge
decorator chain wrapped the payload-hash gate outside the signature gate,
so the hash was checked first. gateway/README.md and gateway/docs/flows.md
had drifted to match the code (hash-first), leaving ARCHITECTURE.md as the
lone source describing the intended order.
Swap the two decorators in server.go so the signature gate runs first, and
align README + flows.md to ARCHITECTURE.md. Signature-first is the
cryptographically sound order: the signature covers the payload_hash field,
so the request is authenticated before any of its content is processed.
Observable side effect: a request carrying a tampered payload_hash whose
signature was computed over the original hash is now rejected at the
signature gate (UNAUTHENTICATED "invalid request signature") instead of the
hash gate (INVALID_ARGUMENT). Security is unchanged — both refusals happen
before the payload is handled. The four payload-hash unit tests re-sign
over the tampered hash so they keep exercising the hash gate; the
cross-service integration test signs over the overridden hash and already
accepts both codes.
Refs #39
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The single-URL restore replays the saved screen/view on an in-place
refresh only. Re-entering a game from the lobby resets activeView to the
map (lobby calls activeView.reset() before appScreen.go("game")), and
browser Back / the return-to-lobby control exit to the lobby. Spell this
out so the refresh-restore is not mistaken for a per-re-enter restore.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The app-shell migration surfaced a mobile-only e2e failure: taps on the
bottom-tab bar, the map-toggles menu, and the planet sheet were
intercepted by sibling elements despite the targets being on top.
Root cause: `.game-shell` used `min-height: 100vh`, so sub-pixel content
overflowed the viewport and made the document scrollable. On mobile that
scroll toggles the browser's dynamic toolbar, which resizes the viewport
and every `position: fixed` overlay (their sizes derive from `100vh`)
mid-gesture — defeating Playwright's actionability hit-test, and making
the real controls jittery to tap.
Pin the shell with `position: fixed; inset: 0` on the mobile breakpoint
so it leaves document flow: the document can no longer scroll, the
toolbar stays put, the viewport and overlays stay stable, and the
active-view area remains the single internal scroll region. Desktop is
unchanged (the rule is scoped to max-width: 767.98px).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Unit: repoint moved screen imports (lib/screens, lib/game), mock
$lib/app-nav (appScreen/activeView) instead of $app/navigation, drop the
removed gameId props, assert screen/view selection.
- e2e: add a dev-only window.__galaxyNav affordance; specs enter a game via
enterGame(...) instead of a /games/:id URL; URL assertions become content
assertions (the URL stays /game/); reload uses waitUntil:"commit" (shallow
routing) and mocks /rpc on game entry.
- Remove the obsolete report scroll-restore test (it relied on a SvelteKit
route Snapshot that no longer exists); update the missing-membership test
to the new lobby-redirect+toast behaviour. Fix a stale report.svelte
docstring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- A restored game that no longer exists (cancelled/removed/revoked) drops to
the lobby with a toast instead of the in-game error state: game-state
exposes a `notFound` flag and the shell redirects via appScreen.go("lobby").
- Add a visible "return to lobby" control to the in-game header.
- Push/toast deep-links use activeView.select(...) (no URL); fix a latent
visibility-listener double-install on in-place game switches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror the screen into browser history via SvelteKit shallow routing
(pushState/replaceState with page.state) so Back/Forward move between
screens while the URL stays at /game/. Overlays (game, lobby-create) push;
lobby/login replace. A popstate→page.state effect syncs the store back
without re-pushing (no loop); the boot stamp puts a restored overlay above
the load entry so Back falls through to lobby. In-game view switches never
touch history.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Collapse the game UI to one route (`/`): a screen dispatcher renders
login/lobby/lobby-create/game from `appScreen`/`activeView` state instead of
URL routes. Move screen components to lib/screens & lib/game; the game shell
reads the game id from `appScreen.gameId` and re-inits per-game stores via an
$effect; in-game views render from `activeView`. Flip ~23 goto/href nav sites
to store mutations; drop the `?sidebar=` URL coupling. Auth gate is now
state-based. WIP: browser-history (Back→lobby), restore-validation, the
return-to-lobby button, push deep-links, and the test migration are follow-ups
on this branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `appScreen` + `activeView` rune singletons with a shared sessionStorage
snapshot — the in-memory source of truth that replaces URL-based screen/view
routing for the single-URL app-shell. Not wired in yet (additive).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VitePress is a Vue SPA; a same-origin link to /game/ (a separate app, not
a VitePress page) was intercepted by its client router and rendered
VitePress's own 404 until a manual reload. Mark the game links (both
home pages and the nav item) target="_self" so the click is a real
browser navigation that the edge Caddy serves from the game bundle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- The app root ("/", i.e. /game/) rendered a dev "workspace skeleton"
stub, and the layout guard only redirected anonymous users off it, so
an authenticated visitor stayed on the stub. Redirect "/" to /lobby
(authenticated) and /login (anonymous), and replace the stub with a
minimal loading placeholder. Drop the obsolete landing-stub unit test
(root redirect is covered by the auth-flow e2e).
- Ship a tombstone /service-worker.js on the project site so any old
root-scoped PWA worker (from when the game lived at the origin root)
unregisters itself instead of serving a stale cached page at the
site origin. The game now registers its worker only under /game/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The single-origin manifest now uses relative URLs (`start_url: "./"`) so
it stays base-agnostic under `/` and `/game/`. Update the PWA spec to
assert the relative value instead of the old absolute `/`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Serve the whole stack behind one host: site at /, game UI at /game/,
gateway REST at /api + /healthz, Connect at /rpc (prefix stripped by the
edge Caddy). The built artifact is domain-agnostic — the UI talks to the
gateway same-origin via relative URLs, so the same bundle runs under any
host with no rebuild and with CORS disabled.
- Rename the Connect proto service galaxy.gateway.v1.EdgeGateway ->
edge.v1.Gateway; regenerate Go + TS; public path /rpc/edge.v1.Gateway.
- Move the game UI under base path /game (env BASE_PATH); make the
manifest, service-worker scope, WASM loader, and all navigation
base-aware via a withBase helper.
- Relative API + /rpc Connect prefix; Vite dev proxy mirrors the strip.
- Rewrite the edge Caddy (dev + prod) for path-based routing; empty CORS
allow-lists (same-origin); single host.
- New VitePress project site (site/): i18n en/ru with switcher, LaTeX
math, minimal monospace theme; built and served at /.
- dev-deploy compose/Makefile + CI (dev-deploy, prod-build, new
site-build) build and seed the site; probes hit /, /game/, /healthz.
- Sync docs (ARCHITECTURE, gateway README/openapi, dev-deploy &
local-dev READMEs, CLAUDE.md, ui/PLAN).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The de-archaeology and the ui/docs index landed in the earlier reorg
(0 "Phase N" refs in ui/docs/; 24 topic docs linked). This finishes the
sync: ui/README.md gains a finalized-web-target summary and links the new
topic docs (design-system, a11y, error-state-ux, pwa-strategy).
docs/ARCHITECTURE.md and docs/FUNCTIONAL.md need no change — they cover
the backend/gateway/cross-service contracts with no stale UI statements;
the finalized UI is client-local UX documented under ui/docs/. Marks F7
done.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SvelteKit's automatic SW registration also runs under `vite dev`, where
the worker intercepted/cached the dev-server e2e suite (42 failures).
Disable auto-registration (kit.serviceWorker.register: false) and
register the worker manually from the root layout guarded by `!dev`, so
`vite dev` and the e2e suite run worker-free while the production build —
and the PWA preview test — still install it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Native SvelteKit service worker (src/service-worker.ts): a version-keyed
cache precaches the app shell + build artefacts (incl. core.wasm) +
static files; activate purges old caches; the gateway is never
intercepted; navigations fall back to the cached shell offline. Adds
static/manifest.webmanifest, a generated placeholder icon set
(scripts/gen-pwa-icons.mjs — dependency-free pure-Node PNG encoder), and
manifest / theme-color / apple-touch tags in app.html.
Gated by Playwright against a production preview (playwright.pwa.config.ts
+ tests/pwa/pwa.spec.ts via `pnpm test:pwa`, wired into ui-test):
manifest + installable icons, SW registration + a single version-keyed
cache, and offline shell load. Lighthouse is not used — its PWA category
was removed in v12.
Docs: ui/docs/pwa-strategy.md (+ index); F5 marked done.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
core.wasm and wasm_exec.js are no longer tracked (untracked + gitignored).
A reusable composite action .gitea/actions/build-wasm installs TinyGo
(actions/cache'd) and runs `make -C ui wasm`; it runs in all three
frontend-building workflows — ui-test (before Playwright; Vitest uses the
fake Core and needs no build), dev-deploy, and prod-build. ui-test gains a
Go setup (TinyGo shells out to Go); the deploy workflows already had one.
Docs: ui/docs/wasm-toolchain.md, ui/README.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The F4 selection ring is a real map primitive. The cargo-route flow has
the source planet selected, so the total primitive count is 8 (7 + the
ring circle), not 7; the line count (3) is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The planet/ship-group sheets stay mounted on desktop but are hidden by a
media query (`display: none`); the document-level tap-outside listener
fired regardless, so the first click after selecting a planet cleared the
selection — breaking every desktop inspector/select flow in CI. Guard the
handler on the sheet's computed display (`offsetParent` is unreliable for
`position: fixed`). The swipe handle is naturally inert when hidden.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- lib/error/: classify any caught error into a stable ErrorKind from the
transport signal (HTTP status / Connect Code / fetch TypeError /
navigator.onLine); map to translated error.* messages via reportError
(sticky Retry toast for retryable kinds) or errorMessageKey (inline).
Mail compose now surfaces the translated 403/error inline.
- lib/ui/view-state.svelte: shared loading/empty/error placeholder with
the right live-region role + optional action; entity tables
(races/sciences/ship-classes) migrated, rest adopt incrementally.
- map/selection-ring.ts: accent ring around the selected planet, fed into
the map buildExtras alongside the reach circles.
- lib/ui/sheet-dismiss.ts: tap-outside + drag-handle swipe-down dismissal
for the planet/ship-group bottom-sheets (hand-rolled pointer events).
Tests: error, view-state, selection-ring, sheet-dismiss (761 total).
Docs: ui/docs/error-state-ux.md (+ index); F4 marked done.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
An audit found the client already i18n-first: one hard-coded UI string
(the battle-scene aria-label, now keyed) and en/ru already share an
identical 692-key set.
- Persist the locale: i18n.setLocale writes localStorage (galaxy-locale)
and the store boots from stored > browser detection > default, so a
language switch survives reloads.
- tests/i18n-completeness.test.ts: en/ru key-set parity, non-empty
values, and locale persistence.
- Docs: ui/docs/i18n.md; mark F3 done in ui/PLAN-finalize.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With the default theme now following the OS, Playwright renders the light
theme, where the previous light `--color-danger` (#c84d4d, ~3.9:1 on a
near-white surface) failed WCAG 1.4.3 on error text — caught by the axe
scan of the science designer's empty-name error. Darken light
`--color-danger` to #c0392b (~5.5:1 on white; white-on-danger fills stay
≥5:1). Dark theme unchanged.
Light has been signed off, so the theme store's default choice is now
`system` (it was `dark` during the incremental migration). This matches
the app.html pre-paint guard, which already resolved an unset choice via
prefers-color-scheme — removing the brief boot-time mismatch where the
store re-pinned dark. Users still pin light/dark via the account-menu
picker. Updates the store default + its test and the design-system /
finalize-plan docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the a11y foundation and bring login, lobby, and the in-game shell to
WCAG 2.2 AA:
- Primitives: .sr-only + .skip-link (base.css), trapFocus (modal focus
trap + restore) and restoreFocus (menu focus restore) actions, the
--color-focus visible ring.
- In-game shell: skip link + focusable main landmark; WAI-ARIA sidebar
tabs (roving tabindex, arrow/Home/End, tabpanel wiring); menu Escape +
focus restore (account / view / turn-navigator / map-toggles /
bottom-tabs); mail compose as a role=dialog modal with a focus trap.
- login / lobby / lobby-create: skip link + main landmark, field labels,
role=alert / role=status live regions.
- Map canvas: aria-label naming it a visual overview, with its data
reachable by keyboard via the sidebar inspector and tables (accessible
alternative; in-canvas keyboard nav deferred).
Gates (chromium-desktop): tests/e2e/a11y-axe.spec.ts scans every
top-level view for WCAG 2.2 AA violations (zero); a11y-keyboard.spec.ts
covers the skip link, menu Escape+restore, and tab roving. Adds
@axe-core/playwright. Docs: ui/docs/a11y.md (+ index). Marks F1 and F2
done in ui/PLAN-finalize.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tokenize every remaining component <style> — calculator, order tab,
inspectors, tables, report sections, lobby, auth, mail, battle viewer,
toasts, map overlays. A scripted pass handled the unambiguous core
palette (text/bg/surface/border/accent/danger/muted), the rest were
mapped to the semantic/grey tokens by role.
Remaining colour literals are the documented exceptions only: the
battle-scene SVG data-visualisation palette (fixed dark, like the WebGL
map canvas), overlay scrims (modal / map-canvas), and directional or
deliberate drop shadows. The default theme stays dark until light
coherence is signed off across the views.
Updates ui/docs/design-system.md (migration status + exceptions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce the shared design-token system under
ui/frontend/src/lib/theme/: tokens.css (dark default + light palette,
plus spacing/radii/typography scales), base.css global baseline
(document background, text, token focus ring, selection), and
theme.svelte.ts (system/light/dark choice, persisted to localStorage,
applied via data-theme on <html>). A pre-paint guard in app.html
resolves the theme before the app boots to avoid a flash, and the theme
picker is wired into the previously-disabled account-menu stub.
Migrate the always-visible in-game chrome to the tokens (header, account
menu, sidebar, tab-bar, bottom-tabs, shell background): dark renders as
before, light comes for free. The default stays dark during the
incremental migration; the remaining view bodies migrate in F1b.
Docs: ui/docs/design-system.md (+ index entry). Test: tests/theme.test.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MVP web client (Phases 1-30) is complete; reorganize planning + living docs around that.
- PLAN.md kept as the staged MVP record (1-30) with a status block + pointers; removed the 31-36 stages, regression scenarios, and deferred-TODO section (moved out); fixed a stale cross-machine plan path.
- ui/PLAN-finalize.md (new): active web-finalization plan in 8 stages (visual system, a11y, i18n, error UX, PWA, build hygiene, docs, owner manual-QA loop); absorbs former Phases 33 and 35.
- ui/ROADMAP.md (new): post-MVP (Wails, Capacitor, realistic projection, acceptance + regression scenarios) and triaged deferred follow-ups.
- ui/docs/README.md (new): grouped topic-doc index.
- De-archaeologized all 20 ui/docs topic docs + ui/README.md + ui/core/README.md: stripped Phase-N build history, rewritten as current-state; deferred work now points at ROADMAP.md / PLAN-finalize.md. Docs-only; no code change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- custom load capped at cargo capacity (error when exceeded); full load shows the cargo capacity; zero cargo pins load to empty and disables the toggle
- per-input red border + tooltip for every invalid value (blocks, techs, load, MAT, modernization target); no value may be negative; locking a speed is disabled when drive is zero
- display every computed number (results + goal-seek back-solved input) rounded up to 3 decimals via a shared pkg/calc Ceil3 bridged to wasm; engine keeps its own round-to-nearest util.Fixed*
- modernization total upgrade cost spans two columns (single line)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the calculator's inputs into a page-level calculatorState singleton so they survive the sidebar unmounting the tab on a tab switch (the inspector auto-opens on a planet click). ensureGame resets the design when the active game changes.
While on the calculator, a planet click no longer switches to the inspector — the calculator consumes the selection in its planet area / reach circles. Halve the reach-circle stroke width.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fuse the standalone ship-class designer (Phases 17/18) into a sidebar calculator: live mass/speed/attack/defence/bombing results, a planet build-rate readout, single-target goal-seek, a modernization-cost mode, and auto reach circles on the map for the selected planet.
pkg/calc becomes the single source for the new math (no mirroring): extract BombingPower from the engine model and the per-turn ship-production loop from controller.ProduceShip into pkg/calc (engine now delegates), and add inverse goal-seek solvers in pkg/calc/solve.go. Thin-bridge the combat, planet-build, and solver functions through ui/core/calc + ui/wasm and rebuild core.wasm.
Remove the standalone designer view/route; the ship-classes table and the view/bottom menus open the calculator via a shared request store.
Docs: rewrite ui/PLAN.md Phase 30, adjust Phase 34 (realistic forecast + CAP/COL ownership), add ui/docs/calculator-ux.md, extend calc-bridge.md, fix navigation.md; remove ui/CALCULATOR.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`pnpm/action-setup@v4` defaults to installing pnpm in the shared
`~/setup-pnpm`. On the single host-mode runner $HOME is shared across
concurrent jobs, so when two pnpm jobs overlap (e.g. a post-merge
`dev-deploy` and `ui-test`, which sit in different concurrency groups)
their self-installers race and one fails with
`ENOTEMPTY ... rmdir '~/setup-pnpm/node_modules/.bin/store/v11/files'`
before the tests even run.
Point each step's `dest` at `${{ runner.temp }}/setup-pnpm` (a per-job
isolated directory) so concurrent jobs never share the install location.
The action still adds `dest` to PATH, so setup-node's pnpm cache and
later `pnpm` calls are unaffected; the pnpm package store stays shared
(safe — pnpm locks it). Applied to the three workflows that set up pnpm:
ui-test, dev-deploy, prod-build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stage 1 (render-on-demand) removed the idle / whole-system freeze, but
panning a loaded map with "visible hyperspace" on stayed heavy in Safari:
the fog still cut its visibility holes by opaque overpaint — on KNNTS041
that is ~260 near-world-sized opaque circles blended over the fog every
rendered frame, a fill-rate cliff for Safari's WebGPU / Apple's tile-based
GPU.
Replace the overpaint with an INVERSE stencil mask: setVisibilityFog now
draws the FOG_COLOR rectangle(s) into fogLayer and collects the visibility
circles into one Graphics set as fogLayer.setMask({ mask, inverse: true }),
so the fog shows everywhere except the union of the circles. Per-frame cost
drops from dozens of blended opaque circle fills to one rect fill + a
stencil pass (no colour writes), which Apple's TBDR GPU handles cheaply,
and the fog stays fully vector — crisp at any zoom.
fogPaintOps and its unit tests are unchanged (the circle ops now feed the
mask instead of an overpaint). Verified with a high-contrast screenshot
during development (fog field with a correct circle-union hole) plus the
existing fog / render-on-demand e2e green on chromium + webkit.
Docs: renderer.md fog section + PLAN.md Phase 29 decision 9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 29 visibility fog ("visible hyperspace") froze the whole UI on
large reports in Safari while staying smooth in Firefox. Root cause: the
fog is a layered overpaint (torus mode = 9 world-sized rects + 9xN
near-world-sized opaque circles, ~270 fills for KNNTS041) and Pixi's
continuous auto-render loop re-rasterised all of it every frame, even
while idle. Safari's WebGPU backend cannot sustain that fillrate, so the
main thread/compositor starved and the entire UI froze.
Stage 1 (vector-preserving, no rasterisation):
- Stop Pixi's auto-render loop (app.stop()) and paint on demand via a
single Ticker.shared flush gated on viewport.dirty (camera) plus an
internal requestRender() from every content mutation (fog / hide-set /
extras / wrap mode / resize / pick overlay). An idle map now does zero
GPU work per frame; plain hover paints nothing.
- Remove the decelerate (drag-inertia) plugin: a released drag stops
instantly (owner request) and the viewport goes idle immediately.
- Expose RendererHandle.getRenderCount() / getMapRenderCount for
deterministic e2e assertions.
Tests: new map-toggles e2e specs (idle map does not repaint; released
drag does not coast) green on all four Playwright projects incl. WebKit.
Docs: renderer.md (render-on-demand section; fog section corrected to the
current single-fogLayer model; FPS note) and PLAN.md Phase 29 decision 8.
If Safari pan is still heavy after this, stage 2 will cut the overpaint
itself with an inverse stencil mask of the circle union (kept vector).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two regressions surfaced once visible-hyperspace toggled on a real
dev-deploy map:
1. On the zero-turn map the bg holes painted ON TOP of the planet
glyphs — every LOCAL planet looked like a hollow circle of
background colour instead of the planet pixel inside an
unfogged area.
2. On a legacy report with a drive tech that pushes the visibility
radius well past the world dimensions the bg circles overlapped
to cover the entire viewport. Combined with the wrong z-order
the result was a uniformly black canvas with every primitive
hidden.
The per-copy implementation added the fog container via
`copy.addChildAt(container, 0)` and trusted Pixi v8 to insert the
container at the start of the copy's children. Whether by a Pixi
quirk or by some interaction with how `populatePrimitives` orders
its `c.addChild(g)` calls, the fog ended up rendering after every
primitive in practice — the symptoms above are a perfect match for
that ordering.
Restructured the fog rendering so the z-order is structural
rather than relying on `addChildAt`:
- A single `fogLayer: Container` is added to the viewport BEFORE
the nine torus copies. Pixi renders viewport children in order,
so the layer is guaranteed to paint first; every copy renders
on top.
- `fogPaintOps` now emits world-space coordinates with wrap
offsets baked in (9 fog rects + 9 bg circles per visibility
entry in torus mode, 1 + N in no-wrap mode). The renderer
populates `fogLayer` with one `Graphics` per op — no per-copy
iteration on the fog side.
- The previous `fogGraphics: Container[]` closure state is gone.
Each `setVisibilityFog` flip drops every child of `fogLayer`
and rebuilds it. The dispose path drops the children
eagerly before `app.destroy({children: true})` walks the tree.
The fog-paint-ops test exercises the new contract: the no-wrap
path keeps one rect + N circles, the torus path expands to nine
rects + nine wrapped circles per entry (including the seam-fix
case at x = 950).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two visible regressions in the in-game map's fog overlay surfaced
on dev-deploy:
1. With three LOCAL planets close together, only the last planet
glyph stayed visible inside the bg holes — the other two were
obscured. The previous implementation stacked the fog rectangle
plus every bg circle onto a single `Graphics` via repeated
`g.rect(...).fill(...).circle(...).fill(...)...`. Pixi v8's
multi-shape Graphics is supported in theory, but in practice
only the last shape's fill seems to land, dropping the earlier
bg holes (and the planet glyphs on top look like they vanished
along with their hole). Splitting each op onto its own
`Graphics` inside a per-copy `Container` removes the ambiguity
— one shape, one fill, one render pass.
2. A planet near the right world edge produced a "sector" — the
bg circle painted into the area past the seam, but the
neighbouring tile's fog rectangle then overpainted that bleed,
leaving a quarter-circle hole. In torus mode each visibility
circle is now drawn at the nine wrapped positions
(`(dx, dy) ∈ {-1, 0, 1}²`); the wrapped copies in the
neighbour-tile-aligned positions keep the hole continuous
across the seam. No-wrap mode keeps a single emission per
circle, because wrapped circles would leak into the visible
world rectangle as unwanted holes.
The `fogPaintOps` helper now takes the wrap mode as a parameter;
`tests/fog-paint-ops.test.ts` covers the torus expansion
(nine-wrap product per circle, the seam-fix case at x = 950) and
re-asserts the no-wrap path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lifted the Phase 29 fog draw sequence out of `setVisibilityFog`
into a pure `fogPaintOps` helper that returns an ordered list of
fill operations (one fog rect, then one background-coloured
circle per visibility entry). The renderer now dispatches each op
straight onto a Pixi `Graphics`; the indirection lets the layered-
overpaint contract be tested without booting Pixi.
`tests/fog-paint-ops.test.ts` covers: empty input → no ops; single
circle → fog rect + bg circle in that order; multiple circles → N
bg circles after the fog rect; overlapping circles emitted
independently (the rendering order unions them); zero / negative
world dimensions → no ops.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 29 fog overlay rendered as a handful of random arc
segments instead of a clean union of holes around LOCAL planets
— Pixi v8's `Graphics.cut()` does not reliably subtract multiple
overlapping circles from a base path.
Replaced the cut-based approach with a layered overpaint: a
fog-tinted rectangle fills the world, then opaque background-
coloured circles are painted on top for every visibility circle.
The natural rendering order unions overlapping circles for free —
no geometry, no `cut()` quirks, one extra fill per circle.
Renamed the toggle from `visibilityFog` to `visibleHyperspace`
across the store, i18n strings, popover, tests, and docs. The
overlay still implements the visual "fog" effect at the renderer
level (FOG_COLOR, setVisibilityFog, getMapFog); the toggle is
named after the player-facing concept it controls — the portion
of the map that is visible (intelligence/scan coverage) — rather
than the obscured part.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous logic re-mounted the renderer whenever
`store.wrapMode` flipped, because the `sameSnapshot` gate
included `handle.getMode() === mode`. Pixi 8 does not reliably
re-initialise an `Application` on the same canvas — the symptom
showed up as the chromium tab silently closing during the
Phase 29 wrap-mode e2e ("Target page, context or browser has
been closed").
The renderer already exposes an in-place `setMode` that swaps
the wrap-clamp / torus-copy visibility synchronously while
preserving the camera; the playground-map.spec.ts wrap toggle
has been driving it for several phases without issue. Drop
mode from the snapshot gate and route the change through
`handle.setMode(mode)` instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run #217 surfaced three independent bugs that survived the first
fixup pass:
1. `visibleHighBitCount` masked the id with `(prim.id >>> 0) & 0xf…`,
but JS bitwise AND always returns a signed int32 — the mask had
to be re-converted with `>>> 0` AFTER the AND, not before. Result
was always 0 on the previous run, masking the next two bugs by
making the persistence test's high-bit-count assertions a
tautology.
2. `applyVisibilityState` was wrapped in `untrack`, so the
`toggles.X` reads inside `computeHiddenIds` / `computeFogCircles`
never landed in the effect's dependency set — toggling fog or any
marker / group / kind flag did not re-run the effect, so the
renderer never received the new hide / fog input. Explicit
`void toggles.X` reads now live at the top of the effect so every
key is tracked synchronously.
3. The wrap-mode radios fired on `onchange`, which Svelte 5
suppresses on a re-activation of an already-checked input — the
Playwright `.click()` flake on the second wrap test reflected the
missed event. Switched to `onclick` and short-circuited when the
target mode is already active.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three independent bugs in `tests/e2e/map-toggles.spec.ts` made the
fresh-Phase-29 suite red on CI #216:
1. `visiblePlanets` filtered on `p.id < 1_000_000`, which JS interprets
in signed space — high-bit-prefix primitives (cargo route 0x80…,
battle 0xa0…, bombing 0xc0…) are stored as negative Numbers and
leaked into the planet list. Filter switched to a `0 < id < 1e7`
window that matches the engine planet-number range exactly.
2. The `visibleHighBitCount` helper now ToUint32-converts the id
before masking so the bitmask comparison works regardless of
whether the id is stored as positive or negative.
3. The fog and wrap-mode tests read the renderer state synchronously
after the click — the Svelte effect re-runs asynchronously, so the
tests saw stale state. Both now `waitForFunction` on the canonical
"settled" signal: empty fog circles for the fog flip, and a new
`getMapMode()` debug accessor for the wrap-mode remount.
Renderer side: registers a `MapModeProvider` next to the existing
camera / fog providers and exposes `getMapMode()` through the debug
surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the gear-icon popover on the map view with per-game persistence
of every category toggle plus the wrap-mode radio. Hide-by-id and
visibility-fog facilities land on the renderer so every flip applies
within one frame without a Pixi remount; the wrap-mode toggle keeps
its existing remount + camera-preserve path. A new server-side turn
force-resets every flag to defaults so a hidden category never makes
the player miss the next turn's news.
Also fixes the FligthDistance → FlightDistance typo in pkg/calc/race.go
(plus the single Go caller); the TS side keeps duplicating the formula
until a race-level WASM bridge lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`RandomName` builds the suffix as two independent `rand.Intn(1000)`
calls, so the two 4-digit halves collide on ~0.1% of runs. The
sub-test asserted `g[2] != g[3]`, which flakes whenever the same
value lands twice — once per ~1000 sub-runs per class, so across
the seven `PlanetClass` rows the integration suite hit it on
`#199 go-unit.yaml` against `feature/subscribe-events-heartbeat`
(`"0074"` collision).
Distinctness is not a property `RandomName` promises and is not
load-bearing for callers: `game/internal/controller/generate_game.go`
uses these names for planet labels and already tolerates duplicate
names across planets, so collisions inside one name are no worse
than collisions between names. Drop the assert; keep the format and
class-prefix checks, which are the actual contract.
Stress-tested with `-count=200`: 200 consecutive iterations × 7
classes = 1400 sub-runs without a single failure where the prior
version's flake probability would have surfaced ~once on average.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Caddy's `file_server` did not set Cache-Control on the SvelteKit
build, so browsers fell back to heuristic caching keyed off
Last-Modified. On the long-lived dev environment the heuristic
window leaves the previous deploy's `index.html` cached for
minutes-to-hours, and Safari combined that with stale conditional
requests into a visible multi-second freeze on every reload (the
reproduction was "private window reloads instantly, normal window
hangs; clearing Safari caches restores normal speed"). Push
delivery itself works — heartbeat keeps the SubscribeEvents stream
alive — but the bundle path stalls behind the browser revalidating
a chain of stale chunks.
Mirror the standard SvelteKit cache split inside both Caddyfiles:
- `_app/immutable/*` — hash-named JS/CSS chunks Vite emits with
content-addressed file names — `Cache-Control:
public, max-age=31536000, immutable`. Safe to cache forever
because the name changes whenever the content does, so the next
deploy serves new files under new URLs.
- Everything else (`index.html` fallback via `try_files`,
`env.js`, `version.json`, `core.wasm`, `wasm_exec.js`,
`favicon.svg`) — `Cache-Control: no-cache, must-revalidate`.
The browser still uses the cached body when the ETag matches,
but it always asks first; a fresh deploy reaches the user on
the next reload without a manual cache clear.
Smoke-tested locally: a docker-run Caddy with this config returns
the immutable header only for `/_app/immutable/*` and the
no-cache header for `/index.html`, `/env.js`, and the SPA-fallback
path `/some/route`. The Caddyfile passes `caddy validate` in
both `Caddyfile.dev` and `Caddyfile.prod`; the pre-existing
formatting warning on line 7 is untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Browser fetch-streaming layers close response bodies they consider
idle after roughly 15-30 s without incoming bytes. Safari is the
most aggressive, but the symptom matters everywhere: a quiet
SubscribeEvents stream (lobby, between turns, mailbox empty) gets
torn down by the browser, the EventStream singleton reconnects with
backoff, and any push event that fires inside the reconnect window
is lost because `push.Hub` queues are not persisted across
subscription closes. The user-visible failure mode is the
intermittent "Fetch API cannot load … due to access control checks"
console error (a misleading WebKit symptom — CORS headers are
actually present) plus missed turn-ready / mail-received toasts.
Server-side fix: a silence-based heartbeat at the
`authenticatedPushStreamService` wrapper layer. After the signed
`gateway.server_time` bootstrap event, gateway wraps the bound
stream with `heartbeatingStream`. Every tail Send (fan-out, future
variants) resets the silence timer; when the timer elapses, a
goroutine emits `gateway.heartbeat` with only `EventType` set —
everything else stays at proto3 defaults, so the wire frame is
~45 bytes amortised. A `sendMu` serialises the heartbeat goroutine
with tail Sends because grpc.ServerStream.Send is not goroutine-safe.
The heartbeat is intentionally UNSIGNED: heartbeats carry no
payload, dispatch to no handler on the client, and an injected
heartbeat trivially causes no user-visible state change. TLS still
protects the wire and real events keep the signed envelope
unchanged. Documented in `docs/ARCHITECTURE.md` § 15 alongside the
per-scale bandwidth projection (100…100 000 clients × 15…60 s).
Config: new `GATEWAY_PUSH_HEARTBEAT_INTERVAL` (default `15s`,
`0s` disables). Telemetry: new
`gateway.push.heartbeats_sent{outcome}` counter so operators can
budget bandwidth and spot a sudden `outcome=error` bump as an
upstream-failing-before-flush signal.
Client (`ui/frontend/src/api/events.svelte.ts`): early `continue`
on `event.eventType === "gateway.heartbeat"` before `verifyEvent`,
`verifyPayloadHash`, or dispatch — empty signature would otherwise
trip SignatureError and reconnect. A leading heartbeat still flips
`connectionStatus` to `connected` and resets backoff, because
receiving one is proof the stream is healthy.
Tests:
- `push_heartbeat_test.go`: unit tests for the wrapper — zero
interval returns nil, heartbeat fires after silence, real Send
resets the timer, Stop / context-cancel halt the goroutine,
Send errors propagate.
- `server_test.go`: integration tests through the full gateway
pipeline — heartbeat fires after the configured silence window,
zero interval keeps the stream silent.
- `config_test.go`: default applied, env-override parsed,
negative value rejected.
- `events.test.ts`: heartbeat skipped before verification + not
dispatched to handlers; leading heartbeat still flips
`connectionStatus` to `connected`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 28 ConnectRPC migration of the battle viewer added a
guard in `lib/active-view/battle.svelte` that waits for the
surrounding layout to publish a `GalaxyClient` before issuing the
fetch. The in-game shell layout deliberately skips
`galaxyClient.set(...)` on the synthetic branch (gateway is not
reachable in synthetic mode), so for any battle opened from a
synthetic-report game the viewer sat on "loading battle…"
forever — `fetchBattle` was never called, so the synthetic-fixture
short-circuit it carries was unreachable.
Let the guard skip synthetic ids: `fetchBattle` already resolves
those through `lookupSyntheticBattle` and never touches the
client, so its signature widens to `GalaxyClient | null` and the
synthetic path passes `null`. The live path still waits for the
handle as before; a `null` client on the live path now fails
fast with a transport-level `BattleFetchError` instead of silently
sitting on `loading`.
Tests:
- Existing "loading placeholder" smoke now uses a non-synthetic
game id so it keeps asserting the live-path wait.
- Two new cases pin the synthetic behaviour: missing fixture →
`battle-not-found`; registered fixture → `BattleViewer` mounts.
Docs:
- `docs/FUNCTIONAL.md` §6.5 still described the pre-Phase-28
raw REST path. Updated to the signed ConnectRPC command and
noted the synthetic short-circuit. Russian mirror updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`docker restart galaxy-dev-backend` failed with "not a directory"
after every dev-deploy workflow run. Root cause: the compose file
bind-mounted the geoip database via a relative path
(`../../pkg/geoip/test-data/test-data/GeoIP2-Country-Test.mmdb`).
When the Gitea runner invoked `docker compose up`, the path
resolved against the runner's ephemeral workspace under
`/home/runner/.cache/act/<hash>/hostexecutor/...`. The bind source
baked into the running container therefore pointed at that
ephemeral path; the runner deleted the workspace once the workflow
finished, and any later `docker restart` could not remount.
Replace the bind with a named volume `galaxy-dev-geoip-data`,
seeded at deploy time:
- `tools/dev-deploy/docker-compose.yml`: mount
`galaxy-dev-geoip-data:/var/lib/galaxy:ro` instead of a relative
bind. Declare the volume in the top-level `volumes:` block.
- `.gitea/workflows/dev-deploy.yaml`: new `Seed geoip volume` step
(placed right after the existing UI-volume seed) copies the
fixture from `pkg/geoip/test-data/test-data/` into the named
volume via an ephemeral alpine container, the same pattern UI
seeding already uses.
- `tools/dev-deploy/Makefile`: new `seed-geoip` target performs
the same copy from the persistent checkout. `up` and `rebuild`
now depend on it, so a hand-run `make -C tools/dev-deploy up`
populates the volume without operator action.
- `tools/dev-deploy/README.md`: updated the make-targets table to
list `seed-geoip`.
- `tools/dev-deploy/KNOWN-ISSUES.md`: the entry for the restart
failure is downgraded to a "fixed" postmortem; the symptom,
cause, and where the fix lives are kept for future reference.
Verification on the dev host (this branch checked out):
$ make -C tools/dev-deploy up # populates the volume, brings stack healthy
$ docker restart galaxy-dev-backend # used to error "not a directory"
$ until [ "$(docker inspect -f '{{.State.Health.Status}}' galaxy-dev-backend)" = "healthy" ]; do sleep 2; done
$ echo "ok" # backend up 6s, healthy
The pre-existing sandbox engine `galaxy-game-80f3ce86-...` survived
both `make up` and `docker restart` untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause for the long-standing "Dev Sandbox flips to cancelled
after dev-deploy" symptom in push-triggered cycles: when
`integration.yaml` runs in parallel with `dev-deploy.yaml`, its
`integration/scripts/preclean.sh` issues a `docker rm -f` over every
container labelled `galaxy.backend=1`. That label is stamped by the
backend's runtime adapter on every engine it spawns — including the
engines living in the long-lived dev-deploy environment on the same
Docker daemon. Each post-merge auto-deploy therefore had the
integration preclean wipe the dev-sandbox engine, and the new
backend's reconciler tick observed `container disappeared` and
cascaded the sandbox into `cancelled`.
Fix:
- `integration/testenv/backend.go` now sets
`BACKEND_STACK_LABEL=integration` on every backend-under-test, so
the engines spawned by integration carry
`galaxy.stack=integration` in addition to `galaxy.backend=1`. The
backend support for this env was added in the previous CI tidy-up
PR (#13).
- `integration/scripts/preclean.sh` gains a multi-label AND filter
helper and uses it to scope engine cleanup to the combination
`galaxy.backend=1 AND galaxy.stack=integration`. dev-deploy and
local-dev engines carry different `galaxy.stack` values, so the
AND match leaves them alone.
- `docs/ARCHITECTURE.md` "Container labels" — refreshed to call out
the AND-scoping rule and the new integration backend stamp.
- `tools/dev-deploy/KNOWN-ISSUES.md` — the sandbox-cancel entry
gets an "Update" section recording the root cause and the fix; the
status is downgraded to "partially fixed" because the solo
`workflow_dispatch` reproduction (which does NOT trigger
integration) remains unexplained.
- `tools/dev-deploy/KNOWN-ISSUES.md` — separately, document the
`docker restart galaxy-dev-backend` failure caused by the
runner-workspace bind-mount that surfaced while diagnosing this
issue. Workaround: `make -C tools/dev-deploy up` from the
persistent checkout. Real fix is a follow-up (bake fixture into
image or copy to named volume).
Verification:
- `go build ./backend/... ./integration/...` — clean.
- `bash -n integration/scripts/preclean.sh` — syntax OK.
- Live AND-filter check on the dev host:
`docker ps -aq --filter label=galaxy.backend=1 --filter label=galaxy.stack=integration`
returns nothing while the dev-deploy engine
`galaxy-game-80f3ce86-...` keeps running.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit stamped `galaxy.stack=<value>` on services,
volumes, and networks. Putting it on volumes/networks changes their
compose config-hash on every label revision, so `docker compose up`
tries to recreate them — which on the long-lived dev environment
either destroys the postgres data volume or deadlocks while trying
to remove `galaxy-dev-internal` with containers still bound to it.
Observed live: run #184 hung in compose recreate after the three
stateful services were stopped, with no recovery.
Containers alone are sufficient for the cleanup contract (we filter
containers, not volumes or networks). Roll back the label on volumes
and networks in both compose files and capture the rule in
docs/ARCHITECTURE.md so the next contributor does not reintroduce it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five connected cleanups across the dev/CI infrastructure:
1. Drop tools/local-ci/. The standalone Gitea + act_runner stack was
the legacy "offline workflow validator"; the per-stage CI gate now
runs on gitea.lan and the directory was only retained as a
fallback. Removing it leaves no operational dependency: backend,
gateway, and game code have no references; documentation that
pointed at it (CLAUDE.md, docs/ARCHITECTURE.md, ui/docs/testing.md,
tools/dev-deploy/README.md, tools/local-dev/README.md) is updated
in this same change. Historical "Verified on local-ci run N"
markers in ui/PLAN.md are preserved unchanged.
2. Lift the pre-production single-migration rule. The rule forced
every schema delta into 00001_init.sql and required a manual
make clean-data wipe on every backward-incompatible change in
tools/dev-deploy/. Future schema deltas now land as additive
sequence-numbered files (00002_*.sql, …) that goose applies
automatically on backend startup; 00001_init.sql becomes an
immutable baseline. Authoring conventions live in
backend/internal/postgres/migrations/README.md. The chain may be
squashed back into a fresh 00001 as a deliberate one-time
operation before the first production deployment.
3. Document the deployment cadence. The dev environment is
single-tenant: pushes to feature/* run the test workflows
(go-unit, ui-test, integration) only; dev-deploy.yaml fires on
push to development. A workflow_dispatch override on
dev-deploy.yaml lets a developer preview a feature branch on the
shared dev environment before merge; the next merge into
development overwrites the manual deploy idempotently.
4. Scope compose-managed resources by an explicit
galaxy.stack=<local-dev|dev-deploy> label. Both compose files
stamp the label on every service, network, and named volume.
Makefiles in tools/local-dev/ and tools/dev-deploy/ filter their
engine-cleanup operations by (stack-label AND engine OCI title)
so they never touch unrelated workloads on the same daemon.
dev-deploy.yaml gains a pre-`compose up` step that reaps stale
exited/dead containers under the dev-deploy stack label.
5. Backend now stamps the same galaxy.stack=<value> label on every
engine container it spawns, sourced from a new BACKEND_STACK_LABEL
env var (empty → label not applied; legacy-safe). Both compose
files set it to their stack name (local-dev / dev-deploy). The
contract is recorded in docs/ARCHITECTURE.md under
"Container labels". A package-level test in
backend/internal/runtime exercises both the label-present and
label-absent paths.
No tests intentionally regressed: go test ./backend/internal/{config,
runtime,dockerclient} is green, both compose files validate cleanly,
and the backend, gateway, and game modules all build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the live investigation, the project owner confirms that none
of the host-side cleanup paths apply: no docker prune cron, no
manual `docker rm`, no `dockerd` restart in the window, and the
engine binary does not crash while idling on API calls.
Replace the host-side hypothesis list with a one-line note that
they were considered and rejected, narrow the open suspicion to
the `dev-deploy.yaml` job sequence (`docker build` + `docker
compose build` + the alpine `docker run --rm` for UI seeding +
`docker compose up -d --wait --remove-orphans`), and park the
entry. Reopen if the symptom recurs with a fresh
`docker events --since 0` capture armed before the deploy
starts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A live `docker inspect` of an engine container and two redispatch
runs with `docker events` captured confirm:
- Engine has no `com.docker.compose.*` labels and `AutoRemove=false`,
so `--remove-orphans` cannot reap it.
- Two consecutive `dev-deploy.yaml` redispatches with an engine
already running emitted `die` / `destroy` events only for
`galaxy-dev-{backend,api,caddy}` — never for the engine.
- The reconciler tick that fires 60s after backend recreate
correctly matched the surviving engine in both cases
(`status=running` in both `games` and `runtime_records`).
- `runtime.Service` has no `Shutdown` that proactively removes
engine containers, so a graceful backend exit also leaves them
alone.
The repro window therefore needs a separate trigger that removed
the engine container outside of compose. The new hypotheses point
at host-side `docker prune` jobs, a `dockerd` restart that lost the
container, or an early `Engine.Init` failure that exited the engine
before `status=running` reached the runtime row. The investigation
list now leads with `journalctl -u docker` and the host crontab —
those are the cheapest checks to confirm or rule out next.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Capture the diagnostic notes for the issue we hit after every
`dev-deploy.yaml` redispatch: the freshly-bootstrapped "Dev Sandbox"
game ends up `cancelled` ~15 minutes later, with the runtime
reconciler reporting "container disappeared". The engine never
shows up in `docker ps -a --filter label=galaxy-game-engine`, so
either it never spawned or it was removed before any host-side
snapshot.
`KNOWN-ISSUES.md` records the symptom, the log excerpt, three
working hypotheses (runtime spawn race, `--remove-orphans`
interaction, engine `--rm` lifecycle), and the investigation
checklist before opening an issue. The README gets a one-line
pointer so future redeploys land on the doc immediately.
No code change — this is the placeholder so the next person
investigating the cancellation pattern does not have to
rediscover the diagnostic from scratch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two issues surfaced once the long-lived dev environment finally
reached the diplomail view:
1. `/sent` returns one row per recipient for broadcast and admin
fan-outs (so the admin tooling can render the materialised
audience). The list pane fed all rows into the stand-alone
bucket, so the `{#each entries as e (entryKey(e))}` key in
`thread-list.svelte` collapsed to the same `standalone:${id}`
for every recipient and Svelte 5 aborted the render with
`each_key_duplicate`. Dedupe stand-alones by `message_id` in
`buildEntries`.
2. The compose dialog exposed an `admin` kind toggle gated on
"owner of game". That was a Phase 28 plan decision, but admin
compose is an operator tool (server admin), not an in-game
action — every game owner should not be able to broadcast
admin notifications. Drop the admin option, the audience
sub-toggles, and the admin path through `submit`. The
`MailStore.composeAdmin` wrapper and the backend RPC stay so
the future admin UI can call them.
Vitest covers the fan-out dedup with three rows sharing one
`message_id` collapsing to a single stand-alone entry.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The public REST listener already exposes
`GATEWAY_PUBLIC_HTTP_CORS_ALLOWED_ORIGINS`; the authenticated
Connect-Web listener on the separate gRPC port had no equivalent.
That worked in `tools/local-dev` (Vite proxy makes everything
same-origin) and would work in production once UI and gateway share
a single hostname, but the long-lived dev environment serves the
UI from `https://www.galaxy.lan` and the gateway from
`https://api.galaxy.lan` — every `/galaxy.gateway.v1.EdgeGateway/*`
fetch failed in the browser with the WebKit "Load failed" generic
message because the response carried no `Access-Control-Allow-Origin`
header. Lobby rendered as "[unknown] Load failed" with no game.
Mirror the public-REST CORS surface for the authenticated handler:
- new env `GATEWAY_AUTHENTICATED_GRPC_CORS_ALLOWED_ORIGINS`;
- new `AuthenticatedGRPCConfig.CORSAllowedOrigins` field;
- new `grpcapi.withCORS` middleware wrapping the Connect mux;
- dev-deploy stack sets the env to `https://www.galaxy.lan`.
The middleware speaks plain net/http (the Connect handler is mounted
on a ServeMux, not gin), handles preflight 204 immediately, and
exposes the Connect-Web header set the browser needs to read the
response (`Grpc-Status`, `Grpc-Message`, `Connect-Protocol-Version`).
Empty allow-list disables the middleware — production stays at
"single hostname" by default.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`api.galaxy.lan` was proxying every path to `galaxy-api:8080` (the
public REST listener), so authenticated Connect-Web calls
(`/galaxy.gateway.v1.EdgeGateway/ExecuteCommand`,
`/galaxy.gateway.v1.EdgeGateway/SubscribeEvents`) collapsed to a 404
from the public route table — the lobby loaded the static bundle
but every authenticated query failed silently.
Split routing by path: `/galaxy.gateway.v1.EdgeGateway/*` goes to
the authenticated listener on `:9090`, everything else stays on
`:8080`. Mirrors the Vite dev-server proxy in
`ui/frontend/vite.config.ts`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two long-standing dev-environment ergonomics had not survived the
move from the bespoke local-dev stack to the CI-driven dev-deploy:
1. `BACKEND_DEV_SANDBOX_EMAIL` defaulted to an empty string in the
dev-deploy compose, so the auto-provisioned "Dev Sandbox" game
never appeared on `https://www.galaxy.lan`. Bake `dev@galaxy.lan`
as the default — matches `.env.example` and lets a developer who
logs in with that email find a ready-to-play game in the lobby.
2. The lobby's synthetic-report loader was gated on
`import.meta.env.DEV`, which is true only for `vite dev` (the
tools/local-dev path). The long-lived dev environment builds
with `vite build` (production mode), so the section was always
stripped from its bundle. Gate it on an explicit
`VITE_GALAXY_DEV_AFFORDANCES` flag instead and set it both in
`.env.development` (preserves `pnpm dev` behaviour) and in the
`dev-deploy.yaml` build step. The `prod-build.yaml` build path
leaves the flag unset, so production stays clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two problems showed up while trying to log into the long-lived dev
environment with the dev-fixed code `123456`:
1. `ConfirmEmailCode` checked the per-challenge attempts ceiling
*before* the dev-fixed-code override. A developer who burned past
`ChallengeMaxAttempts` on an existing un-consumed challenge (easy
to trigger when the throttle reuses one challenge_id) hit
`ErrTooManyAttempts` and the UI rendered "code expired or already
used" even though the fixed code was correct. Reorder so the
dev-fixed-code branch runs first and bypasses both the bcrypt
verify and the attempts gate. Production stays unaffected
because production loaders refuse to set `DevFixedCode`.
2. `dev-deploy.yaml` only fires on push to `development`, so the
matching docker-compose default change for
`BACKEND_AUTH_DEV_FIXED_CODE` could not reach the running stack
before this PR merged. Add `workflow_dispatch: {}` so a developer
can deploy any branch — typically a feature branch under review —
from the Gitea Actions UI without waiting for the merge.
Covered by a new `TestConfirmEmailCodeDevFixedCodeBypassesAttemptsCeiling`
integration test that burns through the ceiling with wrong codes
then proves the dev-fixed code still produces a session.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 28 moved the battle fetch off the REST passthrough onto the
signed envelope, so the Playwright spec's `page.route(...)` against
the old REST path no longer intercepts anything and the viewer
times out waiting for data. Update the spec to:
- Build a FlatBuffers `BattleReport` payload in
`fixtures/battle-fbs.ts` (mirrors `report-fbs.ts`'s pattern).
- Add a `user.games.battle` case to the ExecuteCommand mock that
decodes the FBS `GameBattleRequest`, returns the encoded report
when the battle_id matches the seeded one, and surfaces a
canonical `not_found` resultCode otherwise.
- Drop the obsolete REST route stubs.
- Drive the negative-path test with a real UUID that does not match
the seeded one, so the gateway-side switch is the source of the
404 (the old `missing-uuid` literal was no longer a valid wire
shape for the UUID decoder).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Phase 27 BattleViewer was the last UI surface still issuing raw
fetch() against the backend REST contract (`/api/v1/user/games/...
/battles/...`). The dev-deploy gateway never proxied that path, so
the viewer worked only in tools/local-dev/. Move it onto the signed
ConnectRPC channel every other authenticated surface already uses.
Wire pieces:
- FBS GameBattleRequest in pkg/schema/fbs/battle.fbs, regenerated
Go + TS bindings.
- MessageTypeUserGamesBattle constant + GameBattleRequest struct in
pkg/model/report/messages.go.
- pkg/transcoder/battle.go gains GameBattleRequestToPayload and
PayloadToGameBattleRequest helpers.
- gateway games_commands.go switches on the new message type and
GETs /api/v1/user/games/{id}/battles/{turn}/{battle_id}; the JSON
response is re-encoded as a FlatBuffers BattleReport before being
returned. 404 from backend surfaces as the canonical `not_found`
gateway error.
- ui/frontend/src/api/battle-fetch.ts now builds the FBS request,
calls GalaxyClient.executeCommand, and decodes the FBS response
into the existing UI shape (Record<string,string> race/ship maps,
string-form UUID). BattleFetchError carries an HTTP-style status
derived from the result code so the active-view's not_found branch
keeps working.
- battle.svelte pulls the GalaxyClient from the in-game shell
context. While the layout's boot Promise.all is in flight the
effect stays in `loading` until the client handle becomes
non-null.
- ui/Makefile FBS_INPUTS gains battle.fbs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The long-lived dev environment now opts into the bcrypt-bypass on a
fresh `up`/`rebuild` so a returning developer can sign in with `123456`
even after the matching browser session was cleared (the real emailed
code is single-use). Set the variable to an empty string in `.env` to
force real Mailpit codes (mail-flow QA).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`tests/mail-store.test.ts` exercises the `entries` derived rune
with handcrafted inbox + sent fixtures:
- personal messages exchanged with one race collapse into a
per-race thread with messages sorted oldest → newest;
- system mail (`sender_kind=system`) and admin notifications
(`sender_kind=admin`) surface as stand-alone items even when a
race-name snapshot is present;
- the caller's own paid-tier broadcasts (`broadcast_scope=
game_broadcast`) render as stand-alone outgoing items;
- `unreadCount` counts inbox rows with `readAt === null`.
The store fields are mutated directly to avoid wiring a fake
`GalaxyClient`; the underlying `$derived` rune fires whenever
those fields change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- `ui/docs/diplomail-ui.md`: new topic doc covering the wire
surface, recipient-by-race-name decision, threading model,
translation toggle, push events, badge, layout, and
accessibility.
- `docs/FUNCTIONAL.md` §11.4 grows a paragraph that records the
UI's per-race threading rule, the absent read-receipt UX, and
the recipient-by-race-name compose path. Mirrored verbatim into
`docs/FUNCTIONAL_ru.md`.
- `ui/PLAN.md` Phase 28 marked done with a "Decisions during
stage" block matching the implementation plan, and the artifact
list updated to the actual file set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 7 — header view-menu badge.
`view-menu.svelte` reads `mailStore.unreadCount` and renders an
inline pill next to the "diplomatic mail" entry whenever the
counter is non-zero. The badge styling matches the per-row dot in
`thread-list.svelte` so the two surfaces feel consistent.
Step 8 — push event handler + MailStore init in the in-game layout.
`routes/games/[id]/+layout.svelte`:
- registers a `diplomail.message.received` handler alongside the
existing `game.turn.ready` / `game.paused` ones, parses the
signed payload, calls `mailStore.applyPushEvent` to refresh the
inbox for the matching game, and raises a toast with a "view"
deep-link that navigates to `/games/:id/mail`;
- adds `mailStore.init({ client, cache, gameId })` to the boot
`Promise.all` so the inbox + sent lists are warm by the time the
view mounts, and the badge counter is populated before any user
interaction;
- disposes the new subscription in the `onDestroy` block so a game
switch does not leak handlers across navigations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 6 — mail active view + subcomponents.
- `lib/active-view/mail.svelte` replaces the Phase 10 stub with the
list / detail layout: two-pane on desktop, one-pane stack on
mobile (CSS media query, no separate route).
- `lib/active-view/mail/thread-list.svelte` renders per-race
threads collapsed to their last message plus stand-alone
system / admin / outgoing-broadcast items, with unread badges.
- `lib/active-view/mail/thread-pane.svelte` is the chat-style
transcript for one race; bodies render through `textContent`,
per-message Show original / translation toggles flip the
rendering when a translated body is present, and a persistent
reply box at the bottom calls `mailStore.composePersonal`.
- `lib/active-view/mail/system-item-pane.svelte` renders one
stand-alone item read-only with the same translation toggle.
- `lib/active-view/mail/compose.svelte` is the compose dialog:
recipient race picker fed from `report.races[]`, kind toggle
(personal / broadcast / admin), admin sub-toggle for target
user / all and recipient-scope picker. Server-side enforces
paid-tier and owner gating; the UI surfaces 403 inline.
- `lib/active-view/mail/system-titles.ts` keeps the keyword →
i18n-title mapping for lifecycle-hook system mail so both the
list and the detail pane pick the same canonical title.
Step 9 — i18n strings (en + ru).
`game.mail.*`, `game.view.mail.badge`, `game.events.mail_new.*`,
`game.mail.system.*` keys added in lockstep across both locales
covering compose labels / validation copy / per-system titles /
translation toggle / reply / delete affordances.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `src/lib/mail-store.svelte.ts` — the reactive store that
coordinates the in-game mail view. Responsibilities:
- holds the inbox and sent listings for the current game and fires
the initial parallel fetch (`fetchInbox` + `fetchSent`) on
`setGame`;
- exposes a `entries` derived rune that builds the unified list
pane: per-race threads merged from incoming + outgoing personal
messages, plus stand-alone items for system / admin / own
paid-tier broadcasts. Thread messages are sorted oldest → newest
for chat-style rendering; the list itself sorts newest-first by
the most-recent entry timestamp;
- derives `unreadCount` from `readAt === null` rows for the header
view-menu badge;
- imperative `markRead` / `softDelete` actions with optimistic
state flips and roll-back on RPC failure;
- compose actions for personal / paid-tier broadcast / owner-admin
sends;
- `applyPushEvent(gameId)` hook called by the layout when a
`diplomail.message.received` push frame arrives; refetches the
inbox without trusting the preview payload;
- persists the most recent message id under
`cache.diplomail/${gameId}/last-seen` so a returning session can
pre-paint the badge without a network round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds typed wrappers around `GalaxyClient.executeCommand` for the
eight Phase 28 mail RPCs. Each wrapper builds the matching
FlatBuffers request, decodes the response, and surfaces backend
errors through a dedicated `MailError` (mirroring `LobbyError`).
The compose helpers accept the recipient race name directly so the
UI can feed it straight from `report.races[].name` without a
membership lookup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the gateway-side translation layer that maps the eight new
ConnectRPC mail commands onto backend's
`/api/v1/user/games/{game_id}/mail/*` REST endpoints.
- `gateway/internal/backendclient/mail_commands.go` defines
`ExecuteMailCommand` and one helper per command (inbox, sent,
message.get, send, broadcast, admin, read, delete). Each helper
decodes the FlatBuffers request envelope, issues the REST call
via the existing `*RESTClient.do`, decodes the JSON body, and
re-encodes a typed FlatBuffers response. Recipient identifiers
travel through unchanged so the new `recipient_race_name`
shortcut introduced in Step 1 reaches backend untouched.
- `routes.go` exposes a `MailRoutes` constructor and a matching
`mailCommandClient` implementing `downstream.Client`.
- `cmd/gateway/main.go` registers the new routes alongside the
existing user / lobby / game-engine routes.
- `mail_commands_test.go` covers the inbox, send-by-race-name, and
read-state paths end-to-end against an `httptest.Server`,
asserting request shapes (path, body, X-User-ID) and the
decoded FlatBuffers response.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 28's in-game mail UI threads sent messages by the recipient
race name, so the bulk `/sent` endpoint now returns the same
`UserMailMessageDetail` shape as `/inbox` — single sends contribute
one row per message, broadcasts contribute one row per addressee
and the UI collapses them by `message_id` into a stand-alone item.
- `Store.ListSent` / `Service.ListSent` switched from `[]Message`
to `[]InboxEntry`. SQL grows an INNER JOIN with
`diplomail_recipients`.
- Handler emits `userMailMessageDetailWire` items; the deprecated
`userMailSentSummaryWire` is removed.
- `openapi.yaml`: `UserMailSentList.items` now reference
`UserMailMessageDetail`; the standalone `UserMailSentSummary`
schema is dropped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the wire schema for the eight `user.games.mail.*` ConnectRPC
commands together with the shared payload types (`MailMessage`,
`MailRecipientState`, `MailBroadcastReceipt`). Send-request tables
carry the optional `recipient_race_name` introduced in Step 1.
Drops:
- `pkg/schema/fbs/diplomail.fbs` — schema sources;
- `pkg/schema/fbs/diplomail/*.go` — generated Go bindings (flatc
`--go --go-module-name galaxy/schema/fbs`);
- `pkg/model/diplomail/diplomail.go` — message-type catalog used by
the gateway router;
- `ui/frontend/src/proto/galaxy/fbs/diplomail/*.ts` — generated TS
bindings consumed by the upcoming UI client wrapper;
- `ui/Makefile` `FBS_INPUTS` extended to pick the new schema up on
the next `make -C ui fbs-ts` run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 28's in-game mail UI groups personal threads by the other
party's race. To support that without an extra membership-listing
RPC, the diplomail subsystem now:
- accepts `recipient_race_name` on `POST /messages` and
`POST /admin` (target=user) as an alternative to
`recipient_user_id`; the service resolves it via the existing
`Memberships.ListMembers(gameID, "active")` and rejects with
`forbidden` when the matching member is no longer active;
- snapshots `diplomail_messages.sender_race_name` at send time for
every player sender (admin / system rows stay NULL). The UI keys
per-race threading on this column.
Schema, openapi, README, and a focused e2e test for the new path
(happy path + dual / missing / unknown / kicked errors) land in
this commit; the gateway + UI legs follow in subsequent commits on
this branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the documentation gaps from the freshly-audited diplomail
implementation. FUNCTIONAL.md gains a §11 "Diplomatic mail" with
the full user-facing story across all five stages, mirrored into
FUNCTIONAL_ru.md as the project conventions require. A new
backend/docs/diplomail-translator-setup.md captures the
LibreTranslate operational recipe (Docker image, env wiring,
manual smoke test, troubleshooting). The package README gains a
"Multi-instance posture" note documenting the deliberate absence
of FOR UPDATE in the worker pickup query — single-instance is
safe today; multi-instance scaling will revisit the claim
mechanism.
Two small edge-case tests round things out: malformed
LibreTranslate response bodies (single string, short array,
empty array, missing field) must surface as errors so the worker
falls back instead of crashing; and an empty translation queue
must produce zero events on three consecutive Worker.Tick calls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Synchronous translation on read (Stage D) blocks the HTTP handler on
translator I/O. Stage E switches to "send moments-fast, deliver
when translated": recipients whose preferred_language differs from
the detected body_lang are inserted with available_at=NULL, and an
async worker turns them on once a LibreTranslate call materialises
the cache row (or fails terminally after 5 retries).
Schema delta on diplomail_recipients: available_at,
translation_attempts, next_translation_attempt_at, plus a snapshot
recipient_preferred_language so the worker queries do not need a
join. Read paths (ListInbox, GetMessage, UnreadCount) filter on
available_at IS NOT NULL. Push fan-out is moved from Service to the
worker so the recipient only sees the toast when the inbox row is
actually visible.
Translator backend is now a configurable choice: empty
BACKEND_DIPLOMAIL_TRANSLATOR_URL → noop (deliver original);
populated → LibreTranslate HTTP client. Per-attempt timeout, max
attempts, and worker interval all live in DiplomailConfig. The HTTP
client itself is unit-tested via httptest (happy path, BCP47
normalisation, unsupported pair, 5xx, identical src/dst, missing
URL); worker delivery + fallback paths are covered by the
testcontainers-backed e2e tests in diplomail_e2e_test.go.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the LangUndetermined placeholder with whatlanggo-backed
body detection on every send path, then adds a translation cache
keyed on (message_id, target_lang) populated lazily on the
per-message read endpoint. The noop translator that ships with
Stage D returns engine="noop", which the service treats as
"translation unavailable" — wiring a real backend (LibreTranslate
HTTP client is the documented next step) is a one-file swap.
GetMessage and ListInbox now accept a targetLang argument; the HTTP
layer resolves the caller's accounts.preferred_language and
forwards it. Inbox uses the cache only (never calls the
translator) so bulk reads stay fast under future SaaS backends.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes out the producer-side of the diplomail surface. Paid-tier
players can fan out one personal message to the rest of the active
roster (gated on entitlement_snapshots.is_paid). Site admins gain a
multi-game broadcast (POST /admin/mail/broadcast with `selected` /
`all_running` scopes) and the bulk-purge endpoint that wipes
diplomail rows tied to games finished more than N years ago. An
admin listing (GET /admin/mail/messages) rounds out the
observability surface.
EntitlementReader and GameLookup are new narrow deps wired from
`*user.Service` and `*lobby.Service` in cmd/backend/main; the lobby
service grows a one-off `ListFinishedGamesBefore` helper for the
cleanup path (the cache evicts terminal-state games so the cache
walk is not enough). Stage D will swap LangUndetermined for an
actual body-language detector and add the translation cache.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Item 7 of the spec wants game-state and membership-state changes to
land as durable inbox entries the affected players can re-read after
the fact — push alone times out of the 5-minute ring buffer. Stage B
adds the admin-kind send matrix (owner-driven via /user, site-admin
driven via /admin) plus the lobby lifecycle hooks: paused / cancelled
emit a broadcast system mail to active members, kick / ban emit a
single-recipient system mail to the affected user (which they keep
read access to even after the membership row is revoked, per item 8).
Migration relaxes diplomail_messages_kind_sender_chk so an owner
sending kind=admin keeps sender_kind=player; the new
LifecyclePublisher dep on lobby.Service is wired through a thin
adapter in cmd/backend/main, mirroring how lobby's notification
publisher is plumbed today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 28 of ui/PLAN.md needs a persistent player-to-player mail
channel; the existing `mail` package is a transactional email
outbox and the `notification` catalog is one-way platform events.
Stage A lands the schema (diplomail_messages / _recipients /
_translations), a single-recipient personal send/read/delete
service path, a `diplomail.message.received` push kind plumbed
through the notification pipeline, and an unread-counts endpoint
that drives the lobby badge. Admin / system mail, lifecycle hooks,
paid-tier broadcast, multi-game broadcast, bulk purge and language
detection / translation cache come in stages B–D.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces per-sha cancel-in-progress (which fired spurious self-cancels) with a singleton queueing group. ui-test #74 (push) and #75 (pull_request) both green at ~2m, queue-not-cancel verified.
`cancel-in-progress: true` killed run #73 even though it was the
only ui-test in its concurrency group — Gitea appears to cancel the
in-progress job on its own under that setting in some edge cases.
Switch to a singleton group with `cancel-in-progress: false`. The
new behaviour is simple queueing: only one ui-test workflow runs at
a time across the repository, the rest wait. Vite-on-:5173 cannot
collide because there is never a second ui-test alive. The wall-time
hit is bounded — ui-test is ~2 minutes — and bursts are rare enough
that queueing is cheap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Caps Playwright at 4 workers + 4 retries to absorb the host-mode flake budget, and serialises ui-test runs by head sha so push and pull_request events for the same commit cannot collide on Vite :5173.
`gitea.ref` differs between push (`refs/heads/<branch>`) and
pull_request (`refs/pull/N/head`) events even for the same commit,
so the two parallel runs land in different concurrency groups and
the Vite-on-:5173 collision is not suppressed. Switching the key to
the head sha (`gitea.event.pull_request.head.sha || gitea.sha`)
collapses both events into one bucket, leaving exactly one ui-test
alive per commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two ui-test jobs cannot coexist on the same host: Playwright's
`webServer` spec spawns `pnpm dev` on :5173, and on a host-mode
runner the port lives in the host namespace shared by every job.
ui-test #67 hit "Error: http://localhost:5173 is already used"
because a parallel job's Vite still held the port.
Two changes:
1. `concurrency: ui-test-${{ gitea.ref }}` with `cancel-in-progress:
true`. New push/PR runs against the same ref kill any earlier
ui-test before starting, so we never have two `pnpm dev`s alive
at once.
2. `pkill -f 'vite dev' || true` plus `fuser -k 5173/tcp` right
before Playwright. Defence in depth in case the concurrency
cancellation does not reap the spawned shell promptly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Under host-mode runner the default 6 workers + 1 retry consistently
land on ~7 flakies and an occasional hard fail per ui-test run
(ui-test #59 most recently). Workers share CPU and the host Docker
daemon with gitea, the long-lived dev stack, and the user's host
Caddy; the extra wall time from contention pushes individual
expectations past their timeouts.
Lower the worker cap to 4 to keep parallelism but give each worker
real CPU headroom, and raise retries to 4 so the rare slow page is
absorbed without surfacing as failure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the birthday-collision flake in TestRandomSuffixGenerator and adds an env-driven CORS allow-list on the public gateway so the dev UI on https://www.galaxy.lan can reach https://api.galaxy.lan.
Adds a `GATEWAY_PUBLIC_HTTP_CORS_ALLOWED_ORIGINS` env-driven allow-list
on the public REST server so the dev UI on https://www.galaxy.lan can
call https://api.galaxy.lan without the browser blocking the
cross-origin response. Defaults to empty (no CORS) so the production
posture stays closed.
The middleware mounts before route classification and anti-abuse, so
OPTIONS preflights never charge against per-class rate-limit buckets.
`tools/dev-deploy/docker-compose.yml` opts the dev gateway into a
single allowed origin (`https://www.galaxy.lan`); local-dev keeps the
defaults because Vite proxies through the same origin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous test asserted that no two adjacent samples from a
~10 000-element space were equal across 100 iterations. The birthday
math gives that adjacency check a ~1 % flake rate per run; with the
new gitea.lan CI volume that turned into observable random failures
(go-unit #51 on feature/enable-actions-cache hit "Should not be:
'6635'").
Replace adjacency with a distinctness floor over a wider 200-sample
draw. A stuck generator (single value) lands at 1 unique; a
256-element range lands at ~196; the natural full-range generator
lands at ~198. A floor of 150 catches the failure modes the test was
actually written to guard against and never trips on legitimate
randomness.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cache service answers on 10.200.0.1:43513 after the nftables fix. setup-go/setup-node opt back into cache: true / cache: pnpm. Cache hit verified in run #55 (ui-test on PR head).
The Gitea Actions cache service now answers on 10.200.0.1:43513
(post nftables fix on the runner side). Turn `cache: true` and
`cache: pnpm` back on so setup-go/setup-node can use it for
cross-job tarball caching on top of the host-persistent caches we
already rely on.
The setup-* actions still tolerate the cache being unavailable, so
this is reversible to `cache: false` if the service goes away again.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Avoids the zombie reserveCache retries against the unreachable Gitea Actions cache service on :43513. Host-mode runner keeps caches warm in $HOME without needing actions/cache plumbing.
`cache: true` (setup-go) and `cache: pnpm` (setup-node) make the
actions push and pull tarballs through the Gitea Actions cache
service at 192.168.0.222:43513. That endpoint currently does not
answer, so every workflow burns minutes per run on reserveCache
retries before the action gives up.
In host-mode the real caches live under the runner user's $HOME
(~/go/pkg/mod, ~/.cache/go-build, ~/.local/share/pnpm,
~/.cache/ms-playwright) and persist between jobs without any
actions/cache plumbing. Switching cache: off avoids the zombie
retries and uses the local disk caches the runner already has warm.
Reviving the cache service is a separate TODO. Until then this is
the simpler and faster baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the docker-in-docker workarounds (GIT_SSL_NO_VERIFY env, GeoIP image bake, playwright --with-deps) now that act_runner executes jobs natively on the host.
`playwright install --with-deps` shells out to `sudo apt-get install`
for the system libraries that headless browsers need. In a job
container that runs as root this is silent; on a host-mode runner the
non-interactive sudo prompts for a password, fails three times, and
the step exits 1.
Drop --with-deps. The system .so libraries are installed once on the
host via `pnpm exec playwright install-deps` (or the equivalent
apt-get incantation); workflow runs only need to fetch the browser
binaries themselves, which lives under the runner user's home and
needs no privilege.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With the runner in host-mode, compose bind-mount paths resolve to
real host paths the Docker daemon can see, so the GeoIP file no
longer needs to be baked into the backend image to survive CI. Bring
back the bind-mount of `pkg/geoip/test-data/.../mmdb`, matching how
local-dev sources it. Image now only carries the backend binary,
symmetric with the production `backend/Dockerfile`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The act_runner now executes jobs natively on the host (no per-job
container), so actions/checkout uses the host's system CA store,
which already trusts the host-Caddy root CA. The workaround that
disabled TLS verification for `git fetch` is no longer needed and
just hides legitimate cert issues if they ever appear.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switches the `name:` field on every workflow to the bulleted style:
Tests · Go (go-unit.yaml)
Tests · UI (ui-test.yaml)
Tests · Integration (integration.yaml)
Deploy · Dev (dev-deploy.yaml)
Build · Prod (prod-build.yaml)
Deploy · Prod (deploy-prod.yaml)
File names stay the same so existing path filters and any URL
references continue to work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs surfaced on the first real merge into development:
1. `${{ env.HOME }}` evaluates to empty string at the workflow stage,
so GALAXY_DEV_GAME_STATE_DIR became `/.galaxy-dev/game-state`.
Resolve in the shell instead of YAML.
2. The compose bind-mount of GeoIP2-Country-Test.mmdb referenced a
path inside the runner's workspace volume, which the host Docker
daemon cannot see — it created an empty directory and the backend
crashed with "geoip database: is a directory" in a restart loop.
Bake the file into the backend image so dev-deploy no longer needs
a bind-mount; local-dev compose still mounts it on top for swap-in
during development.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Gitea host serves https://gitea.iliadenisov.ru with a cert signed
by host-Caddy's internal CA, which the runner-image's CA bundle does
not trust. actions/checkout@v4 fails on `git fetch` as a result, so
every workflow on gitea.lan has been failing — visible only now that
we made gitea.lan the primary CI target.
Sets GIT_SSL_NO_VERIFY=true on every workflow as a quick fix. Safe in
practice because both endpoints sit on the same LAN. The long-term
fix is to bake the Caddy root CA into the runner image and drop this
env.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns the project guides with the branching/CI/environment changes
landed in the previous commits:
- CLAUDE.md: per-stage CI gate now closes against gitea.lan; describes
the main/development/feature/* flow and the workflow surface
- docs/ARCHITECTURE.md: new section 18 "CI and Environments" covering
branches, workflows, and the local-dev / dev-deploy / local-ci
triad; section numbering shifted accordingly
- tools/local-ci/README.md: marked as fallback (offline / runner
isolation only)
- tools/local-dev/README.md and ui/README.md: cross-link to
tools/dev-deploy/ for production-shaped testing
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reshapes .gitea/workflows/ around the new main ← development ←
feature/* branching model:
- go-unit.yaml — Go unit tests, runs on push/PR matching Go paths
- ui-test.yaml — narrowed to Vitest + Playwright only (Go tests now
live in go-unit.yaml)
- integration.yaml — testcontainers suite, fires on PR to
development/main and on push to development
- dev-deploy.yaml — builds the stack and (re)deploys tools/dev-deploy/
on every merge into development
- prod-build.yaml — builds prod images on push to main and uploads
docker save bundles as artifacts (30-day retention)
- deploy-prod.yaml — workflow_dispatch placeholder for the future
SSH-based rollout
ui-release.yaml is removed; its v* tag trigger is superseded by
prod-build.yaml plus the manual deploy-prod entry point.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A docker-compose stack that hosts postgres, redis, mailpit, backend,
gateway, and an app-routing Caddy. Reachable through the host Caddy at
https://www.galaxy.lan (static SPA) and https://api.galaxy.lan (REST +
gRPC). Coexists with tools/local-dev/ and tools/local-ci/ by giving
every name (compose project, container, network, volume) a distinct
galaxy-dev-* prefix.
State is persisted in named volumes; game-state lives under
${GALAXY_DEV_GAME_STATE_DIR:-$HOME/.galaxy-dev/game-state} so the
default works for a non-root runner without sudo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Legacy reports list the same `(race, className)` pair across several
roster rows; the engine likewise creates one ShipGroup per arrival.
Both the legacy parser and `TransformBattle` were keyed on shipClass
without summing — only the last row / group's counts survived, so a
protocol's destroy count appeared to exceed the recorded initial
roster. The UI worked around this with phantom-frame logic.
Both parser and engine now SUM `Number`/`NumberLeft` across rows /
groups sharing the same class; the phantom-frame workaround is gone.
KNNTS041 turn 41 planet #7 reconciles: `Nails:pup` 1168 initial −
86 survivors = 1082 destroys.
The engine's previously latent nil-map write on `bg.Tech` (would
have paniced on any group with non-empty Tech) is fixed in the same
patch — it blocked the aggregation regression test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two more KNNTS041 viewer fixes:
1. Phantom-frame fast-forward. `buildFrames` now flags every frame
whose shot landed on an already-empty defender group as
`phantom: true`. During play the BattleViewer effect detects a
phantom frame and chains a 0 ms timer to the next non-phantom,
so streaks of phantoms (the ~30 frames between shots 224 and
255, and the 401..414 stretch) collapse from "the player just
mots the timeline" into a single visual tick. Step controls and
the scrubber can still land on a phantom deliberately for
protocol inspection.
2. Final-frame layout freeze. `displayFrame` derives from the raw
`frames[i]` and, on the very last frame when `activeRaceIds`
shrinks vs the penultimate frame (the killing blow eliminates a
race), substitutes the penultimate's `remaining` and
`activeRaceIds` while keeping the current `shotIndex` and
`lastAction`. The result: the surviving cluster no longer
reflows onto the planet ring on the very last shot — the user
sees the killing line + defender flash rendered against the
picture they saw a moment earlier.
Tests: `phantom-destroy clamp` case extended with `frame.phantom`
flag assertions across the protocol; 644 Vitest cases stay green,
4 Playwright `battle-viewer` cases stay green.
Docs: `ui/docs/battle-viewer-ux.md` documents the fast-forward
behaviour and the final-frame freeze.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Layout reshuffle so the scene captures the maximum viewer area:
- Header collapses three rows into one: `back to map` / `back to
report` on the left, the centred title `Battle on planet <name>
(#<number>)` (new i18n key `game.battle.header_title`), and the
frame counter on the right. The wrapper `.active-view` no longer
renders its own back-row; routes flow through props.
- Viewer drops the `max-width: 880px` cap so on a wide monitor the
scene scales up across the full active-view-host.
- A drag-seek `<input type="range">` sits between the scene and the
controls; dragging pauses playback and lands `frameIndex` on the
chosen shot.
- Speed control is one cycling button: `1x → 2x → 4x → 6x → 1x`.
The label shows the current speed; the new 6x adds a 67 ms frame
interval for skimming a long timeline.
- The text protocol log is now collapsible behind a `Log ▲▼`
toggle in the controls bar. The toggle is its own button; the
default state stays expanded. Collapsing the log hands the
remaining height to the scene.
- Numerical list markers (`1. 2. 3.`) are dropped from the log;
`list-style: none` keeps each row visually clean.
Static cluster + visibility filter:
- `staticBucketsByRace` now locks bucket order, mass, radius and
local Vogel-spiral positions for the lifetime of the viewer; it
only re-derives when `report` or the wasm `core` change.
- `renderedByRace` overlays the per-frame `remaining` map and drops
buckets whose `numLeft` hits zero. The surviving buckets keep
their slots, so a class emptying never reshuffles the cluster —
the empty bucket simply disappears.
- A shot whose attacker or defender bucket is no longer visible
draws no line (phantom shots into already-empty buckets are
silently skipped, matching the user expectation that pup at 0
should stop attracting fire visually).
- Race label clamps to a minimum y inside the SVG viewport so
three-or-more-race layouts with a north anchor never clip the
top race name off-canvas.
Duel layout (user suggestion):
- `layoutRaces` rotates the radial start angle by 90° when only
two participants remain, so race 0 lands at 9 o'clock and race 1
at 3 o'clock. The pair faces off horizontally; neither label
pushes against the SVG top edge. The existing test for two-race
positions is updated accordingly.
Tests: the existing `layoutRaces` two-race case is rewritten for
the horizontal duel; the `game-shell-stubs` battle case checks the
loading placeholder (back buttons now live in the loaded viewer,
not the wrapper). 644 Vitest cases stay green; 4 Playwright
battle-viewer cases stay green.
Docs: `ui/docs/battle-viewer-ux.md` documents the static cluster /
visibility filter, the duel layout, the scrubber, the cycling
speed button and the collapsible log.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nine BattleViewer refinements from the latest review pass:
1. Mass radii were uniform in synthetic mode because
`+layout.svelte` skipped `loadCore()` on the synthetic branch.
The wasm bridge to `pkg/calc/ship.go` now boots in both modes
so `computeBattleGroupMass` resolves a real FullMass and
`radiusForMass` produces a per-battle scale.
2. Phantom-destroy clamp in `buildFrames`. Legacy emitters
(KNNTS041 planet #7) log many more `Destroyed` lines against a
group than the group's initial population — at frame 406 of
2317 the race totals previously hit zero on phantom shots and
the scene blanked while playback continued silently. We now
only shrink the per-group remaining count and the race totals
when the group still has ships. The line still draws on
phantom frames; only the counters stay sane.
3. Vogel sunflower positions are now reassigned by inward dot
product before being handed to ranks: the rank-0 bucket — the
one with the largest initial ship count — always lands at the
most-inward spiral slot. The previous quarter-step anchor bias
was too weak; ranks r ≥ 2 routinely overtook rank-0 toward
the planet. The anchor offset is gone.
4. Bucket order inside a cluster is locked at battle start by
each bucket's *initial* ship count (`num`), not its live
`numLeft`. The position of every class circle stays put for
the whole battle; only the label number changes as ships die.
5. Shot line + defender flash blink on a per-frame timer during
play. The line stays on for the first 90 % of frame duration,
off for the last 10 %, so two consecutive shots from the same
attacker on the same defender look like two distinct pulses.
On pause the line and flash stay drawn for inspection.
6. The defender's class circle now flashes red (destroyed) or
green (shielded) in sync with the shot line, so the eye
catches *who* was hit, not just where the line lands.
7. Battle log rows are buttons. Click / Enter / Space pauses
playback and seeks to that shot. The list also auto-scrolls
the current row into view so the highlight does not race off
the bottom on long battles.
8. Race labels now sit above the cloud's bounding top instead of
a fixed offset, so a dense cluster does not swallow its own
race name.
9. Planet glyph + label switch to neutral grey
(`#2a2f40` / `#4a5066` / `#6d7388`), keeping the planet "in the
background" rather than competing with the combatants.
Step-back icon switched to `◀︎◀︎` to mirror step-forward.
Tests: two new Vitest cases cover the phantom-destroy clamp
(single-race wipe, mixed-class race survives a class wipe). The
existing 642 Vitest tests stay green; all four `battle-viewer`
Playwright cases pass.
Docs: `ui/docs/battle-viewer-ux.md` rewrites the cluster section
(locked order + Vogel reassignment), adds Playback Details (blink
+ flash semantics), and a Phantom Destroys section explaining the
clamp.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three Phase-27 BattleViewer refinements on top of the radial scene:
1. Height fit. The viewer is pinned to `calc(100dvh − 80px)` so it
never pushes the in-game shell past the viewport. `.active-view`
gains `overflow: hidden` + flex column; `.viewer` becomes a
`flex: 1` child; the always-visible text log shrinks to a 30 dvh
ceiling with its own scroll. A global `body { margin: 0 }`
reset (added to `app.html`) plugs the 16 px the browser's
default body margin used to leak.
2. Mass-based ship-class circles. New `lib/battle-player/mass.ts`
carries the radius formula and the per-battle FullMass compute:
`MIN_RADIUS + (MAX_RADIUS − MIN_RADIUS) * sqrt(mass / max)`,
clamped to `[6, 24] px`. FullMass goes through the existing
wasm bridge (`emptyMass` → `carryingMass` → `fullMass`) — no
new wire fields. The viewer page resolves a
`(race, className) → ShipClassRef` lookup from the parent
GameReport's `localShipClass` + `otherShipClass` tables and
passes it to the viewer via context. Unknown class or
degenerate (weapons/armament) params fall back to MAX_RADIUS
so the bucket stays visible.
3. Cloud cluster layout. Cluster key shifts from per-group
`g.key` to `(raceId, className)` so tech-variants of the same
hull collapse into one visual bucket. The horizontal
classCircleX row is replaced by a Vogel sunflower spiral in
the local `(u, v)` basis — `u` points from the race anchor to
the planet, `v` is `u` rotated 90° clockwise. Buckets are
sorted by NumberLeft desc; the cluster anchor is pushed inward
by a quarter step so rank-0 sits closest to the planet. The
step is adaptive (`min(baseStep, MAX_CLUSTER_RADIUS / sqrt(N))`)
so clusters with many classes do not spill into neighbours.
Tests:
- Vitest: `radiusForMass` covering zero / max / quarter-mass /
out-of-range cases (6 cases).
- Playwright: new `battle-viewer.spec.ts` case asserts
`document.documentElement.scrollHeight - window.innerHeight ≤ 4`
at a 1280×720 desktop viewport. The existing fixture gains
`localShipClass` + `otherShipClass` so the lookup has data to
render proportional circles.
Docs: `ui/docs/battle-viewer-ux.md` rewrites the "Radial scene"
section (cloud layout, mass-based radius, height fit) and adds
a "Height fit" subsection. `docs/FUNCTIONAL.md` §6.5 (+ ru
mirror) get the one-line story about per-mass sizing, cluster
aggregation, and the viewport-locked layout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Side activity on top of Phase 27: the legacy-report tool now extracts
the "Battle at (#N) Name" / "Battle Protocol" blocks the parser used
to skip. Both the per-battle summary (Report.Battle: []BattleSummary)
and the full BattleReport (rosters + protocol) flow through.
Parser:
- new sectionBattle / sectionBattleProtocol states, with handle()
trapping the per-race "<Race> Groups" sub-headers so the roster
stays attributed to the right race;
- parseBattleHeader extracts (planet, planetName) from
"Battle at (#NN) <Name>";
- parseBattleRosterRow maps the 10-token row into
BattleReportGroup; column 8 ("L") is NumberLeft, confirmed against
KNNTS fixtures;
- parseBattleProtocolLine counts shots and builds
BattleActionReport entries from the 8-token "X Y fires on A B :
Destroyed|Shields" lines;
- flushPendingBattle finalises a battle on next "Battle at" or any
top-level section change and appends both the summary and the
full report;
- syntheticBattleID(idx) + syntheticBattleRaceID(name) synthesise
stable UUIDs in dedicated namespaces so re-runs produce
byte-identical JSON.
Parse() signature widens to (Report, []BattleReport, error); the
single caller — the CLI — is updated.
CLI emits a v1 envelope:
{ "version": 1, "report": <Report>, "battles": { <uuid>: <BR>, ... } }
Bare-Report JSONs still load on the UI side for backward compat.
UI synthetic loader: loadSyntheticReportFromJSON detects the v1
envelope, decodes the report as before, and forwards every battle
through registerSyntheticBattle so the Battle Viewer resolves any
UUID offline. Pre-envelope JSON files (no `version` field) still
load — the battle registry stays empty for them.
Docs: legacy-report README moves Battles from "Skipped" to
in-scope, documents the envelope and UUID namespaces;
docs/FUNCTIONAL.md §6.5 (and the ru mirror) note that synthetic
mode is now end-to-end via the envelope.
Tests:
- TestParseBattles covers two battles with full rosters,
per-shot destroyed/shielded mapping, NumberLeft from column 8,
deterministic UUIDs across re-parses, and proves a trailing
top-level section still parses (battle state closes cleanly);
- smokeWant gains a battles count; runSmoke cross-checks
BattleSummary ↔ BattleReport alignment (id/planet/shots);
- all six real-fixture smoke tests pinned to their `Battle at`
counts (28, 79, 56, 30, 83, 57);
- Vitest covers the synthetic-report envelope path (battles
forwarded, missing-battles tolerated, bare-Report backward
compat);
- KNNTS041.json regenerated against the new parser (existing
diff was stale w.r.t. Phase 23 anyway; this commit brings it
in line with the v1 envelope).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Doc-only nit; triggers a CI rerun on the workflow's path filter to
verify the new Monitor permission lets local-CI polling run without
prompts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Engine wire change: Report.battle switched from []uuid.UUID to
[]BattleSummary{id, planet, shots} so the map can place battle
markers without N extra fetches. FBS schema + generated Go/TS
regenerated; transcoder + report controller updated; openapi
adds the BattleSummary schema with a freeze test.
Backend gateway forwards engine GET /api/v1/battle/:turn/:uuid as
/api/v1/user/games/{game_id}/battles/{turn}/{battle_id} (handler
plus engineclient.FetchBattle, contract test stub, openapi spec).
UI:
- BattleViewer (lib/battle-player/) is a logically isolated SVG
radial scene that consumes a BattleReport prop. Planet at the
centre, races on the outer ring at equal angular spacing, race
clusters by (race, className) with <class>:<numLeft> labels;
observer groups (inBattle: false) are not drawn; eliminated
races drop out and survivors re-distribute on the next frame.
- Shot line per frame: red on destroyed, green otherwise; erased
on the next frame. Playback controls: play/pause + step ± +
rewind + 1x/2x/4x speed (400/200/100 ms per frame).
- Page wrapper (lib/active-view/battle.svelte) loads BattleReport
via api/battle-fetch.ts; synthetic-gameId prefix routes to a
fixture loader, otherwise REST through the gateway. Always-
visible <ol> text protocol satisfies the accessibility ask.
- section-battles.svelte links every battle UUID into the viewer.
- map/battle-markers.ts: yellow X cross of 2 LinePrim through the
corners of the planet's circumscribed square (stroke width
clamps from 1 px at 1 shot to 5 px at 100+ shots); bombing
marker is a stroke-only ring (yellow when damaged, red when
wiped). Wired into state-binding.ts; click handler dispatches
battle clicks to the viewer and bombing clicks to the matching
Reports row.
- i18n keys for the viewer in en + ru.
Docs: ui/docs/battle-viewer-ux.md, FUNCTIONAL.md §6.5 + ru
mirror, ui/PLAN.md Phase 27 decisions + deferred TODOs (push
event, richer class visuals, animated re-distribution).
Tests: Vitest unit (radial layout + timeline frame builder +
marker stroke formula + marker primitives), Playwright e2e for
the viewer (Reports link → viewer, playback step, not-found),
backend engineclient FetchBattle (200 / 404 / bad input), engine
openapi freezes (BattleReport, BattleReportGroup,
BattleActionReport, BattleSummary, Report.battle items).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Split GameStateStore into currentTurn (server's latest) and viewedTurn
(displayed snapshot) so history excursions don't corrupt the resume
bookmark or the live-turn bound. Add viewTurn / returnToCurrent /
historyMode rune, plus a game-history cache namespace that stores
past-turn reports for fast re-entry. OrderDraftStore.bindClient takes
a getHistoryMode getter and short-circuits add / remove / move while
the user is viewing a past turn; RenderedReportSource skips the order
overlay in the same case. Header replaces the static "turn N" with a
clickable triplet (TurnNavigator), the layout mounts HistoryBanner
under the header, and visibility-refresh is a no-op while history is
active.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend now owns the turn-cutoff and pause guards the order tab
relies on: the scheduler flips runtime_status between
generation_in_progress and running around every engine tick, a
failed tick auto-pauses the game through OnRuntimeSnapshot, and a
new game.paused notification kind fans out alongside
game.turn.ready. The user-games handlers reject submits with
HTTP 409 turn_already_closed or game_paused depending on the
runtime state.
UI delegates auto-sync to a new OrderQueue: offline detection,
single retry on reconnect, conflict / paused classification.
OrderDraftStore surfaces conflictBanner / pausedBanner runes,
clears them on local mutation or on a game.turn.ready push via
resetForNewTurn. The order tab renders the matching banners and
the new conflict per-row badge; i18n bundles cover en + ru.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TestBuildClientPushEventCoversCatalog required every catalog kind to
encode through a FlatBuffers `preMarshaledEvent`. game.turn.ready
intentionally rides on the JSON fallback because its payload is just
`{game_id, turn}` and the only consumer (Phase 24 UI handler) parses
JSON inline. Make the policy explicit through a jsonFriendlyKinds
allow-list so the test still asserts each kind is covered and a future
producer that picks the wrong encoding fails loudly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires the gateway's signed SubscribeEvents stream end-to-end:
- backend: emit game.turn.ready from lobby.OnRuntimeSnapshot on every
current_turn advance, addressed to every active membership, push-only
channel, idempotency key turn-ready:<game_id>:<turn>;
- ui: single EventStream singleton replaces revocation-watcher.ts and
carries both per-event dispatch and revocation detection; toast
primitive (store + host) lives in lib/; GameStateStore gains
pendingTurn/markPendingTurn/advanceToPending and a persisted
lastViewedTurn so a return after multiple turns surfaces the same
"view now" affordance as a live push event;
- mandatory event-signature verification through ui/core
(verifyPayloadHash + verifyEvent), full-jitter exponential backoff
1s -> 30s on transient failure, signOut("revoked") on
Unauthenticated or clean end-of-stream;
- catalog and migration accept the new kind; tests cover producer
(testcontainers + capturing publisher), consumer (Vitest event
stream, toast, game-state extensions), and a Playwright e2e
delivering a signed frame to the live UI.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the Phase 10 report stub with a scrollable orchestrator that
renders every FBS array as a dedicated section (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). A sticky table of contents (a <select> on mobile),
"back to map" affordance, IntersectionObserver-driven active-section
highlight, and SvelteKit Snapshot-based scroll save/restore round out
the view.
GameReport gains six new fields (players, otherScience, otherShipClass,
battleIds, bombings, shipProductions); decodeReport, the synthetic-
report loader, the e2e fixture builder, and EMPTY_SHIP_GROUPS extend
in lockstep. ~90 new i18n keys land in en + ru together.
The legacy-report parser is extended to populate the new sections from
the dg/gplus text formats (Your Sciences, <Race> Sciences, <Race> Ship
Types, Bombings, Ships In Production). Ships-in-production prod_used
is derived through a new pkg/calc.ShipBuildCost helper; the engine's
controller.ProduceShip refactors to call the same helper without any
behaviour change (engine tests stay unchanged and green). Battles
remain in the parser's Skipped list — the legacy text carries no
stable per-battle UUID.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Clicking the already-active WAR/PEACE button still appended a
\`setDiplomaticStance\` whose \`relation\` matched the row's current
value. The engine would accept the duplicate harmlessly, but the
order tab inflates with rows that say nothing and every auto-sync
re-ships the redundant payload. Compare against the overlayed
stance (so a queued-but-not-applied change suppresses a re-click
that matches the *intended* state, not just the server snapshot)
and short-circuit when they agree. Mirrors the vote picker, which
already had the same guard.
vitest.config.ts: \`mergeConfig\` refuses callback-form base
configs, so resolve \`vite.config.ts\`'s callback with the test
context first and merge the plain object. Surfaced after the
\`loadEnv\` migration switched the root config to the callback
form.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`vite.config.ts` read `VITE_DEV_PROXY_TARGET` /
`VITE_DEV_GRPC_PROXY_TARGET` straight from `process.env`, so the
gateway-override knob only worked when the variable was exported in
the shell that ran `pnpm dev`. Per-developer `.env.development.local`
files (the documented way to override) were silently ignored by the
config: Vite auto-populates `import.meta.env` for client code from
those files, but the config itself runs in Node and has to call
`loadEnv` explicitly.
Switch the config to the function-form + `loadEnv` so every
`VITE_*` entry in any `.env*` file reaches both client code and the
config. Now adding `VITE_DEV_PROXY_TARGET=http://localhost:18080` to
`.env.development.local` actually retargets the proxy, no shell
gymnastics required.
While there, introduce `VITE_DEV_HOST` as an opt-in for wider
listener binding: unset (default) keeps Vite's loopback-only
behaviour; `true`/`1`/`yes` flips to "all interfaces" (`0.0.0.0` +
IPv6); any other string is passed through verbatim to pin a
specific LAN address. Useful when reaching the dev server through
SSH port forwarding, a VM, or a container needs a non-loopback
bind, and intentionally opt-in so an unattended `pnpm dev` on a
laptop never exposes the unauthenticated dev surface to the LAN by
accident.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The compose stack hard-coded host ports (postgres 5433, redis 6380,
mailpit 8025, gateway REST 8080, gateway gRPC 9090) — fine for a
clean dev machine, painful when those ports collide with other
services on the same host (e.g. a `crowdsec` sitting on
127.0.0.1:8080 or a Prometheus instance on :9090).
Every host-port mapping is now `${LOCAL_DEV_*_PORT:-<old-default>}`,
so the defaults match prior behaviour for everyone and a per-host
override is a single environment variable away. `.env` carries the
overrides as commented-out lines so the customisation surface is
discoverable without grepping the compose file. README's
"Port 8080 already in use" troubleshooting entry now points at the
new variables and the optional `docker-compose.override.yml`
workflow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the Races View in the in-game shell. The table lists every
non-extinct other race with tech levels (percent), totals,
planets, votes received, and a per-row WAR | PEACE segmented
control. A single vote-recipient slot above the table queues a
`CommandRaceVote`; per-row buttons queue `CommandRaceRelation`.
Both commands flow through the existing order draft store with
collapse-by-acceptor (stance) and singleton (vote) rules.
`GameReport` widens with `races`, `myVotes`, `myVoteFor`; the
decoder walks `report.player[]` once for the richer projection.
The optimistic overlay flips stance and vote target immediately;
`votesReceived`, `myVotes`, and the alliance summary stay
server-authoritative — alliance grouping and the 2/3 victory
check are tallied on the server at turn cutoff and explicitly
not surfaced client-side (`rules.txt` keeps foreign races'
outgoing vote targets private).
Includes Vitest component coverage of stance and vote
collapse rules + a Playwright e2e that drives both commands
through the dispatcher route and verifies the gateway saw the
expected `CommandRaceRelation` / `CommandRaceVote` payloads.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The renderer-mount effect in `lib/active-view/map.svelte` reads
`mounted` to gate the runSerializedMount call, but the variable was
declared as a plain `let`, not `$state`. On the first navigation to
/map this is benign: the effect's first pass returns early (gameState
still hydrating, `report` null), and once `report` arrives the
effect re-fires — by which point `onMount` has already flipped
`mounted = true`.
On every subsequent return to /map the report is already loaded by
the long-lived gameState in the layout. The effect therefore makes
exactly one pass on the freshly-mounted component, gates on
`mounted === false` (the brand-new instance has not run `onMount`
yet), and never wakes up again because no tracked state changes
afterwards. Symptom: black canvas — fresh DOM, no mount-error
overlay, but Pixi never rebuilt the world on the new canvas.
Convert `mounted` to `$state(false)` so flipping it true inside
`onMount` triggers the effect's second pass, which now finds all
preconditions satisfied and proceeds to `runSerializedMount`. The
detailed lifecycle reasoning is preserved as a code comment so the
next reader can see why this one variable must be reactive.
Add tests/e2e/map-roundtrip.spec.ts: navigates /map → {report,
ship-class designer, science designer, mail} → /map for each
non-map view, then asserts the renderer republished primitives onto
the DEV `__galaxyDebug.getMapPrimitives()` surface. The pre-fix
build failed every variant; the patch lands all four green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Commit 408097e ('feat: move func to calc package') moved a helper
into pkg/calc and made pkg/util/map.go import galaxy/calc, but the
local-dev backend / gateway Dockerfiles never picked up the new
module. The synthesised go.work has no replace directive for
galaxy/calc and the build context never copies pkg/calc, so any
backend / gateway image rebuild fails with
galaxy/calc@v0.0.0: malformed module path "galaxy/calc": missing
dot in first path element
Add the missing COPY, the matching `use ./pkg/calc` line, and the
`galaxy/calc v0.0.0 => ./pkg/calc` replace to both local-dev
Dockerfiles. The local-dev stack now rebuilds cleanly and the
auto-heal flow (prune-broken-engines + pre-bootstrap reconciler
tick) finishes by spawning a fresh engine container for the new
sandbox game.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`make rebuild` runs `compose build --no-cache backend gateway` plus
a fresh `up -d --wait`. It must therefore also reap any engine
container whose bind-mount source went away during host downtime,
otherwise the new backend image boots into a stack with the same
orphan that triggered the heal flow in the first place.
Also extend the troubleshooting note: pulling the heal-cycle fix
requires one explicit `make rebuild` so the backend image picks up
the pre-bootstrap reconciler tick. Without that, `make up` runs
the new Makefile target but the legacy backend cannot follow
through, and the developer is left staring at a `cancelled`
sandbox with no running replacement.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After a host reboot macOS clears /private/tmp, so the per-game
bind-mount source under /tmp/galaxy-game-state/<uuid> vanishes and
Docker refuses to restart the long-lived engine container under
`restart: unless-stopped`. The container then sits in `exited` state
and the dev sandbox is unreachable until the developer manually rms
it and runs `make up` twice.
Fix `make -C tools/local-dev up` to heal this in one cycle:
1. `prune-broken-engines` (new make target wired into `up`) walks
every container labelled `galaxy-game-engine` and removes the ones
not in `running` / `restarting` state. Healthy long-lived
containers survive normal up/down cycles untouched.
2. The backend now runs a single reconciliation pass before the
dev-sandbox bootstrap (`Reconciler().Tick(ctx)` in main.go).
Without it, bootstrap would reuse the soon-to-be-cancelled game
that the periodic ticker is about to mark `removed`. The pre-tick
cascades the orphan runtime row through markRemoved → lobby
cancel before bootstrap purges terminal sandbox games and creates
a fresh one — so a single `make up` lands a working sandbox with
a brand new state directory.
README troubleshooting section documents the symptom and the
recovery so the bind-mount-source error message is greppable.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fixes a black-canvas regression on /map after creating a science in
DEV: when Vite hot-reloads the decoder bump that adds the
`localScience` field, the live in-memory `gameState.report` keeps
its older shape with no such field, so the overlay's
`[...report.localScience]` throws inside the reactive getter and
silently aborts the map view's `$effect`. The fix wraps the spread
and the final return in `?? []` defaults — and matches the
ship-class branches for symmetry — so the overlay stays well-defined
for any partial report shape upstream consumers may carry across an
HMR boundary.
Also adds order-overlay regression tests covering the createScience /
removeScience branches plus the explicit HMR-stale shape, and a
Playwright e2e (sciences-map-regress.spec.ts) replaying the
user-reported flow: /map → /designer/science → save → /map, asserting
no map-mount-error overlay and no console errors.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lights up the player-defined sciences feature: a table view with sort
and filter, a designer with four percent inputs and a strict
sum-equals-100 gate, and a Research-sub-row integration so the
planet production picker lists the user's sciences alongside the
four tech buttons. Phase 21 decisions are baked back into ui/PLAN.md
(no UpdateScience on the wire — write-once via createScience +
removeScience; percentages instead of fractions; sciences live under
the existing Research segment).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Send joins Modernize / Dismantle / Transfer as a lockable command:
once any of the four lands in the draft for a group, every action
button on its inspector is disabled with a "command pending"
tooltip and the banner names the queued kind. Load / Unload /
Split / Join Fleet stay non-locking — they stack legitimately on
the engine side.
Two dashed overlays now run alongside the cargo-route arrows:
- Yellow dashed track for own in-space groups, drawn from the
origin planet to the destination (matches the in-space point
colour so eye reads both as one entity).
- Green dashed track for every wire-valid sendShipGroup command
in the order draft, drawn from the source group's orbit planet
to the chosen destination. Disappears when the command is
removed from the order tab, when the engine rejects it, or
when the group has left orbit (in-space track replaces it).
Both tracks are wrap-aware via torusShortestDelta and never
participate in hit-test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Send no longer carries a destination control inside the form: a
click on the action drops the inspector straight into map-pick
mode, and the form (ship count + confirm) only mounts after the
player chooses a destination. Cancelling the picker leaves no
form behind.
A queued Modernize / Dismantle / Transfer for a given group
locks every action button on its inspector and surfaces a banner
that points the player at the order list. Cancelling the queued
entry from the order tab releases the lock on the next render —
the derivation watches draft.commands directly. Send / Load /
Unload / Split / Join Fleet do not lock; Send is naturally
followed by an out-of-orbit state at turn cutoff, the rest can
stack legitimately.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Eight ship-group operations land on the inspector behind a single
inline-form panel: split, send, load, unload, modernize, dismantle,
transfer, join fleet. Each action either appends a typed command to
the local order draft or surfaces a tooltip explaining the
disabled state. Partial-ship operations emit an implicit
breakShipGroup command before the targeted action so the engine
sees a clean (Break, Action) pair on the wire.
`pkg/calc.BlockUpgradeCost` migrates from
`game/internal/controller/ship_group_upgrade.go` so the calc
bridge can wrap a pure pkg/calc formula; the controller now
imports it. The bridge surfaces the function as
`core.blockUpgradeCost`, which the inspector calls once per ship
block to render the modernize cost preview.
`GameReport.otherRaces` is decoded from the report's player block
(non-extinct, ≠ self) and feeds the transfer-to-race picker. The
planet inspector's stationed-ship rows become clickable for own
groups so the actions panel is reachable from the standard click
flow (the renderer continues to hide on-planet groups).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two follow-up fixes after the initial Phase 19 landing:
1. The IncomingGroup dashed trajectory was drawn between raw
(x1, y1) and (x2, y2) world coordinates. On torus wrap mode
this took the long way around when origin and destination
sat near opposite seams. The line now picks endpoints via
`torusShortestDelta` so the segment crosses the seam when
that's the shorter visual path. The interpolated clickable
point follows the same unwrapped vector. The same helper
fixes the in-hyperspace position for local / foreign groups.
2. On-planet local and foreign groups previously rendered as
small offset points next to every populated planet, which
turned the canvas into noise as soon as a player held more
than a handful of planets. The map no longer renders any
in-orbit group; the planet inspector grows a compact
"stationed ship groups" subsection
(`lib/inspectors/planet/ship-groups.svelte`) that lists
each in-orbit group as a row of `<race> · <class> · <count>
ships · <mass>`. Race attribution: LocalGroup → the player's
race, OtherGroup on a foreign-owned planet → the planet's
owner, OtherGroup elsewhere → "foreign" placeholder. Rows
are non-interactive in Phase 19; Phase 21+ will deep-link
into the ship-groups table view with a (planet, race) filter.
Tests:
- `state-binding-groups.test.ts` swaps the on-planet rendering
expectation for the new "no map primitive" rule, and adds a
regression that asserts the incoming line crosses the torus
seam via `torusShortestDelta`.
- new `inspector-planet-ship-groups.test.ts` covers row
composition, the destination-mismatch filter, the
in-hyperspace exclusion, the foreign-planet owner fallback,
and the empty-state collapse.
- `inspector-planet.test.ts` and `inspector-ship-group.spec.ts`
pick up the new prop chain (`localShipGroups`,
`otherShipGroups`, `localRace`).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The pre-existing `Deltas` helper used the height to wrap the x-axis,
which silently produced wrong values on any rectangular galaxy
(`w != h`). Square galaxies — the only configuration the engine
ships today — masked the bug, so it stayed in tree.
`Deltas` is now a thin wrapper around the new `ShortestDelta(a, b,
size)`, which returns the signed per-axis shortest delta on a 1-D
circle (range `(-size/2, size/2]`). The signed flavour is what the
Phase 19 ship-group renderer needs to draw an IncomingGroup
trajectory across the torus seam; `Deltas` continues to return the
pair of absolute deltas for distance computation.
Adds `pkg/calc/map_test.go` with table-driven coverage for both
helpers, including a regression that exercises the rectangular
case the bug was hiding behind, and the half-circumference
tie-break.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The root +layout.svelte redirects anonymous traffic to /login, so a
fresh CI browser context never gets to render the lobby's
DEV-gated synthetic-report section — the previous spec relied on
leftover session state in the local browser and silently broke on
clean runners (local-ci run 23).
Bootstrap the session through /__debug/store before navigating to
/lobby: load a device keypair, set a deterministic device session
id. The synthetic flow itself still bypasses the gateway entirely;
the seed only ensures `session.status === "authenticated"` so the
layout guard lets the lobby through.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes Phase 19's UI surface. The inspector dispatches on the
selection variant: local / other groups render class, count, the
four tech levels, mass, cargo (type + amount when loaded),
location (planet name on-orbit, from/to/distance in hyperspace),
and — for local groups only — fleet membership + state. Incoming
groups surface origin / destination / distance / speed and the
inline ETA = ceil(distance / speed); zero speed collapses to the
designer's existing "—" placeholder. Unidentified groups render
just the (x, y) coordinates and the no-data hint, mirroring the
unidentified planet treatment.
Layout / inspector-tab plumbing:
- inspector-tab.svelte derives selectedShipGroup against the
rendered report and mounts <ShipGroup /> when the planet
branch doesn't match. Stale refs (an index that no longer
resolves after a turn refresh) collapse cleanly to the empty
state.
- +layout.svelte mounts <ShipGroupSheet /> alongside the
existing planet sheet on mobile; both share the
`effectiveTool === "map"` guard and clear-on-close.
i18n: en + ru both grow ~30 keys under
`game.inspector.ship_group.*`. Adding a key to one without the
other is a TS error (TranslationKey is `keyof typeof en`), so the
Russian mirror stays mandatory.
Tests:
- inspector-ship-group.test.ts exercises every variant —
on-planet local, in-hyperspace local, cargo-loaded local,
foreign, incoming with ETA, incoming with zero speed,
unidentified, plus the missing-planet `#NN` fallback.
- tests/e2e/inspector-ship-group.spec.ts is a smoke spec that
drives the DEV-only synthetic-report loader from /lobby
through navigation to /games/synthetic-XXX/map.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires Phase 19's data and rendering layers without yet adding the
inspector UI:
- game-state.ts grows ReportLocalShipGroup / ReportOtherShipGroup
/ ReportIncomingShipGroup / ReportUnidentifiedShipGroup /
ReportLocalFleet types and walks the matching FlatBuffers
vectors (LocalGroup, OtherGroup, IncomingGroup,
UnidentifiedGroup, LocalFleet) inside decodeReport. The Tech
map is folded into the fixed-shape ShipGroupTech struct;
cargo strings normalise to the closed CargoLoadType | "NONE"
union; UUIDs come back as canonical 36-char strings.
- synthetic-report.ts mirrors the new fields so the DEV-only
lobby loader can feed JSON produced by legacy-report-to-json
straight into the live UI surface.
- selection.svelte.ts widens its discriminated union with a
`kind: "shipGroup"` branch carrying a ShipGroupRef
(local UUID / other / incoming / unidentified by index).
- world.ts adds Style.strokeDashPx and render.ts.drawLine
honours it via manual segmentation (PixiJS v8 has no native
dash API). Ignored on points and circles.
- state-binding.ts now returns { world, hitLookup }: the
hit-lookup map keys every primitive id back to a concrete
HitTarget so the click handler can dispatch to selectPlanet
or selectShipGroup. Ship-group primitives live in a separate
ship-groups.ts that emits one point per local / other /
unidentified group, plus a dashed origin→destination line +
clickable point per incoming group. Position is interpolated
along the trajectory for in-hyperspace groups.
- map.svelte threads the hitLookup into handleMapClick.
Vitest:
- tests/helpers/empty-ship-groups.ts exposes EMPTY_SHIP_GROUPS
so existing fixtures can spread the new five empty arrays
without enumerating every field.
- state-binding-groups.test.ts covers each group variant's
primitive geometry and lookup correctness.
- All previously-existing fixture builders pick up the spread
so GameReport stays a complete object.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The parity rule from ui/PLAN.md says every UI phase that decodes a
new Report field must extend the legacy converter in lockstep.
Phase 19 brings ship groups (LocalGroup / OtherGroup /
UnidentifiedGroup / IncomingGroup) and LocalFleet onto the wire-
compatible UI surface; this commit teaches
tools/local-dev/legacy-report to populate the three sections that
exist in the legacy text format:
- "Your Groups" → []LocalGroup. Cargo type, load, fleet name,
state, on-planet vs hyperspace position (origin / range) all
decoded; LocalGroup.ID is synthesised deterministically from
the per-report group index so re-running the converter
produces byte-identical JSON. Speed is left zero — the legacy
table doesn't expose it.
- "Your Fleets" → []LocalFleet. Origin / range / state mirror
the row layout used by Killer / Tancordia variants; gplus's
state-less rows still resolve.
- "Incoming Groups" → []IncomingGroup. Origin / destination
names — and `#NN` by-id references — resolve against the
parsed planet tables. Because the section can land before
"Your Planets" in some engines, group / fleet / incoming rows
are buffered and resolved in `parser.finish` after every
planet is known.
Battles, OtherGroup (only ever in battle rosters), and
UnidentifiedGroup stay out of scope — README.md spells out what
remains not-derivable.
Adds Killer031–033 / TSERCON_Z032–033 / Tancordia036–039 fixtures
to the dg directory and exercises three of them through new
TestParseDg{Killer031,Tancordia037,KNNTS041} smoke tests, plus
inline tests for each new section parser. Drops the stale
KNNTS039.json artefact left over from Phase 18 development.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Locks in the synthetic-report parity rule as a global "Assumptions
and Defaults" entry in ui/PLAN.md: every phase that extends the
server->UI report contract must also extend the legacy parser in
the same PR (or document in tools/local-dev/legacy-report/README.md
why the new field cannot be derived from legacy text). The Go side
already enforces shape compatibility via the pkg/model/report
import; this rule extends that mechanical guard to "did we remember
to wire the new field through".
ui/docs/testing.md grows a "Synthetic reports for visual testing"
section with the full conversion -> load -> compose loop and the
two operational gotchas (no network on synthetic ids, page reload
clears the in-memory map).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds api/synthetic-report.ts, an in-memory registry + JSON->GameReport
decoder for synthetic-mode game sessions. The lobby grows a
import.meta.env.DEV-gated "Synthetic test reports" section with a
JSON file picker; loading a file registers the decoded report under
a synthetic-<uuid> id and navigates to /games/<id>/map.
The in-game shell layout detects the synthetic id range, takes the
report straight from the registry via gameState.initSynthetic, and
deliberately skips both galaxyClient.set and orderDraft.bindClient.
Order auto-sync stays silent: scheduleSync already short-circuits on
non-UUID game ids, and without a bound client the network path is
unreachable. applyOrderOverlay continues to project locally-valid
draft commands onto the rendered report so renames / production
choices / route edits are visible immediately.
A page reload loses the in-memory entry and redirects to /lobby —
synthetic mode is a debug affordance, not a session.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A Go module under tools/local-dev/legacy-report that converts the
"dg" / "gplus" engine .REP files in tools/local-dev/reports/ into the
JSON shape of pkg/model/report.Report. The output drives a DEV-only
synthetic-mode loader on the UI lobby so the map, inspectors, and
order-overlay can be exercised against rich game states without
playing many turns end-to-end.
Scope is intentionally narrow: only the fields the UI client decodes
today (planets, players, own ship classes, header). Importing
pkg/model/report keeps the parser and the typed contract in lockstep
— any backwards-incompatible schema change breaks the tool's
compilation before it ships. The README spells out the parity rule
for extending the parser alongside future UI decoders.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires pkg/calc/ship.go into the WASM Core boundary as seven thin
wrappers (DriveEffective, EmptyMass, WeaponsBlockMass, FullMass,
Speed, CargoCapacity, CarryingMass). The ship-class designer reads
Core through a new CORE_CONTEXT_KEY populated by the in-game layout
and renders a five-row preview pane (mass, full-load mass, max
speed, range at full load, cargo capacity) that updates reactively
on every form edit and on the player's localPlayer{Drive,Weapons,
Shields,Cargo} tech levels — three of which are now decoded from
the report's Player block alongside the existing localPlayerDrive.
CarryingMass is the seventh wrapper added to the original six-function
list so that "full-load mass" composes through pkg/calc/ functions
without putting math in TypeScript.
Mirrors the validateEntityName MAX_LENGTH on the form input so the
field stops accepting characters once the limit is hit. The
validator still runs and surfaces the localised reason if a paste
overshoots; the maxlength is purely a typing-time guardrail.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds an explicit acceptance line documenting that pending Save /
Delete actions reflect on the table immediately via
applyOrderOverlay (already exercised by the new Vitest + Playwright
suites). Touches a watched path so the local-ci runner picks up the
retrigger after the previous flaky run.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Run 19 failed on a known flake in pkg/util.TestRandomSuffixGenerator
(line 289 — generator can produce the same 4-char suffix in two
consecutive draws out of 10000). Empty commit retriggers the
runner; flake is unrelated to Phase 17.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 17 lights up the ship-class table and designer active views,
extends the order-draft pipeline with createShipClass and
removeShipClass commands, and projects pending Save/Delete actions
through applyOrderOverlay so the table reflects the player's
intent before auto-sync lands. The plan is corrected in the same
patch: per game/rules.txt, ship classes are designed once and
cannot be edited — the engine has no Update command, so the UI
exposes only Create + Delete.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The cargo-route picker filtered out unidentified planets, so an
early-game player who had spotted but not surveyed a destination
could not configure a route to it — the engine has no such
restriction (`game/internal/controller/route.go.PlanetRouteSet`
only checks ownership of the origin and `util.ShortDistance(...) <=
FligthDistance`). Drop the unidentified guard and document the
contract in `cargo-routes-ux.md` plus a comment over `reachableSet()`.
Pick-mode dim now drops both alpha and tint on out-of-reach
planets so bright shapes (`STYLE_LOCAL` is `0x6dd2ff`) collapse
into a single muted gray. The single-channel `dimAlpha=0.3` was too
gentle against the dark theme — the user reported the dim wasn't
visible. Tighten to `dimAlpha=0.35 + dimTint=0x303841`; restore
both on tear-down.
Also threads through the user's `pkg/calc/race.go.FligthDistance`
addition: `calc-bridge.md` records the new Go-side reference (the
engine's `Race.FlightDistance()` already wraps it), and the picker
comment points at the canonical formula location.
Tests:
- `inspector-planet-cargo-routes.test.ts` adds two cases — a
reach-spans-every-kind case (own + foreign + uninhabited +
unidentified all picked when in range) and a successful pick to
an unidentified destination.
- All 356 vitest cases + chromium-desktop / webkit-desktop e2e
cargo-routes pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add per-planet cargo routes (COL/CAP/MAT/EMP) to the inspector with
a renderer-driven destination picker (faded out-of-reach planets,
cursor-line anchor, hover-highlight) and per-route arrows on the
map. The pick-mode primitives are exposed via `MapPickService` so
ship-group dispatch in Phase 19/20 can reuse the same surface.
Pass A — generic map foundation:
- hit-test now sizes the click zone to `pointRadiusPx + slopPx` so
the visible disc is always part of the target.
- `RendererHandle` gains `onPointerMove`, `onHoverChange`,
`setPickMode`, `getPickState`, `getPrimitiveAlpha`,
`setExtraPrimitives`, `getPrimitives`. The click dispatcher is
centralised: pick-mode swallows clicks atomically so the standard
selection consumers do not race against teardown.
- `MapPickService` (`lib/map-pick.svelte.ts`) wraps the renderer
contract in a promise-shaped `pick(...)`. The in-game shell
layout owns the service so sidebar and bottom-sheet inspectors
see the same instance.
- Debug-surface registry exposes `getMapPrimitives`,
`getMapPickState`, `getMapCamera` to e2e specs without spawning a
separate debug page after navigation.
Pass B — cargo-route feature:
- `CargoLoadType`, `setCargoRoute`, `removeCargoRoute` typed
variants with `(source, loadType)` collapse rule on the order
draft; round-trip through the FBS encoder/decoder.
- `GameReport` decodes `routes` and the local player's drive tech
for the inline reach formula (40 × drive). `applyOrderOverlay`
upserts/drops route entries for valid/submitting/applied
commands.
- `lib/inspectors/planet/cargo-routes.svelte` renders the
four-slot section. `Add` / `Edit` call `MapPickService.pick`,
`Remove` emits `removeCargoRoute`.
- `map/cargo-routes.ts` builds shaft + arrowhead primitives per
cargo type; the map view pushes them through
`setExtraPrimitives` so the renderer never re-inits Pixi on
route mutations (Pixi 8 doesn't support that on a reused
canvas).
Docs:
- `docs/cargo-routes-ux.md` covers engine semantics + UI map.
- `docs/renderer.md` documents pick mode and the debug surface.
- `docs/calc-bridge.md` records the Phase 16 reach waiver.
- `PLAN.md` rewrites Phase 16 to reflect the foundation + feature
split and the decisions baked in (map-driven picker, inline
reach, optimistic overlay via `setExtraPrimitives`).
Tests:
- `tests/map-pick-mode.test.ts` — pure overlay-spec helper.
- `tests/map-cargo-routes.test.ts` — `buildCargoRouteLines`.
- `tests/inspector-planet-cargo-routes.test.ts` — slot rendering,
picker invocation, collapse, cancel, remove.
- Extensions to `order-draft`, `submit`, `order-load`,
`order-overlay`, `state-binding`, `inspector-planet`,
`inspector-overlay`, `game-shell-sidebar`, `game-shell-header`.
- `tests/e2e/cargo-routes.spec.ts` — Playwright happy path: add
COL, add CAP, remove COL, asserting both the inspector and the
arrow count via `__galaxyDebug.getMapPrimitives()`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The desktop spec previously asserted the read-only `inspector-planet-
field-production` row for an owned planet. Phase 15 replaced that
row with the interactive production component on the local-planet
branch — the assertion now confirms the component is mounted and
the legacy field is absent.
Adds the second end-to-end command (`setProductionType`) with a
collapse-by-`planetNumber` rule on the order draft, the segmented
production-controls component on the planet inspector, the FBS
encoder/decoder pair for `CommandPlanetProduce`, and the
`localShipClass` projection on `GameReport`. Forecast number is
deferred and tracked in the new `ui/docs/calc-bridge.md`.
Phase 14's auto-sync calls `uuidToHiLo` on every layout boot. The
existing Phase 10 e2e specs use a placeholder string `test-shell`
as the game id, which throws in the FBS request encoder and
surfaced as a noisy `console.warn` plus a flaky webkit-desktop
test on the local-ci ARM runner.
`OrderDraftStore.hydrateFromServer` and `scheduleSync` now skip
when the active game id isn't a real UUID — the auto-sync path is
inert for fixture data and the placeholder-warning is gone. The
Phase 10 spec switches to a deterministic UUID
(`10101010-1010-1010-1010-101010101010`) so future Phase 14+
specs don't have to special-case it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously a cancelled / finished / start_failed sandbox game would
hang in the dev user's lobby until manually cleaned up — `make up`
would create a new running game alongside it but the dead tiles
piled up. Now backend's `devsandbox.Bootstrap` deletes every
terminal sandbox game owned by the dev user before find-or-create
runs, so the lobby always shows exactly one running tile.
Schema: `runtime_records` and `player_mappings` gain
`ON DELETE CASCADE` on their `game_id` foreign keys so a single
`DELETE FROM games` cleans every referencing row in one write.
Pre-prod migration rule applies — change goes into
`00001_init.sql`, not a new migration.
API: `lobby.Service.DeleteGame` is the new destructive helper that
backs the bootstrap purge. It bypasses the cancel-cascade-notify
pipeline; production callers must stay on the regular lifecycle.
The dev-sandbox docs in `tools/local-dev/README.md` spell out the
new behaviour.
Tests:
- backend/internal/lobby/lobby_e2e_test.go gains
`TestDeleteGameCascadesEverything` proving CASCADE works
end-to-end against a real Postgres testcontainer.
- backend/internal/devsandbox keeps its existing terminal-status
contract test; the new `purgeTerminalSandboxGames` helper rides
on the same `terminalSandboxStatus` predicate.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the manual Submit button with an auto-sync pipeline driven
by `OrderDraftStore`: every successful add / remove / move
coalesces a `submitOrder` call so the engine always mirrors the
local draft. Removing the last command sends an empty cmd[] PUT —
the engine, repo, and rest model now accept that as a valid
"player cleared their draft" state.
`hydrateFromServer` is now invoked unconditionally on game boot so
a fresh device picks up the player's stored order, and the local
cache is overwritten by the server's view (server is the source of
truth).
Header replaces the static "race ?" + turn counter with a single
headline string `<race> @ <game>, turn <n>`, sourced from the
engine's Report.race + the lobby's GameSummary.gameName + the live
turn number, with a `?` fallback while any piece is loading.
Tests:
- engine: empty PUT round-trips, repo round-trips empty Commands
- order-draft: auto-sync sends full draft on every mutation,
rejected response surfaces error sync status, rapid mutations
coalesce, server hydration overwrites cache
- order-tab: per-row status flips through the auto-sync lifecycle,
remove → empty cmd[] PUT, rejected → retry button
- inspector overlay: applied + valid + submitting all participate
in the optimistic projection
- header: live race / game / turn rendering with fall-back
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Phase 14 follow-up surfaced a footgun: `make up` reuses any
pre-built backend / gateway images and silently misses route table
or transcoder edits. Add a dedicated section to the README that
points at `make rebuild`, plus the engine-only path through
`make stop-engines` + `docker rmi`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The owner reported two symptoms after pulling the Phase 14 stack:
1. user.games.order.get answered with `unimplemented: message_type
is not routed`. The gateway/backend code was correct, but the
local-dev compose images were stale — `make rebuild` picked up
the new routes table and the symptom went away. To prevent this
class of regression from depending on docker-image freshness,
gateway/internal/backendclient/routes_test.go now asserts that
every authenticated MessageType constant declared in
pkg/model/{user,lobby,order,report} is registered, and verifies
that user.games.order.get specifically resolves to the game
command client.
2. The inspector kept the un-renamed name after a successful submit.
ui/frontend/tests/inspector-overlay.test.ts mounts the inspector
tab against a real OrderDraftStore + a stubbed GameStateStore
and walks the full happy path (add planetRename → markSubmitting
→ applied → simulate refresh) plus the integration scenario
driven through the order-tab Submit button. Both cases pass —
the underlying overlay path is reactive and resilient to a
refresh that returns the un-renamed snapshot. The original
in-browser symptom was the rebuilt-image freshness issue from
point 1; this test pins the reactive contract for future
refactors.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires the first end-to-end command through the full pipeline:
inspector rename action → local order draft → user.games.order
submit → optimistic overlay on map / inspector → server hydration
on cache miss via the new user.games.order.get message type.
Backend: GET /api/v1/user/games/{id}/orders forwards to engine
GET /api/v1/order. Gateway parses the engine PUT response into the
extended UserGamesOrderResponse FBS envelope and adds
executeUserGamesOrderGet for the read-back path. Frontend ports
ValidateTypeName to TS, lands the inline rename editor + Submit
button, and exposes a renderedReport context so consumers see the
overlay-applied snapshot.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Plumbs the map → inspector pathway: a click on a planet selects it
through the new SelectionStore, the sidebar Inspector tab swaps
its empty-state copy for a per-kind read-only field set, and a
mobile-only bottom-sheet mirrors the same content over the map.
Field projection in api/game-state.ts now surfaces every documented
planet field.
Expanding the comment so future readers know the `type` field is
informational here — no `pixi-viewport@6` plugin or local listener
switches on it, so picking any literal from the closed union works.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`MovedEvent.type` in pixi-viewport@6 is a closed union of built-in
plugin names; the prior `"manual"` value tripped svelte-check.
`"animate"` is the closest semantic match for a programmatic move
and the renderer's listeners read only `viewport`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
OrderDraftStore persists per-game command drafts in Cache; the
sidebar Order tab renders the list with a per-row delete control.
The layout passes a `historyMode` prop through Sidebar / BottomTabs
as a constant `false`, so Phase 26 only flips the source.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Even with the zoom-out clamp from cc004f9, panning still let the
user walk the camera centre out of the central tile of the 3×3
wrap layout — they would see the wrap copies one tile out and then
empty space beyond, because the renderer paints exactly nine
copies and nothing further. The fix is the standard torus trick:
treat camera coordinates modulo world dimensions. The toroidal
world looks identical at `(x, y)` and `(x mod W, y mod H)`, so
snapping the centre back into `[0, W) × [0, H)` is invisible to
the user, and the fixed 3×3 layout is then sufficient to cover
infinite pan in any direction.
Implementation:
- `src/map/torus.ts::wrapCameraTorus` — pure helper that returns
the modulo-wrapped camera (positive remainder; scale preserved).
- `src/map/render.ts` — the torus-mode path now installs a
`'moved'` listener that runs the wrap, with a re-entry guard
because `viewport.moveCenter` itself fires the same event the
listener subscribes to. The `'moved'` event is emitted by
every `pixi-viewport` plugin that moves the camera (drag,
wheel, decelerate, snap, pinch — confirmed against the v6
source) so production drag inertia and wheel-pan both trigger
the wrap.
- `src/routes/__debug/map/+page.svelte` — adds `setCameraCenter`
to `__galaxyMap`, with an explicit `viewport.emit('moved')`
after the programmatic `moveCenter` (the v6 source does not
emit `'moved'` from `moveCenter`, only plugins do; the manual
emit matches the user-drag semantics).
Tests:
- `tests/map-torus.test.ts` — Vitest unit coverage for
`wrapCameraTorus` (in-bounds noop, one tile / many tiles past
on each axis, negative inputs never return negative, scale
preserved, right/bottom edge folds to left/top, toroidal-
congruence invariant).
- `tests/e2e/playground-map.spec.ts` — torus pan regression: push
the camera to (5.4×W, 7.25×H) through the new debug entry,
assert the centre lands in the central tile and matches the
expected `(0.4×W, 0.25×H)` modulo position. Runs across all
four Playwright projects.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The renderer's torus mode laid out the world in a 3×3 grid of wrap
copies (TORUS_OFFSETS) so the user could pan past an edge without
seeing a void. Below `minScale = max(viewport/world)` the world
shrinks below the viewport along at least one axis and the wrap
copies become visible side-by-side — the user reported a 9-tile
mosaic that pans and zooms as one rigid unit. The doc explicitly
deferred the fix ("if profiling ever reveals that users do this");
real usage is the trigger.
Apply `clampZoom({ minScale })` in both modes; torus still keeps
free pan (no `clamp({ direction: "all" })`) so the wrap copies
fill the cross-edge slack as designed. Resize re-evaluates the
clamp so a window resize does not strand the camera below the new
floor. Documentation in `ui/docs/renderer.md` updated to describe
the new shared invariant.
Regression test in `tests/e2e/playground-map.spec.ts` wheels out
aggressively in torus mode and asserts `camera.scale >= minScale`
across all four Playwright projects.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the Phase 10 map stub with live planet rendering driven by
`user.games.report`, and wires the header turn counter to the same
data. Phase 11's frontend sits on a per-game `GameStateStore` that
lives in `lib/game-state.svelte.ts`: the in-game shell layout
instantiates one per game, exposes it through Svelte context, and
disposes it on remount. The store discovers the game's current turn
through `lobby.my.games.list`, fetches the matching report, and
exposes a TS-friendly snapshot to the header turn counter, the map
view, and the inspector / order / calculator tabs that later phases
will plug onto the same instance.
The pipeline forced one cross-stage decision: the user surface needs
the current turn number to know which report to fetch, but
`GameSummary` did not expose it. Phase 11 extends the lobby
catalogue (FB schema, transcoder, Go model, backend
gameSummaryWire, gateway decoders, openapi, TS bindings,
api/lobby.ts) with `current_turn:int32`. The data was already
tracked in backend's `RuntimeSnapshot.CurrentTurn`; surfacing it is
a wire change only. Two alternatives were rejected: a brand-new
`user.games.state` message (full wire-flow for one field) and
hard-coding `turn=0` (works for the dev sandbox, which never
advances past zero, but renders the initial state for any real
game). The change crosses Phase 8's already-shipped catalogue per
the project's "decisions baked back into the live plan" rule —
existing tests and fixtures are updated in the same patch.
The state binding lives in `map/state-binding.ts::reportToWorld`:
one Point primitive per planet across all four kinds (local /
other / uninhabited / unidentified) with distinct fill colours,
fill alphas, and point radii so the user can tell them apart at a
glance. The planet engine number is reused as the primitive id so
a hit-test result resolves directly to a planet without an extra
lookup table. Zero-planet reports yield a well-formed empty world;
malformed dimensions fall back to 1×1 so a bad report cannot crash
the renderer.
The map view's mount effect creates the renderer once and skips
re-mount on no-op refreshes (same turn, same wrap mode); a turn
change or wrap-mode flip disposes and recreates it. The renderer's
external API does not yet expose `setWorld`; Phase 24 / 34 will
extract it once high-frequency updates land. The store installs a
`visibilitychange` listener that calls `refresh()` when the tab
regains focus.
Wrap-mode preference uses `Cache` namespace `game-prefs`, key
`<gameId>/wrap-mode`, default `torus`. Phase 11 reads through
`store.wrapMode`; Phase 29 wires the toggle UI on top of
`setWrapMode`.
Tests: Vitest unit coverage for `reportToWorld` (every kind,
ids, styling, empty / zero-dimension edges, priority order) and
for the store lifecycle (init success, missing-membership error,
forbidden-result error, `setTurn`, wrap-mode persistence across
instances, `failBootstrap`). Playwright e2e mocks the gateway for
`lobby.my.games.list` and `user.games.report` and asserts the
live data path: turn counter shows the reported turn,
`active-view-map` flips to `data-status="ready"`, and
`data-planet-count` matches the fixture count. The zero-planet
regression and the missing-membership error path are covered.
Phase 11 status stays `pending` in `ui/PLAN.md` until the local-ci
run lands green; flipping to `done` follows in the next commit per
the per-stage CI gate in `CLAUDE.md`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wraps every in-game route under `/games/:id/*` in a responsive shell
with a header (race / turn placeholders, view-menu dropdown or mobile
hamburger, account menu), a three-tab sidebar (Calculator, Inspector,
Order), an active-view slot, and a mobile-only bottom-tabs row
`[Map, Calc, Order, More]`. Every view in the IA section
(`map`, `table/:entity`, `report`, `battle/:battleId?`, `mail`,
`designer/{ship-class,science}/:id?`) ships as a thin SvelteKit route
that mounts a `lib/active-view/<name>.svelte` stub rendering a
localised `coming soon` body. The lobby's `gotoGame` path now actually
lands on a rendered shell instead of a 404.
The "view router" mentioned in the plan is implemented as the file
system plus two-line route wrappers — no separate dispatch component.
Sidebar tab state lives as a `$state` rune inside `sidebar.svelte`,
which sits in the layout that SvelteKit keeps mounted across child
route swaps, so tab choice survives every active-view navigation for
free. A `?sidebar=calc|inspector|order` URL param seeds the initial
tab on first mount; the mobile bottom-tabs use a layout-owned
`mobileTool` rune with a URL-gated `effectiveTool` derivation so the
Calc / Order tool overlay only applies on `/map` and naturally drops
when the user navigates elsewhere.
Tablet ships with a click-toggle drawer for the sidebar rather than
the IA section's swipe-from-right gesture; the structural breakpoint
satisfies Phase 10's acceptance criterion and Phase 35 polish lands
the swipe. The mobile More drawer mirrors the header view-menu
content; the IA's narrower More list (Mail, Battle, Tables, History,
Settings, Logout) is also a Phase 35 polish target once History
exists.
Topic doc `ui/docs/navigation.md` captures the active-view model, the
sidebar state-preservation rule, the `?sidebar=` and `mobileTool`
conventions, and the transient map-overlay back-stack concept (with
the implementation deferred to Phase 34 alongside its first user).
i18n catalogues for `en` and `ru` add the full `game.shell.*`,
`game.view.*`, `game.sidebar.*`, `game.bottom_tabs.*` namespaces.
Tests: Vitest covers the header view-menu (every IA destination
including the Tables sub-list), the account-menu Logout / Language
wiring, the sidebar default tab / switching / `?sidebar=` seed /
close button, and every active-view stub. Playwright e2e boots an
authenticated session via `__galaxyDebug.setDeviceSessionId` (no
gateway calls — the shell makes none in Phase 10), exercises every
view through both the desktop dropdown and the mobile More drawer,
verifies sidebar tab survival across navigation, and uses
`setViewportSize` to validate the breakpoint switches at 768 px and
1024 px.
Phase 10 status stays `pending` in `ui/PLAN.md` until the local-ci
run lands green; flipping to `done` follows in the next commit per
the per-stage CI gate in `CLAUDE.md`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three fixes around the dev sandbox end-to-end path. Each one was
flushed out by an actual login walkthrough after the previous
commit.
Backend bootstrap now treats `cancelled`, `finished`, and
`start_failed` as terminal: the per-boot find-or-create skips such
games and provisions a fresh one. Without this, a single bad
shutdown cascade leaves the developer staring at a dead lobby tile
forever (cancelled games don't transition back). Covered by
TestTerminalSandboxStatus.
Tools/local-dev: stop killing engine containers in `make down`. The
runtime treats the disappearance of an engine as a real failure
(cascading the lobby game to `cancelled`); leaving the container
running across `down/up` lets the runtime reconciler re-attach on
the next boot. The teardown happens only in `make clean`, where the
DB is wiped anyway. Compose now also exposes :9090 (authenticated
EdgeGateway listener) on the host so the Vite dev proxy can reach
the Connect-Web surface, and bumps the gateway anti-abuse limits
for `public_misc` so the same surface is not blanket-rejected with
413.
Ui/frontend: the lobby's `My Games` cards are now clickable only
for the playable statuses (`running`, `paused`, `finished`). All
other statuses render as disabled buttons so a click on a draft or
cancelled game no longer drops the user on a 404 — the in-game
view at /games/:id/* doesn't exist before Phase 10 and never makes
sense for a cancelled game. Vite proxy splits the dev targets so
`/api/*` continues to talk to the REST listener and
`/galaxy.gateway.v1.EdgeGateway/*` is routed to the Connect-Web
listener via VITE_DEV_GRPC_PROXY_TARGET (defaults to :9090).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Backend's runtime spawns the engine container outside the compose
project, so `docker compose down` left a `galaxy-game-…` container
running. Add a `stop-engines` target that finds them by their OCI
image-title label (set in game/Dockerfile) and remove forcibly;
make `down` and `clean` depend on it. `clean` additionally wipes
the per-game state directory under /tmp/galaxy-game-state.
Add a troubleshooting note for the related symptom: when the
browser holds a keypair from a previous DB and `make clean`
recreates everything, the lobby renders "no games yet" until the
user clears site data or opens an incognito window. The dev user
keeps the same email but receives a fresh user_id, which the old
keypair cannot authenticate against.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a single zap.Info line after the membership-insertion loop so
the boot log explicitly shows how many participants the sandbox
provisioned. The number is fixed by config (PlayerCount) but
surfacing it in the log makes troubleshooting "why is the lobby
empty" cases (typo in the email, partial failure) faster than
querying the DB.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds backend/internal/devsandbox: an idempotent boot-time hook that,
when BACKEND_DEV_SANDBOX_EMAIL is set, ensures (1) the configured
engine_version row, (2) the real dev user, (3) PlayerCount-1
deterministic dummy users, (4) a private "Dev Sandbox" game with a
year-out turn schedule, (5) memberships for every participant via
the new lobby.Service.InsertMembershipDirect helper, (6) a drive of
the lifecycle to running. Re-running on a populated DB is a no-op;
partial states from earlier crashes are recovered.
tools/local-dev gains the matching env vars in .env, surfaces them
in compose, and acquires a `make build-engine` target that builds
galaxy-engine:local-dev from game/Dockerfile (a prerequisite of
`up`/`rebuild`). The compose game-state mount is changed from a
named volume to a host bind on /tmp/galaxy-game-state so backend's
bind-mount source for spawned engine containers resolves on the
docker daemon.
After `make -C tools/local-dev up`, login as dev@local.test with
the dev code 123456 and the Dev Sandbox already shows up in My
Games. Per-user behaviour for the same email survives a backend
restart.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Local-ci run 15 (b4f37d6) finished with status=success: Vitest
17 files / 150 tests, Playwright 64 cases across the four
projects, Go suites for backend/gateway/game/pkg/ui/core all
green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previous Phase 9 commit pre-marked PLAN.md with "Status: done"
before the local-ci gate ran green. Project rule
(galaxy/CLAUDE.md "Per-stage CI gate") allows the marker only
after the run is success; revert to "Status: pending".
Also folds the inline minScale formula in the playground page
into a call to map/no-wrap.ts:minScaleNoWrap so the playground
and the renderer share one source of truth for the floor.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stand up the vector map renderer in ui/frontend/src/map/ on top of
PixiJS v8 + pixi-viewport@^6. Torus mode renders nine container
copies for seamless wrap; no-wrap mode pins the camera at world
bounds and centres on an axis when the viewport exceeds the world
along that axis. Hit-test is a brute-force pass with deterministic
[-priority, distSq, kindOrder, id] ordering and torus-shortest
distance, validated by hand-built unit cases.
The development playground at /__debug/map exposes a window
debug surface for the Playwright spec, which forces WebGPU on
chromium-desktop, WebGL on webkit-desktop, and accepts the
auto-picked backend on mobile projects.
Algorithm spec lives in ui/docs/renderer.md, which also pins the
new deprecation status of galaxy/client (the entire Fyne client
module, including client/world). client/world/README.md and the
Phase 9 stub in ui/PLAN.md gain matching deprecation banners.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`time.LoadLocation` is called from
backend/internal/server/handlers_public_auth.go:108 (confirm-email-code)
and backend/internal/user/account.go:218 (user.settings.update). Both
runtime images shipped today have no tzdata — production
backend/Dockerfile uses gcr.io/distroless/static-debian12:nonroot, and
local-dev tools/local-dev/backend.Dockerfile uses alpine:3.20 without
the optional tzdata apk — so the container-side binary resolves only
the no-data fallback (UTC and fixed offsets) and rejects every real
IANA zone with HTTP 400 `invalid_request: time_zone must be a valid
IANA zone`.
Adding `import _ "time/tzdata"` to backend's main is the idiomatic
Go fix: the binary embeds the IANA database, time.LoadLocation works
on every base image, no Dockerfile changes needed. Cost is ~800 KB
of binary growth — invisible next to the existing /usr/local/bin/backend
size and well below any container layer threshold.
The OpenAPI spec already documents the field as "IANA time-zone
identifier" (gateway/openapi.yaml:205, backend/openapi.yaml:2334)
and the UI sends Intl.DateTimeFormat().resolvedOptions().timeZone,
so neither the contract nor the client needs a change.
Why this slipped through: backend unit tests run as a host Go test
process (developer's tzdata covers them), Playwright tests mock the
gateway (backend never reached), and the integration suite — the only
layer that exercises the real backend container — uses
RegisterSession which hardcoded `time_zone="UTC"`. Switching that
default to "Europe/Berlin" makes every integration scenario that
enrols a pilot exercise the tzdata path, so the next regression
surfaces in the integration run instead of escaping into manual
smoke. (The integration suite is not in the per-PR workflow yet; that
gap is tracked separately.)
Verified end-to-end against `tools/local-dev`:
- Europe/Amsterdam, Asia/Tokyo, America/Los_Angeles → 200 +
device_session_id (was 400 before this patch).
- Mars/Olympus still → 400 (validation behaviour unchanged).
Host tests: backend/internal/{auth,user,config} green.
UI: pnpm test 14/14, CI=1 pnpm exec playwright test 44/44.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
vite.config.ts now proxies `/api` and `/galaxy.gateway.v1.EdgeGateway`
to the gateway, so the browser sees only `localhost:5173` and never
trips a cross-origin preflight. `.env.development` accordingly points
`VITE_GATEWAY_BASE_URL` at the Vite origin. The proxy target is
overridable via `VITE_DEV_PROXY_TARGET=...` for non-default gateways
without touching the compose file.
`gateway/Dockerfile` previously failed to build because gateway
imports `galaxy/core` (replaced to `../ui/core` in `gateway/go.mod`)
but the Dockerfile did not copy `ui/core/` into the build context
nor declare the replace in the synthesised `go.work`. Adding both
makes `docker build -f gateway/Dockerfile .` succeed; this is the
same fix already shipped in `tools/local-dev/gateway.Dockerfile`,
back-ported to upstream.
Verified:
- docker build -f gateway/Dockerfile . — builds cleanly
- pnpm test 14/14, pnpm exec playwright test 44/44 (with CI=1 to
force a fresh dev server; reuse keeps the previous startup env)
- curl POST through localhost:5173/api/* and /galaxy.gateway.v1.* —
reach the gateway, no CORS preflight on the browser side
tools/local-dev/README.md updated with the new network map and the
`VITE_DEV_PROXY_TARGET` override.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds tools/local-dev/ with postgres + redis + mailpit + backend +
gateway plus a Make wrapper, so `make -C tools/local-dev up` brings
the full authenticated stack online and `pnpm -C ui/frontend dev`
talks to it directly. The committed `.env.development` already
points at the stack and pins the matching gateway response public
key from the dev keypair under tools/local-dev/keys/.
The backend ships a new opt-in env, BACKEND_AUTH_DEV_FIXED_CODE
(`tools/local-dev/.env` defaults it to 123456). When set,
ConfirmEmailCode accepts that literal in addition to the real
bcrypt-verified code; SendEmailCode still queues a real email so
Mailpit captures the issued code at http://localhost:8025/, and
both paths coexist. The override is rejected as non-six-digit by
config validation and emits a loud warning at backend startup.
The local-dev Dockerfiles mirror backend/Dockerfile and
gateway/Dockerfile but switch the runtime stage to alpine so
docker-compose healthchecks can wget /healthz; the gateway
Dockerfile additionally copies ui/core/ into the build context
because gateway/go.mod's `replace galaxy/core => ../ui/core` is
required to compile the gateway main.
Smoke tested:
- `make -C tools/local-dev up` boots all five services to healthy.
- send-email-code + confirm-email-code with code=123456 returns a
device_session_id; a real code in Mailpit also redeems
successfully.
- `pnpm test` 14/14, `pnpm exec playwright test` 44/44.
- `go test ./backend/internal/config/...` green.
Docs: tools/local-dev/README.md, tools/local-dev/keys/README.md,
new "Local development stack" section in ui/docs/testing.md, and a
short pointer in ui/README.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Extend pkg/model/lobby and pkg/schema/fbs/lobby.fbs with public-games
list, my-applications/invites lists, game-create, application-submit,
invite-redeem/decline. Mirror the matching transcoder pairs and Go
fixture round-trip tests.
- Wire the seven new lobby message types through
gateway/internal/backendclient/{routes,lobby_commands}.go with
per-command REST helpers, JSON-tolerant decoding of backend wire
shapes, and httptest-based unit coverage for success / 4xx / 5xx /
503 across each command.
- Introduce TS-side FlatBuffers via the `flatbuffers` runtime dep, a
`make fbs-ts` target driving flatc, and the generated bindings under
ui/frontend/src/proto/galaxy/fbs. Phase 7's `user.account.get` decode
now uses these bindings as well, closing the JSON.parse vs
FlatBuffers gap that would have failed against a real local stack.
- Replace the placeholder lobby with five sections (my games, pending
invitations, my applications, public games, create new game) and the
/lobby/create form. Submit-application uses an inline race-name
form on the public-game card; create-game keeps name / description /
turn_schedule / enrollment_ends_at always visible and the rest under
an Advanced toggle with TS-side defaults.
- Update lobby/+page.svelte to throw LobbyError on non-ok result codes;
GalaxyClient.executeCommand now returns { resultCode, payloadBytes }.
- Vitest binding round-trips, lobby.ts wrapper unit tests, lobby-page
+ lobby-create component tests, Playwright lobby-flow.spec covering
create / submit / accept across all four projects. Phase 7 e2e was
migrated to the FlatBuffers fixtures and to click+fill against the
Safari-autofill readonly inputs.
- Mark Phase 8 done in ui/PLAN.md, mirror the wire-format note into
Phase 7, append the new lobby commands to gateway/README.md and
docs/ARCHITECTURE.md, add ui/docs/lobby.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a minimal Svelte 5 i18n primitive (`src/lib/i18n/`) backing the
login form, the layout blocker page, and the lobby placeholder.
SUPPORTED_LOCALES drives both the picker and the runtime lookup;
adding a language is a two-step change inside `src/lib/i18n/`.
Login form gains a globe-icon language dropdown (English / Русский
in their native names), defaulting to navigator.languages with `en`
as the fallback. Switching the locale re-renders the form in place;
on submit, the locale rides in the JSON body of `send-email-code`
because Safari/WebKit silently drops JS-set Accept-Language. Gateway
gains a body `locale` field that takes priority over the request
header for preferred-language resolution.
Email and code inputs disable browser autofill / suggestions
(`autocomplete=off` + `autocorrect=off` + `autocapitalize=off` +
`spellcheck=false`) so Keychain / address-book pickers and
remembered-value dropdowns no longer fire on focus.
Cross-cuts:
- backend & gateway openapi: clarify that body `locale` is honored.
- docs/FUNCTIONAL{,_ru}.md §1.2: document body-vs-header priority.
- gateway tests: body `locale` overrides Accept-Language; blank
body `locale` falls back to header.
- new ui/docs/i18n.md; cross-links from auth-flow.md and ui/README.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implements ui/PLAN.md Phase 7 end-to-end:
- /login two-step form (email -> code) over the gateway public REST
surface; /lobby placeholder issues the first authenticated
user.account.get and renders the decoded display name.
- SessionStore (Svelte 5 runes) with loading / unsupported / anonymous /
authenticated states; layout-level route guard, browser-not-supported
blocker, and a minimal SubscribeEvents revocation watcher that closes
the active client within 1s on a clean stream end or
Unauthenticated.
- VITE_GATEWAY_BASE_URL + VITE_GATEWAY_RESPONSE_PUBLIC_KEY config plus
AuthError taxonomy in api/auth.ts.
- Vitest (auth-api, session-store, login-page) and Playwright e2e
(auth-flow.spec.ts) on the four configured projects, with a fixture
Ed25519 keypair forging Connect-Web JSON responses.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
KeyStore + Cache TS interfaces with WebCrypto non-extractable Ed25519
keys persisted via IndexedDB (idb), plus thin api/session.ts that
loads or creates the device session at app startup. Vitest unit
tests under fake-indexeddb cover both adapters; Playwright e2e
verifies the keypair survives reload and produces signatures still
verifiable under the persisted public key (gateway round-trip moves
to Phase 7's existing acceptance bullet).
Browser baseline: WebCrypto Ed25519 — Chrome >=137, Firefox >=130,
Safari >=17.4. No JS fallback; ui/docs/storage.md documents the
matrix and the WebKit non-determinism quirk.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Compile `ui/core` to WebAssembly via TinyGo (903 KB) and expose four
canonical-bytes / signature-verification functions on
`globalThis.galaxyCore` from `ui/wasm/main.go`. The TypeScript-side
`Core` interface plus a `WasmCore` adapter (browser + JSDOM loader)
bridge those into a typed shape, and a `GalaxyClient` skeleton wires
`Core.signRequest` → injected `Signer` → typed Connect client →
`Core.verifyPayloadHash` / `verifyResponse`.
Wire `ui/buf.gen.yaml` against the local
`@bufbuild/protoc-gen-es` v2 binary (devDependency) so the codegen
step does not depend on the buf.build BSR. Vitest covers the bridge
end-to-end: per-method WasmCore tests under JSDOM, byte-for-byte
canon parity against the gateway fixtures committed in Phase 3, and
a `GalaxyClient` orchestration test using
`createRouterTransport`. The committed `core.wasm` snapshot tracks
TinyGo output so contributors run `make wasm` only when `ui/core/`
changes; CI consumes the snapshot directly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 4 replaced the gateway's authenticated edge listener with a
Connect-Go HTTP/h2c bootstrap that natively serves Connect, gRPC,
and gRPC-Web. Sweep the integration suite so test names, comments,
and helper docs match the new transport posture: rename
TestUserAccount_GetThroughGatewayGRPC to TestUserAccount_GetThroughGatewayEdge,
flip "authenticated gRPC" / "signed gRPC" / "gateway gRPC" comments
to "authenticated edge", and align testenv doc strings.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the native-gRPC server bootstrap with a single
`connectrpc.com/connect` HTTP/h2c listener. Connect-Go natively
serves Connect, gRPC, and gRPC-Web on the same port, so browsers can
now reach the authenticated surface without giving up the gRPC
framing native and desktop clients may use later. The decorator
stack (envelope → session → payload-hash → signature →
freshness/replay → rate-limit → routing/push) is reused unchanged
behind a small Connect → gRPC adapter and a `grpc.ServerStream`
shim around `*connect.ServerStream`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ui-test workflow gains a `!**/*.md` negation so commits touching only
markdown (READMEs, PLAN.md updates, topic docs) no longer kick off the
full Go + Vitest + Playwright pipeline. Mixed commits keep triggering
because at least one positive path (`ui/**`, `gateway/**`, …) still
matches.
Project CLAUDE.md adds a per-stage CI gate section so the local
Gitea Actions runner is exercised at the close of every stage from
any PLAN.md, with the push step pre-authorised.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the act-as-fallback section with the operations needed to
work with the local Gitea + arm64 act_runner shipped in tools/local-ci/:
how to bring it up, push, query run status from curl, and pull
zstd-compressed step logs from inside the gitea container. Keeps a
short act note as a syntax-only dry-run.
Also drops `client/**` from the path-filter list documented at the
top (the workflow excludes deprecated client/ from triggers and from
the go test command), and notes that the checkout step now uses
submodules: recursive so MaxMind-DB fixtures land for pkg/geoip.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two issues surfaced by the first end-to-end ui-test.yaml run on a
clean Linux runner that don't reproduce locally:
- pkg/geoip tests load fixtures from the pkg/geoip/test-data git
submodule (MaxMind-DB). actions/checkout@v4 does not fetch
submodules by default, so the fixture path is missing on the
runner. Both ui-test and ui-release workflows now check out with
submodules: recursive.
- pkg/util/TestWritable asserts that /usr/lib is not writable, which
holds for unprivileged users but fails inside the catthehacker
workflow container that runs as root. Skip that branch when
os.Geteuid() == 0; the root-only "the writable dir is writable"
branch still runs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Vitest + @testing-library/jest-dom matchers wired through tests/setup.ts.
Playwright with four projects: chromium-desktop, webkit-desktop,
chromium-mobile-iphone-13, chromium-mobile-pixel-5; traces and
screenshots retained on failure.
.gitea/workflows/ui-test.yaml runs Tier 1 on every push and pull
request: monorepo Go service tests (backend with -p 1 to dodge
testcontainer contention; gateway, game, every pkg/<name> module),
pnpm install --frozen-lockfile, playwright install --with-deps,
pnpm test, pnpm exec playwright test. Uploads playwright-report
and test-results on failure. Integration suite stays gated behind
make -C integration integration; deprecated client/ excluded.
.gitea/workflows/ui-release.yaml mirrors Tier 1 on v* tag push and
keeps commented placeholders for visual regression (Phase 33) and
macOS iOS smoke (Phase 32).
ui/docs/testing.md documents both tiers and the local invocations
that mirror what CI runs. ui/PLAN.md Phase 2 marked done; Phase 3
gains a bullet to extend the go test command with ./ui/core/...;
Phase 36 has the renamed release workflow path.
tools/local-ci/ ships a self-contained docker-compose for verifying
workflows against a local Gitea + arm64 act_runner before pushing
to a real instance.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
TestServiceStartGameEndToEnd's httptest server had no handler for
/healthz, the path engineclient.Healthz probes after a runtime
container starts. Without it the runtime never transitions out of
starting state and the test fails on its 5s deadline. Surfaced by
introducing CI that runs the backend service tests outside the
integration harness.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| `BACKEND_ADMIN_BOOTSTRAP_PASSWORD` | no | — | Initial admin password; required if user is set. |
| `BACKEND_ADMIN_BOOTSTRAP_PASSWORD` | no | — | Initial admin password; required if user is set. |
| `BACKEND_ADMIN_CONSOLE_CSRF_KEY` | no | random per-process | Secret keying the `/_gm` console CSRF token. Set a shared value across replicas; unset uses a per-process random key (forms reset on restart). |
| `BACKEND_GEOIP_DB_PATH` | yes | — | Filesystem path to GeoLite2 Country `.mmdb`. |
| `BACKEND_GEOIP_DB_PATH` | yes | — | Filesystem path to GeoLite2 Country `.mmdb`. |
| `BACKEND_RUNTIME_CONTAINER_STATE_MOUNT` | no | `/var/lib/galaxy-game` | Absolute in-container path for the per-game state bind mount. |
| `BACKEND_RUNTIME_CONTAINER_STATE_MOUNT` | no | `/var/lib/galaxy-game` | Absolute in-container path for the per-game state bind mount. |
| `BACKEND_RUNTIME_STOP_GRACE_PERIOD` | no | `10s` | SIGTERM-to-SIGKILL grace period for engine container stop. |
| `BACKEND_RUNTIME_STOP_GRACE_PERIOD` | no | `10s` | SIGTERM-to-SIGKILL grace period for engine container stop. |
| `BACKEND_STACK_LABEL` | no | — | Optional value stamped as `galaxy.stack=<value>` on every engine container backend spawns. Lets host-side tooling (Makefile / CI) scope cleanup to one dev stack. Empty → label is not applied. |
| `BACKEND_NOTIFICATION_ADMIN_EMAIL` | no | — | Recipient address for admin-channel notifications (`runtime.*` kinds). When empty, admin-channel routes are recorded as `skipped` and the catalog is partially silenced. |
| `BACKEND_NOTIFICATION_ADMIN_EMAIL` | no | — | Recipient address for admin-channel notifications (`runtime.*` kinds). When empty, admin-channel routes are recorded as `skipped` and the catalog is partially silenced. |
| `BACKEND_NOTIFICATION_MAX_ATTEMPTS` | no | `8` | Notification route delivery attempts before dead-lettering. |
| `BACKEND_NOTIFICATION_MAX_ATTEMPTS` | no | `8` | Notification route delivery attempts before dead-lettering. |
| `BACKEND_DIPLOMAIL_MAX_BODY_BYTES` | no | `4096` | Maximum size of `diplomail_messages.body` enforced at send time. Tune at runtime without a migration. |
| `BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` | no | `256` | Maximum size of `diplomail_messages.subject`. Subject is optional; empty is always accepted. |
| `BACKEND_DIPLOMAIL_TRANSLATOR_URL` | no | — | Base URL of a LibreTranslate-compatible instance (`http://libretranslate:5000`). Empty → translator falls through to no-op (recipients are delivered with the original body). |
| `BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT` | no | `10s` | Per-request HTTP timeout for the translation worker. |
| `BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS` | no | `5` | Number of failed HTTP attempts before the worker delivers the message with the original body (fallback). |
| `BACKEND_DIPLOMAIL_WORKER_INTERVAL` | no | `2s` | How often the async translation worker scans for pending pairs. The worker processes one pair per tick. |
If `BACKEND_ADMIN_BOOTSTRAP_USER` is set without
If `BACKEND_ADMIN_BOOTSTRAP_USER` is set without
`BACKEND_ADMIN_BOOTSTRAP_PASSWORD`, `Validate()` fails. If neither is
`BACKEND_ADMIN_BOOTSTRAP_PASSWORD`, `Validate()` fails. If neither is
@@ -146,10 +161,10 @@ seeded `admin_accounts` ahead of time.
before the HTTP listener opens. The startup path also issues a
before the HTTP listener opens. The startup path also issues a
`CREATE SCHEMA IF NOT EXISTS backend` so a fresh database does not
`CREATE SCHEMA IF NOT EXISTS backend` so a fresh database does not
trip goose's bookkeeping table on the first migration.
trip goose's bookkeeping table on the first migration.
-Pre-production uses one migration file (`00001_init.sql`) covering
-Migrations are sequence-numbered (`0000N_*.sql`) and applied
every backend domain (auth, user, admin, lobby, runtime, mail,
additively. `00001_init.sql` is the historical baseline; every
notification, geo). Future migrations are sequence-numbered and
schema change after it is a new file with a higher prefix. See
additive.
`internal/postgres/migrations/README.md` for the authoring rules.
- Queries are written through `go-jet/jet/v2`. The generated code is in
- Queries are written through `go-jet/jet/v2`. The generated code is in
`internal/postgres/jet/backend/` and is committed; `internal/postgres/jet/jet.go`
`internal/postgres/jet/backend/` and is committed; `internal/postgres/jet/jet.go`
carries package metadata that survives regeneration.
carries package metadata that survives regeneration.
@@ -249,11 +264,13 @@ introduce its own request/response types.
Endpoints used:
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`.
<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>
<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.');">
// Loud, repeated warning so a stray production deployment cannot
// claim the operator was unaware. The override is intended for
// `tools/local-dev/` and never reaches production binaries in
// normal operation.
deps.Logger.Warn("DEV-MODE: BACKEND_AUTH_DEV_FIXED_CODE is set; ConfirmEmailCode accepts the literal code in addition to the bcrypt-verified one. NEVER use in production.")
}
return&Service{deps:deps,emailHashKey:key}
return&Service{deps:deps,emailHashKey:key}
}
}
// devFixedCodeMatches reports whether the dev-mode fixed-code override
// is configured and the submitted code matches it verbatim. The
// override is opt-in via `BACKEND_AUTH_DEV_FIXED_CODE`; production
// deployments leave the field empty and devFixedCodeMatches always
// returns false. See `tools/local-dev/README.md` for the full
// ErrGamePaused reports that the game is not in a state that
// 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
// finished bootstrapping (`starting`). Handlers map this to
// HTTP 409 with httperr code `game_paused`; the UI surfaces a
// pause banner and waits for an admin resume or a fresh
// snapshot.
ErrGamePaused=errors.New("runtime: game paused")
)
)
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.