c1672224a6f500019da9c5929f4da92bf66ed93a
93 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c1672224a6 |
fix(ui): pin the mobile game shell to the viewport
Tests · UI / test (push) Successful in 2m49s
The app-shell migration surfaced a mobile-only e2e failure: taps on the bottom-tab bar, the map-toggles menu, and the planet sheet were intercepted by sibling elements despite the targets being on top. Root cause: `.game-shell` used `min-height: 100vh`, so sub-pixel content overflowed the viewport and made the document scrollable. On mobile that scroll toggles the browser's dynamic toolbar, which resizes the viewport and every `position: fixed` overlay (their sizes derive from `100vh`) mid-gesture — defeating Playwright's actionability hit-test, and making the real controls jittery to tap. Pin the shell with `position: fixed; inset: 0` on the mobile breakpoint so it leaves document flow: the document can no longer scroll, the toolbar stays put, the viewport and overlays stay stable, and the active-view area remains the single internal scroll region. Desktop is unchanged (the rule is scoped to max-width: 767.98px). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e31fb2c17a |
docs(ui): sync docs to the app-shell; fix stale nav comments
Tests · UI / test (push) Failing after 9m28s
Rewrite ui/docs (navigation, order-composer, auth-flow, pwa-strategy, game-state + secondary topic docs) and ui/README for the single-URL app-shell (in-memory screens/views, Back→lobby via shallow routing, sessionStorage restore + validation, return-to-lobby). ui/PLAN.md gets a Phase-10 supersede note (implemented; standalone-compatible). Fix stale code comments (session-store auth gate, report-sections spec contract). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4e0058d46c |
test(ui): migrate suite to the app-shell (state-driven navigation)
- Unit: repoint moved screen imports (lib/screens, lib/game), mock $lib/app-nav (appScreen/activeView) instead of $app/navigation, drop the removed gameId props, assert screen/view selection. - e2e: add a dev-only window.__galaxyNav affordance; specs enter a game via enterGame(...) instead of a /games/:id URL; URL assertions become content assertions (the URL stays /game/); reload uses waitUntil:"commit" (shallow routing) and mocks /rpc on game entry. - Remove the obsolete report scroll-restore test (it relied on a SvelteKit route Snapshot that no longer exists); update the missing-membership test to the new lobby-redirect+toast behaviour. Fix a stale report.svelte docstring. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
80545e9f9d |
feat(ui): app-shell behaviour — restore validation, return-to-lobby, push
- A restored game that no longer exists (cancelled/removed/revoked) drops to
the lobby with a toast instead of the in-game error state: game-state
exposes a `notFound` flag and the shell redirects via appScreen.go("lobby").
- Add a visible "return to lobby" control to the in-game header.
- Push/toast deep-links use activeView.select(...) (no URL); fix a latent
visibility-listener double-install on in-place game switches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
be7f06e163 |
feat(ui): screen-level history for the app-shell (Back → lobby)
Mirror the screen into browser history via SvelteKit shallow routing (pushState/replaceState with page.state) so Back/Forward move between screens while the URL stays at /game/. Overlays (game, lobby-create) push; lobby/login replace. A popstate→page.state effect syncs the store back without re-pushing (no loop); the boot stamp puts a restored overlay above the load entry so Back falls through to lobby. In-game view switches never touch history. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
b6770d394c |
feat(ui): app-shell core — single-route dispatcher, route collapse, nav→state
Collapse the game UI to one route (`/`): a screen dispatcher renders login/lobby/lobby-create/game from `appScreen`/`activeView` state instead of URL routes. Move screen components to lib/screens & lib/game; the game shell reads the game id from `appScreen.gameId` and re-inits per-game stores via an $effect; in-game views render from `activeView`. Flip ~23 goto/href nav sites to store mutations; drop the `?sidebar=` URL coupling. Auth gate is now state-based. WIP: browser-history (Back→lobby), restore-validation, the return-to-lobby button, push deep-links, and the test migration are follow-ups on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
182beebcd6 |
feat(ui): app-nav state stores (app-shell foundation)
Add `appScreen` + `activeView` rune singletons with a shared sessionStorage snapshot — the in-memory source of truth that replaces URL-based screen/view routing for the single-URL app-shell. Not wired in yet (additive). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
9cb5097f54 |
fix(ui): redirect app root to lobby/login; evict stale root service worker
Tests · UI / test (push) Has been cancelled
Build · Site / build (push) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 1m42s
Build · Site / build (pull_request) Successful in 6s
Tests · UI / test (pull_request) Successful in 2m23s
Tests · Go / test (pull_request) Successful in 1m56s
- The app root ("/", i.e. /game/) rendered a dev "workspace skeleton"
stub, and the layout guard only redirected anonymous users off it, so
an authenticated visitor stayed on the stub. Redirect "/" to /lobby
(authenticated) and /login (anonymous), and replace the stub with a
minimal loading placeholder. Drop the obsolete landing-stub unit test
(root redirect is covered by the auth-flow e2e).
- Ship a tombstone /service-worker.js on the project site so any old
root-scoped PWA worker (from when the game lived at the origin root)
unregisters itself instead of serving a stale cached page at the
site origin. The game now registers its worker only under /game/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
8565942392 |
feat(deploy): single-origin path-based deployment + project site
Serve the whole stack behind one host: site at /, game UI at /game/, gateway REST at /api + /healthz, Connect at /rpc (prefix stripped by the edge Caddy). The built artifact is domain-agnostic — the UI talks to the gateway same-origin via relative URLs, so the same bundle runs under any host with no rebuild and with CORS disabled. - Rename the Connect proto service galaxy.gateway.v1.EdgeGateway -> edge.v1.Gateway; regenerate Go + TS; public path /rpc/edge.v1.Gateway. - Move the game UI under base path /game (env BASE_PATH); make the manifest, service-worker scope, WASM loader, and all navigation base-aware via a withBase helper. - Relative API + /rpc Connect prefix; Vite dev proxy mirrors the strip. - Rewrite the edge Caddy (dev + prod) for path-based routing; empty CORS allow-lists (same-origin); single host. - New VitePress project site (site/): i18n en/ru with switcher, LaTeX math, minimal monospace theme; built and served at /. - dev-deploy compose/Makefile + CI (dev-deploy, prod-build, new site-build) build and seed the site; probes hit /, /game/, /healthz. - Sync docs (ARCHITECTURE, gateway README/openapi, dev-deploy & local-dev READMEs, CLAUDE.md, ui/PLAN). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
11f51944df |
fix(ui): register the service worker in production only
SvelteKit's automatic SW registration also runs under `vite dev`, where the worker intercepted/cached the dev-server e2e suite (42 failures). Disable auto-registration (kit.serviceWorker.register: false) and register the worker manually from the root layout guarded by `!dev`, so `vite dev` and the e2e suite run worker-free while the production build — and the PWA preview test — still install it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
04c7f6e68a |
feat(ui): installable offline PWA — service worker, manifest, icons (F5)
Tests · UI / test (push) Failing after 7m31s
Native SvelteKit service worker (src/service-worker.ts): a version-keyed cache precaches the app shell + build artefacts (incl. core.wasm) + static files; activate purges old caches; the gateway is never intercepted; navigations fall back to the cached shell offline. Adds static/manifest.webmanifest, a generated placeholder icon set (scripts/gen-pwa-icons.mjs — dependency-free pure-Node PNG encoder), and manifest / theme-color / apple-touch tags in app.html. Gated by Playwright against a production preview (playwright.pwa.config.ts + tests/pwa/pwa.spec.ts via `pnpm test:pwa`, wired into ui-test): manifest + installable icons, SW registration + a single version-keyed cache, and offline shell load. Lighthouse is not used — its PWA category was removed in v12. Docs: ui/docs/pwa-strategy.md (+ index); F5 marked done. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
35e27c5aec |
fix(ui): bottom-sheet tap-outside only fires while the sheet is shown
The planet/ship-group sheets stay mounted on desktop but are hidden by a media query (`display: none`); the document-level tap-outside listener fired regardless, so the first click after selecting a planet cleared the selection — breaking every desktop inspector/select flow in CI. Guard the handler on the sheet's computed display (`offsetParent` is unreliable for `position: fixed`). The swipe handle is naturally inert when hidden. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
8dcaf1c6c6 |
feat(ui): error & state UX — error surface, view states, map selection, sheet gestures (F4)
- lib/error/: classify any caught error into a stable ErrorKind from the transport signal (HTTP status / Connect Code / fetch TypeError / navigator.onLine); map to translated error.* messages via reportError (sticky Retry toast for retryable kinds) or errorMessageKey (inline). Mail compose now surfaces the translated 403/error inline. - lib/ui/view-state.svelte: shared loading/empty/error placeholder with the right live-region role + optional action; entity tables (races/sciences/ship-classes) migrated, rest adopt incrementally. - map/selection-ring.ts: accent ring around the selected planet, fed into the map buildExtras alongside the reach circles. - lib/ui/sheet-dismiss.ts: tap-outside + drag-handle swipe-down dismissal for the planet/ship-group bottom-sheets (hand-rolled pointer events). Tests: error, view-state, selection-ring, sheet-dismiss (761 total). Docs: ui/docs/error-state-ux.md (+ index); F4 marked done. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
1e62837c68 |
feat(ui): locale persistence + i18n completeness guards (F3)
An audit found the client already i18n-first: one hard-coded UI string (the battle-scene aria-label, now keyed) and en/ru already share an identical 692-key set. - Persist the locale: i18n.setLocale writes localStorage (galaxy-locale) and the store boots from stored > browser detection > default, so a language switch survives reloads. - tests/i18n-completeness.test.ts: en/ru key-set parity, non-empty values, and locale persistence. - Docs: ui/docs/i18n.md; mark F3 done in ui/PLAN-finalize.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
70f2973396 |
fix(ui): darken light-theme danger to meet AA contrast
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. |
||
|
|
e193f3ca88 |
feat(ui): default theme to system (follow OS light/dark)
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> |
||
|
|
642c5b7322 |
feat(ui): accessibility pass — WCAG 2.2 AA for login/lobby/shell (F2)
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> |
||
|
|
4ad96b0ef7 |
feat(ui): migrate all view bodies to design tokens (F1b)
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> |
||
|
|
973480d812 |
feat(ui): design tokens + light/dark theming, migrate in-game chrome (F1a)
Tests · UI / test (push) Successful in 2m4s
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> |
||
|
|
b1b87c8521 |
feat(ui-calculator): input validation, load caps, ceil display, modernization layout
- 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> |
||
|
|
3ea29cf8b5 |
fix(ui-calculator): keep calculator state long-lived; don't eject on planet click
Tests · UI / test (push) Successful in 1m59s
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> |
||
|
|
9ae7b88b89 |
feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
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> |
||
|
|
a08f4f55b0 |
fix(ui-map): cut the visibility fog with an inverse stencil mask (Safari pan perf, stage 2)
Stage 1 (render-on-demand) removed the idle / whole-system freeze, but
panning a loaded map with "visible hyperspace" on stayed heavy in Safari:
the fog still cut its visibility holes by opaque overpaint — on KNNTS041
that is ~260 near-world-sized opaque circles blended over the fog every
rendered frame, a fill-rate cliff for Safari's WebGPU / Apple's tile-based
GPU.
Replace the overpaint with an INVERSE stencil mask: setVisibilityFog now
draws the FOG_COLOR rectangle(s) into fogLayer and collects the visibility
circles into one Graphics set as fogLayer.setMask({ mask, inverse: true }),
so the fog shows everywhere except the union of the circles. Per-frame cost
drops from dozens of blended opaque circle fills to one rect fill + a
stencil pass (no colour writes), which Apple's TBDR GPU handles cheaply,
and the fog stays fully vector — crisp at any zoom.
fogPaintOps and its unit tests are unchanged (the circle ops now feed the
mask instead of an overpaint). Verified with a high-contrast screenshot
during development (fog field with a correct circle-union hole) plus the
existing fog / render-on-demand e2e green on chromium + webkit.
Docs: renderer.md fog section + PLAN.md Phase 29 decision 9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
51902b995f |
fix(ui-map): render-on-demand + drop pan inertia to stop the Safari fog freeze
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>
|
||
|
|
53b892ae00 |
fix(ui-map): move fog overlay to a viewport-level layer below the copies
Two regressions surfaced once visible-hyperspace toggled on a real
dev-deploy map:
1. On the zero-turn map the bg holes painted ON TOP of the planet
glyphs — every LOCAL planet looked like a hollow circle of
background colour instead of the planet pixel inside an
unfogged area.
2. On a legacy report with a drive tech that pushes the visibility
radius well past the world dimensions the bg circles overlapped
to cover the entire viewport. Combined with the wrong z-order
the result was a uniformly black canvas with every primitive
hidden.
The per-copy implementation added the fog container via
`copy.addChildAt(container, 0)` and trusted Pixi v8 to insert the
container at the start of the copy's children. Whether by a Pixi
quirk or by some interaction with how `populatePrimitives` orders
its `c.addChild(g)` calls, the fog ended up rendering after every
primitive in practice — the symptoms above are a perfect match for
that ordering.
Restructured the fog rendering so the z-order is structural
rather than relying on `addChildAt`:
- A single `fogLayer: Container` is added to the viewport BEFORE
the nine torus copies. Pixi renders viewport children in order,
so the layer is guaranteed to paint first; every copy renders
on top.
- `fogPaintOps` now emits world-space coordinates with wrap
offsets baked in (9 fog rects + 9 bg circles per visibility
entry in torus mode, 1 + N in no-wrap mode). The renderer
populates `fogLayer` with one `Graphics` per op — no per-copy
iteration on the fog side.
- The previous `fogGraphics: Container[]` closure state is gone.
Each `setVisibilityFog` flip drops every child of `fogLayer`
and rebuilds it. The dispose path drops the children
eagerly before `app.destroy({children: true})` walks the tree.
The fog-paint-ops test exercises the new contract: the no-wrap
path keeps one rect + N circles, the torus path expands to nine
rects + nine wrapped circles per entry (including the seam-fix
case at x = 950).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
00e84579ca |
fix(ui-map): split fog overlay into per-shape Graphics + torus-wrap circles
Tests · UI / test (push) Successful in 3m23s
Two visible regressions in the in-game map's fog overlay surfaced
on dev-deploy:
1. With three LOCAL planets close together, only the last planet
glyph stayed visible inside the bg holes — the other two were
obscured. The previous implementation stacked the fog rectangle
plus every bg circle onto a single `Graphics` via repeated
`g.rect(...).fill(...).circle(...).fill(...)...`. Pixi v8's
multi-shape Graphics is supported in theory, but in practice
only the last shape's fill seems to land, dropping the earlier
bg holes (and the planet glyphs on top look like they vanished
along with their hole). Splitting each op onto its own
`Graphics` inside a per-copy `Container` removes the ambiguity
— one shape, one fill, one render pass.
2. A planet near the right world edge produced a "sector" — the
bg circle painted into the area past the seam, but the
neighbouring tile's fog rectangle then overpainted that bleed,
leaving a quarter-circle hole. In torus mode each visibility
circle is now drawn at the nine wrapped positions
(`(dx, dy) ∈ {-1, 0, 1}²`); the wrapped copies in the
neighbour-tile-aligned positions keep the hole continuous
across the seam. No-wrap mode keeps a single emission per
circle, because wrapped circles would leak into the visible
world rectangle as unwanted holes.
The `fogPaintOps` helper now takes the wrap mode as a parameter;
`tests/fog-paint-ops.test.ts` covers the torus expansion
(nine-wrap product per circle, the seam-fix case at x = 950) and
re-asserts the no-wrap path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
7ade838df8 |
test(ui-map): unit-cover the fog overlay's layered-overpaint contract
Tests · UI / test (push) Successful in 2m49s
Lifted the Phase 29 fog draw sequence out of `setVisibilityFog` into a pure `fogPaintOps` helper that returns an ordered list of fill operations (one fog rect, then one background-coloured circle per visibility entry). The renderer now dispatches each op straight onto a Pixi `Graphics`; the indirection lets the layered- overpaint contract be tested without booting Pixi. `tests/fog-paint-ops.test.ts` covers: empty input → no ops; single circle → fog rect + bg circle in that order; multiple circles → N bg circles after the fog rect; overlapping circles emitted independently (the rendering order unions them); zero / negative world dimensions → no ops. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
37580b7699 |
fix(ui-map): repaint fog as layered overpaint; rename to visibleHyperspace
Tests · UI / test (push) Waiting to run
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> |
||
|
|
2f4dc01d54 |
fix(ui-map): apply wrap-mode flips in place instead of remounting
Tests · UI / test (push) Successful in 2m51s
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>
|
||
|
|
7c46aa4bec |
fix(ui-e2e): tighten Phase 29 effect tracking + radio wiring
Tests · UI / test (push) Failing after 7m19s
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> |
||
|
|
2528d63b51 |
fix(ui-e2e): Phase 29 map-toggles spec passes across all four projects
Tests · UI / test (push) Failing after 10m52s
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> |
||
|
|
2bd1b54936 |
feat(ui): Phase 29 map visibility toggles
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> |
||
|
|
14b65389ef |
feat(gateway): unsigned gateway.heartbeat keeps Safari push streams alive
Tests · UI / test (push) Successful in 2m35s
Tests · Go / test (push) Successful in 1m56s
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m42s
Tests · Go / test (pull_request) Successful in 2m0s
Browser fetch-streaming layers close response bodies they consider
idle after roughly 15-30 s without incoming bytes. Safari is the
most aggressive, but the symptom matters everywhere: a quiet
SubscribeEvents stream (lobby, between turns, mailbox empty) gets
torn down by the browser, the EventStream singleton reconnects with
backoff, and any push event that fires inside the reconnect window
is lost because `push.Hub` queues are not persisted across
subscription closes. The user-visible failure mode is the
intermittent "Fetch API cannot load … due to access control checks"
console error (a misleading WebKit symptom — CORS headers are
actually present) plus missed turn-ready / mail-received toasts.
Server-side fix: a silence-based heartbeat at the
`authenticatedPushStreamService` wrapper layer. After the signed
`gateway.server_time` bootstrap event, gateway wraps the bound
stream with `heartbeatingStream`. Every tail Send (fan-out, future
variants) resets the silence timer; when the timer elapses, a
goroutine emits `gateway.heartbeat` with only `EventType` set —
everything else stays at proto3 defaults, so the wire frame is
~45 bytes amortised. A `sendMu` serialises the heartbeat goroutine
with tail Sends because grpc.ServerStream.Send is not goroutine-safe.
The heartbeat is intentionally UNSIGNED: heartbeats carry no
payload, dispatch to no handler on the client, and an injected
heartbeat trivially causes no user-visible state change. TLS still
protects the wire and real events keep the signed envelope
unchanged. Documented in `docs/ARCHITECTURE.md` § 15 alongside the
per-scale bandwidth projection (100…100 000 clients × 15…60 s).
Config: new `GATEWAY_PUSH_HEARTBEAT_INTERVAL` (default `15s`,
`0s` disables). Telemetry: new
`gateway.push.heartbeats_sent{outcome}` counter so operators can
budget bandwidth and spot a sudden `outcome=error` bump as an
upstream-failing-before-flush signal.
Client (`ui/frontend/src/api/events.svelte.ts`): early `continue`
on `event.eventType === "gateway.heartbeat"` before `verifyEvent`,
`verifyPayloadHash`, or dispatch — empty signature would otherwise
trip SignatureError and reconnect. A leading heartbeat still flips
`connectionStatus` to `connected` and resets backoff, because
receiving one is proof the stream is healthy.
Tests:
- `push_heartbeat_test.go`: unit tests for the wrapper — zero
interval returns nil, heartbeat fires after silence, real Send
resets the timer, Stop / context-cancel halt the goroutine,
Send errors propagate.
- `server_test.go`: integration tests through the full gateway
pipeline — heartbeat fires after the configured silence window,
zero interval keeps the stream silent.
- `config_test.go`: default applied, env-override parsed,
negative value rejected.
- `events.test.ts`: heartbeat skipped before verification + not
dispatched to handlers; leading heartbeat still flips
`connectionStatus` to `connected`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
bde01b1ce2 |
fix(battle-viewer): unblock synthetic-game battle load
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> |
||
|
|
2119f825d6 |
mail UI: dedupe broadcast fan-out and drop in-game admin compose
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>
|
||
|
|
81917acc3e |
dev-deploy: enable Dev Sandbox bootstrap and synthetic-report loader
Two long-standing dev-environment ergonomics had not survived the move from the bespoke local-dev stack to the CI-driven dev-deploy: 1. `BACKEND_DEV_SANDBOX_EMAIL` defaulted to an empty string in the dev-deploy compose, so the auto-provisioned "Dev Sandbox" game never appeared on `https://www.galaxy.lan`. Bake `dev@galaxy.lan` as the default — matches `.env.example` and lets a developer who logs in with that email find a ready-to-play game in the lobby. 2. The lobby's synthetic-report loader was gated on `import.meta.env.DEV`, which is true only for `vite dev` (the tools/local-dev path). The long-lived dev environment builds with `vite build` (production mode), so the section was always stripped from its bundle. Gate it on an explicit `VITE_GALAXY_DEV_AFFORDANCES` flag instead and set it both in `.env.development` (preserves `pnpm dev` behaviour) and in the `dev-deploy.yaml` build step. The `prod-build.yaml` build path leaves the flag unset, so production stays clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
ebd156ece2 |
battle-fetch: migrate to user.games.battle ConnectRPC command
Tests · UI / test (push) Has been cancelled
Tests · Go / test (pull_request) Successful in 2m6s
Tests · Go / test (push) Successful in 2m7s
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · UI / test (pull_request) Failing after 3m42s
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>
|
||
|
|
db81bd8e08 |
Phase 28 (Steps 7+8): header unread badge + push/init wiring
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>
|
||
|
|
f7300f25a3 |
Phase 28 (Steps 6+9): mail active view + i18n keys
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> |
||
|
|
fdd5fd193d |
Phase 28 (Step 5): MailStore reactive state
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>
|
||
|
|
7378d4c8ed |
Phase 28 (Step 4): UI api/diplomail.ts wrappers
Adds typed wrappers around `GalaxyClient.executeCommand` for the eight Phase 28 mail RPCs. Each wrapper builds the matching FlatBuffers request, decodes the response, and surfaces backend errors through a dedicated `MailError` (mirroring `LobbyError`). The compose helpers accept the recipient race name directly so the UI can feed it straight from `report.races[].name` without a membership lookup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
fed282f2d2 |
Phase 28 (Step 2): FBS schemas + message-type constants for mail
Tests · UI / test (push) Has been cancelled
Tests · Go / test (pull_request) Successful in 2m4s
Tests · Go / test (push) Successful in 2m5s
Tests · Integration / integration (pull_request) Successful in 1m54s
Tests · UI / test (pull_request) Successful in 2m50s
Adds the wire schema for the eight `user.games.mail.*` ConnectRPC commands together with the shared payload types (`MailMessage`, `MailRecipientState`, `MailBroadcastReceipt`). Send-request tables carry the optional `recipient_race_name` introduced in Step 1. Drops: - `pkg/schema/fbs/diplomail.fbs` — schema sources; - `pkg/schema/fbs/diplomail/*.go` — generated Go bindings (flatc `--go --go-module-name galaxy/schema/fbs`); - `pkg/model/diplomail/diplomail.go` — message-type catalog used by the gateway router; - `ui/frontend/src/proto/galaxy/fbs/diplomail/*.ts` — generated TS bindings consumed by the upcoming UI client wrapper; - `ui/Makefile` `FBS_INPUTS` extended to pick the new schema up on the next `make -C ui fbs-ts` run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
bd11cd80da |
ui/phase-27: root-cause aggregation of duplicate (race, className) rows
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> |
||
|
|
2e7478f5ea |
ui/phase-27: skip phantom frames during play + freeze final layout
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> |
||
|
|
e2aba856b5 |
ui/phase-27: viewer layout pass + static cluster + duel layout
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> |
||
|
|
17a3afd5e9 |
ui/phase-27: viewer polish + phantom-destroy clamp
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> |
||
|
|
8c260f8715 |
ui/phase-27: mass-based circles + cloud cluster + height fit
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>
|
||
|
|
b23649059f |
legacy-report: parse battles + envelope JSON output
Side activity on top of Phase 27: the legacy-report tool now extracts
the "Battle at (#N) Name" / "Battle Protocol" blocks the parser used
to skip. Both the per-battle summary (Report.Battle: []BattleSummary)
and the full BattleReport (rosters + protocol) flow through.
Parser:
- new sectionBattle / sectionBattleProtocol states, with handle()
trapping the per-race "<Race> Groups" sub-headers so the roster
stays attributed to the right race;
- parseBattleHeader extracts (planet, planetName) from
"Battle at (#NN) <Name>";
- parseBattleRosterRow maps the 10-token row into
BattleReportGroup; column 8 ("L") is NumberLeft, confirmed against
KNNTS fixtures;
- parseBattleProtocolLine counts shots and builds
BattleActionReport entries from the 8-token "X Y fires on A B :
Destroyed|Shields" lines;
- flushPendingBattle finalises a battle on next "Battle at" or any
top-level section change and appends both the summary and the
full report;
- syntheticBattleID(idx) + syntheticBattleRaceID(name) synthesise
stable UUIDs in dedicated namespaces so re-runs produce
byte-identical JSON.
Parse() signature widens to (Report, []BattleReport, error); the
single caller — the CLI — is updated.
CLI emits a v1 envelope:
{ "version": 1, "report": <Report>, "battles": { <uuid>: <BR>, ... } }
Bare-Report JSONs still load on the UI side for backward compat.
UI synthetic loader: loadSyntheticReportFromJSON detects the v1
envelope, decodes the report as before, and forwards every battle
through registerSyntheticBattle so the Battle Viewer resolves any
UUID offline. Pre-envelope JSON files (no `version` field) still
load — the battle registry stays empty for them.
Docs: legacy-report README moves Battles from "Skipped" to
in-scope, documents the envelope and UUID namespaces;
docs/FUNCTIONAL.md §6.5 (and the ru mirror) note that synthetic
mode is now end-to-end via the envelope.
Tests:
- TestParseBattles covers two battles with full rosters,
per-shot destroyed/shielded mapping, NumberLeft from column 8,
deterministic UUIDs across re-parses, and proves a trailing
top-level section still parses (battle state closes cleanly);
- smokeWant gains a battles count; runSmoke cross-checks
BattleSummary ↔ BattleReport alignment (id/planet/shots);
- all six real-fixture smoke tests pinned to their `Battle at`
counts (28, 79, 56, 30, 83, 57);
- Vitest covers the synthetic-report envelope path (battles
forwarded, missing-battles tolerated, bare-Report backward
compat);
- KNNTS041.json regenerated against the new parser (existing
diff was stale w.r.t. Phase 23 anyway; this commit brings it
in line with the v1 envelope).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
969c0480ba |
ui/phase-27: battle viewer (radial scene, playback, map markers)
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>
|
||
|
|
2d17760a5e |
ui/phase-26: history mode (turn navigator + read-only banner)
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> |