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