Commit Graph

129 Commits

Author SHA1 Message Date
Ilia Denisov b07b8fb1c8 test(ui): cargo-routes counts the selection ring in the primitive total
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 2m4s
The F4 selection ring is a real map primitive. The cargo-route flow has
the source planet selected, so the total primitive count is 8 (7 + the
ring circle), not 7; the line count (3) is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:50:42 +02:00
Ilia Denisov 35e27c5aec fix(ui): bottom-sheet tap-outside only fires while the sheet is shown
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Failing after 2m53s
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>
2026-05-22 13:45:41 +02:00
Ilia Denisov 8dcaf1c6c6 feat(ui): error & state UX — error surface, view states, map selection, sheet gestures (F4)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Failing after 7m13s
- 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>
2026-05-22 13:29:11 +02:00
Ilia Denisov 1e62837c68 feat(ui): locale persistence + i18n completeness guards (F3)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m11s
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>
2026-05-22 08:48:13 +02:00
Ilia Denisov 70f2973396 fix(ui): darken light-theme danger to meet AA contrast
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 2m33s
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.
2026-05-22 08:40:38 +02:00
Ilia Denisov e193f3ca88 feat(ui): default theme to system (follow OS light/dark)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Failing after 2m7s
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>
2026-05-22 08:36:17 +02:00
Ilia Denisov 642c5b7322 feat(ui): accessibility pass — WCAG 2.2 AA for login/lobby/shell (F2)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m9s
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>
2026-05-22 08:25:14 +02:00
Ilia Denisov 4ad96b0ef7 feat(ui): migrate all view bodies to design tokens (F1b)
Tests · UI / test (push) Successful in 2m11s
Tests · UI / test (pull_request) Successful in 2m7s
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>
2026-05-22 07:24:02 +02:00
Ilia Denisov 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>
2026-05-22 07:02:13 +02:00
Ilia Denisov a89048f6c5 docs(ui): finalize MVP plan structure and de-archaeologize topic docs
MVP web client (Phases 1-30) is complete; reorganize planning + living docs around that.

- PLAN.md kept as the staged MVP record (1-30) with a status block + pointers; removed the 31-36 stages, regression scenarios, and deferred-TODO section (moved out); fixed a stale cross-machine plan path.

- ui/PLAN-finalize.md (new): active web-finalization plan in 8 stages (visual system, a11y, i18n, error UX, PWA, build hygiene, docs, owner manual-QA loop); absorbs former Phases 33 and 35.

- ui/ROADMAP.md (new): post-MVP (Wails, Capacitor, realistic projection, acceptance + regression scenarios) and triaged deferred follow-ups.

- ui/docs/README.md (new): grouped topic-doc index.

