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).