- De-archaeologized all 20 ui/docs topic docs + ui/README.md + ui/core/README.md: stripped Phase-N build history, rewritten as current-state; deferred work now points at ROADMAP.md / PLAN-finalize.md. Docs-only; no code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:17:51 +02:00
Ilia Denisov 4d3cfd11a3 docs(ui): mark Phase 30 (ship-class calculator) done in PLAN.md
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m10s
Tests · UI / test (pull_request) Successful in 1m59s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:40:45 +02:00
Ilia Denisov b1b87c8521 feat(ui-calculator): input validation, load caps, ceil display, modernization layout
Tests · Go / test (push) Successful in 2m26s
Tests · UI / test (push) Successful in 2m26s
- 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>
2026-05-21 21:24:40 +02:00
Ilia Denisov 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>
2026-05-21 20:29:08 +02:00
Ilia Denisov 9ae7b88b89 feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
Tests · UI / test (push) Successful in 2m14s
Tests · Go / test (push) Successful in 2m25s
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>
2026-05-21 20:04:07 +02:00
Ilia Denisov a08f4f55b0 fix(ui-map): cut the visibility fog with an inverse stencil mask (Safari pan perf, stage 2)
Tests · UI / test (push) Successful in 1m57s
Tests · UI / test (pull_request) Successful in 1m56s
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>
2026-05-20 16:53:54 +02:00
Ilia Denisov 51902b995f fix(ui-map): render-on-demand + drop pan inertia to stop the Safari fog freeze
Tests · UI / test (push) Successful in 1m55s
Tests · UI / test (pull_request) Successful in 2m4s
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>
2026-05-20 16:28:18 +02:00
Ilia Denisov 53b892ae00 fix(ui-map): move fog overlay to a viewport-level layer below the copies
Tests · UI / test (push) Successful in 2m50s
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m5s
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>
2026-05-20 00:26:06 +02:00
Ilia Denisov 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>
2026-05-20 00:04:35 +02:00
Ilia Denisov 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>
2026-05-19 23:42:39 +02:00
Ilia Denisov 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>
2026-05-19 23:39:39 +02:00
Ilia Denisov 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>
2026-05-19 22:33:38 +02:00
Ilia Denisov 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>
2026-05-19 22:23:15 +02:00
Ilia Denisov 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>
2026-05-19 22:02:15 +02:00
Ilia Denisov 2bd1b54936 feat(ui): Phase 29 map visibility toggles
Tests · Go / test (push) Successful in 2m31s
Tests · UI / test (push) Failing after 8m7s
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>
2026-05-19 21:33:53 +02:00
Ilia Denisov 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>
2026-05-19 09:29:29 +02:00
Ilia Denisov bde01b1ce2 fix(battle-viewer): unblock synthetic-game battle load
Tests · UI / test (push) Successful in 2m18s
Tests · UI / test (pull_request) Waiting to run
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>
2026-05-19 07:52:26 +02:00
Ilia Denisov a9087691a3 chore(ci): tidy CI/dev infra — drop local-ci, lift migration rule, scope by galaxy.stack label
Tests · Go / test (push) Successful in 2m6s
Tests · Go / test (pull_request) Successful in 3m1s
Tests · Integration / integration (pull_request) Successful in 1m42s
Five connected cleanups across the dev/CI infrastructure:

1. Drop tools/local-ci/. The standalone Gitea + act_runner stack was
   the legacy "offline workflow validator"; the per-stage CI gate now
   runs on gitea.lan and the directory was only retained as a
   fallback. Removing it leaves no operational dependency: backend,
   gateway, and game code have no references; documentation that
   pointed at it (CLAUDE.md, docs/ARCHITECTURE.md, ui/docs/testing.md,
   tools/dev-deploy/README.md, tools/local-dev/README.md) is updated
   in this same change. Historical "Verified on local-ci run N"
   markers in ui/PLAN.md are preserved unchanged.

2. Lift the pre-production single-migration rule. The rule forced
   every schema delta into 00001_init.sql and required a manual
   make clean-data wipe on every backward-incompatible change in
   tools/dev-deploy/. Future schema deltas now land as additive
   sequence-numbered files (00002_*.sql, …) that goose applies
   automatically on backend startup; 00001_init.sql becomes an
   immutable baseline. Authoring conventions live in
   backend/internal/postgres/migrations/README.md. The chain may be
   squashed back into a fresh 00001 as a deliberate one-time
   operation before the first production deployment.

3. Document the deployment cadence. The dev environment is
   single-tenant: pushes to feature/* run the test workflows
   (go-unit, ui-test, integration) only; dev-deploy.yaml fires on
   push to development. A workflow_dispatch override on
   dev-deploy.yaml lets a developer preview a feature branch on the
   shared dev environment before merge; the next merge into
   development overwrites the manual deploy idempotently.

4. Scope compose-managed resources by an explicit
   galaxy.stack=<local-dev|dev-deploy> label. Both compose files
   stamp the label on every service, network, and named volume.
   Makefiles in tools/local-dev/ and tools/dev-deploy/ filter their
   engine-cleanup operations by (stack-label AND engine OCI title)
   so they never touch unrelated workloads on the same daemon.
   dev-deploy.yaml gains a pre-`compose up` step that reaps stale
   exited/dead containers under the dev-deploy stack label.

5. Backend now stamps the same galaxy.stack=<value> label on every
   engine container it spawns, sourced from a new BACKEND_STACK_LABEL
   env var (empty → label not applied; legacy-safe). Both compose
   files set it to their stack name (local-dev / dev-deploy). The
   contract is recorded in docs/ARCHITECTURE.md under
   "Container labels". A package-level test in
   backend/internal/runtime exercises both the label-present and
   label-absent paths.

No tests intentionally regressed: go test ./backend/internal/{config,
runtime,dockerclient} is green, both compose files validate cleanly,
and the backend, gateway, and game modules all build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:32:42 +02:00
Ilia Denisov 2119f825d6 mail UI: dedupe broadcast fan-out and drop in-game admin compose
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 2m6s
Tests · UI / test (pull_request) Successful in 2m24s
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>
2026-05-16 22:38:59 +02:00
Ilia Denisov 81917acc3e dev-deploy: enable Dev Sandbox bootstrap and synthetic-report loader
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m4s
Tests · UI / test (pull_request) Successful in 2m23s
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>
2026-05-16 21:46:24 +02:00
Ilia Denisov 166baf4be0 battle-viewer e2e: mock user.games.battle ConnectRPC command
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 2m0s
Tests · UI / test (pull_request) Successful in 2m23s
Phase 28 moved the battle fetch off the REST passthrough onto the
signed envelope, so the Playwright spec's `page.route(...)` against
the old REST path no longer intercepts anything and the viewer
times out waiting for data. Update the spec to:

- Build a FlatBuffers `BattleReport` payload in
  `fixtures/battle-fbs.ts` (mirrors `report-fbs.ts`'s pattern).
- Add a `user.games.battle` case to the ExecuteCommand mock that
  decodes the FBS `GameBattleRequest`, returns the encoded report
  when the battle_id matches the seeded one, and surfaces a
  canonical `not_found` resultCode otherwise.
- Drop the obsolete REST route stubs.
- Drive the negative-path test with a real UUID that does not match
  the seeded one, so the gateway-side switch is the source of the
  404 (the old `missing-uuid` literal was no longer a valid wire
  shape for the UUID decoder).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 12:55:15 +02:00
Ilia Denisov 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>
2026-05-16 12:41:54 +02:00
Ilia Denisov 1556d36511 Phase 28: mark stage done after CI gate green
Tests · Integration / integration (pull_request) Successful in 1m43s
Tests · Go / test (pull_request) Successful in 2m4s
Tests · UI / test (pull_request) Successful in 2m20s
Gitea runs at commit 6d0272b:
- go-unit #134 → success
- ui-test #136 → success
- integration #135 → success

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:56:29 +02:00
Ilia Denisov 6d0272b078 Phase 28 (Step 11): Vitest coverage for MailStore threading
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m43s
Tests · Go / test (pull_request) Successful in 2m1s
Tests · UI / test (pull_request) Successful in 2m24s
`tests/mail-store.test.ts` exercises the `entries` derived rune
with handcrafted inbox + sent fixtures:

- personal messages exchanged with one race collapse into a
  per-race thread with messages sorted oldest → newest;
- system mail (`sender_kind=system`) and admin notifications
  (`sender_kind=admin`) surface as stand-alone items even when a
  race-name snapshot is present;
- the caller's own paid-tier broadcasts (`broadcast_scope=
  game_broadcast`) render as stand-alone outgoing items;
- `unreadCount` counts inbox rows with `readAt === null`.

The store fields are mutated directly to avoid wiring a fake
`GalaxyClient`; the underlying `$derived` rune fires whenever
those fields change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:50:01 +02:00
Ilia Denisov c48bc83890 Phase 28 (Step 10): docs — diplomail UI topic + FUNCTIONAL mirror
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 2m4s
- `ui/docs/diplomail-ui.md`: new topic doc covering the wire
  surface, recipient-by-race-name decision, threading model,
  translation toggle, push events, badge, layout, and
  accessibility.
- `docs/FUNCTIONAL.md` §11.4 grows a paragraph that records the
  UI's per-race threading rule, the absent read-receipt UX, and
  the recipient-by-race-name compose path. Mirrored verbatim into
  `docs/FUNCTIONAL_ru.md`.
- `ui/PLAN.md` Phase 28 marked done with a "Decisions during
  stage" block matching the implementation plan, and the artifact
  list updated to the actual file set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:48:16 +02:00
Ilia Denisov db81bd8e08 Phase 28 (Steps 7+8): header unread badge + push/init wiring
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 3m25s
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>
2026-05-15 22:46:00 +02:00
Ilia Denisov f7300f25a3 Phase 28 (Steps 6+9): mail active view + i18n keys
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m36s
Tests · Go / test (pull_request) Successful in 3m19s
Tests · UI / test (pull_request) Waiting to run
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>
2026-05-15 22:43:09 +02:00
Ilia Denisov fdd5fd193d Phase 28 (Step 5): MailStore reactive state
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m38s
Tests · Go / test (pull_request) Successful in 3m19s
Tests · UI / test (pull_request) Waiting to run
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>
2026-05-15 22:37:32 +02:00
Ilia Denisov 7378d4c8ed Phase 28 (Step 4): UI api/diplomail.ts wrappers
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Has been cancelled
Tests · Go / test (pull_request) Successful in 2m20s
Tests · Integration / integration (pull_request) Successful in 1m43s
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>
2026-05-15 22:35:21 +02:00
Ilia Denisov 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>
2026-05-15 22:21:23 +02:00
Ilia Denisov 7ff81de2b6 ui/frontend: cap Playwright at 4 workers, retry 4 times
Tests · UI / test (pull_request) Failing after 26s
Tests · UI / test (push) Successful in 2m21s
Under host-mode runner the default 6 workers + 1 retry consistently
land on ~7 flakies and an occasional hard fail per ui-test run
(ui-test #59 most recently). Workers share CPU and the host Docker
daemon with gitea, the long-lived dev stack, and the user's host
Caddy; the extra wall time from contention pushes individual
expectations past their timeouts.

Lower the worker cap to 4 to keep parallelism but give each worker
real CPU headroom, and raise retries to 4 so the rare slow page is
absorbed without surfacing as failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:39:22 +02:00
Ilia Denisov f00c8efd18 docs: sync project guides to the new CI flow
go-unit / test (pull_request) Failing after 30s
integration / integration (pull_request) Failing after 34s
ui-test / test (pull_request) Failing after 37s
Aligns the project guides with the branching/CI/environment changes
landed in the previous commits:

- CLAUDE.md: per-stage CI gate now closes against gitea.lan; describes
  the main/development/feature/* flow and the workflow surface
- docs/ARCHITECTURE.md: new section 18 "CI and Environments" covering
  branches, workflows, and the local-dev / dev-deploy / local-ci
  triad; section numbering shifted accordingly
- tools/local-ci/README.md: marked as fallback (offline / runner
  isolation only)
- tools/local-dev/README.md and ui/README.md: cross-link to
  tools/dev-deploy/ for production-shaped testing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:26:57 +02:00
Ilia Denisov 6921c70df7 ui/phase-27: mark stage done after local-ci run 14
ui-test / test (push) Failing after 11s
ui-test / test (pull_request) Failing after 56s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:59:00 +02:00
Ilia Denisov 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>
2026-05-13 18:52:40 +02:00
Ilia Denisov 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>
2026-05-13 18:16:11 +02:00
Ilia Denisov 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>
2026-05-13 17:38:46 +02:00
Ilia Denisov 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>
2026-05-13 16:44:46 +02:00
Ilia Denisov 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>
2026-05-13 15:51:31 +02:00
Ilia Denisov 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>
2026-05-13 14:22:53 +02:00
Ilia Denisov 659ba00ebf ui/phase-27: mark stage done after local-ci run 7
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:58:34 +02:00
Ilia Denisov 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>
2026-05-13 12:24:20 +02:00