Commit Graph

546 Commits

Author SHA1 Message Date
Ilia Denisov f9f725f657 fix(site): formulas — downgrade markdown-it-mathjax3 to v4 (fix hydration)
Build · Site / build (push) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 1m42s
Build · Site / build (pull_request) Successful in 8s
Tests · Go / test (pull_request) Successful in 3m18s
Tests · UI / test (pull_request) Successful in 3m24s
The duplicate-then-disappearing formulas were a Vue hydration mismatch,
not a CSS problem. markdown-it-mathjax3 v5 pulls the `mathxyjax3` fork,
which emits each formula's CSS as an in-content `<style>` block scoped to
a per-container `#mjx-<id>` that the static build never sets. The orphaned
scoped CSS left the screen-reader MathML twin visible (the duplicate), and
the in-`<main>` `<style>` elements break VitePress/Vue hydration
("Hydration completed but contains mismatches"), which strips the SVG
glyph `<path>`s and blanks every formula after the page finishes loading.

Downgrade to markdown-it-mathjax3 ^4.3.2 — the mathjax-full-based version
VitePress officially supports. It uses `juice` to inline all CSS into the
element `style` attributes (no in-content `<style>`), so hydration is
clean (glyphs survive) and the MathML twin is hidden by its own inlined
style (no duplicate). This also drops the earlier custom.css workaround,
which only treated the symptom and itself blanked the formulas.

Verified with a headless Chromium render of the built /ru/rules: all 10
formulas keep their glyph paths after hydration, no console mismatch, no
duplicate copies.
2026-05-31 17:03:16 +02:00
Ilia Denisov 9b689b2885 fix(site): hide the MathJax assistive-MathML twin (no duplicate formulas)
Build · Site / build (push) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 1m48s
Build · Site / build (pull_request) Successful in 7s
Tests · UI / test (pull_request) Successful in 3m20s
Tests · Go / test (pull_request) Successful in 2m11s
markdown-it-mathjax3 renders each formula as a visible SVG plus a MathML
twin (<mjx-assistive-mml>) for screen readers, hidden via CSS scoped to a
per-container #mjx-<id> selector. In the static build the containers carry
no id, so that scoped rule matches nothing and the twin renders as a
second, oversized (theme-monospaced) copy of every formula, in every
browser. Add a global visually-hidden rule for mjx-assistive-mml in the
theme CSS: the twin stays in the DOM for assistive tech but is removed
from view and from layout.
2026-05-31 16:32:43 +02:00
Ilia Denisov 2a3f31a32b chore: bug screenshot
Tests · Integration / integration (pull_request) Successful in 1m48s
Build · Site / build (pull_request) Successful in 9s
Tests · Go / test (pull_request) Successful in 2m0s
Tests · UI / test (pull_request) Successful in 3m14s
2026-05-31 16:23:01 +02:00
Ilia Denisov 140ee8e0ee docs(site): edit rules for clarity + cross-links; migrate off rules.txt
Build · Site / build (push) Successful in 8s
Tests · Go / test (push) Successful in 2m27s
Tests · UI / test (push) Waiting to run
Tests · Integration / integration (pull_request) Successful in 1m45s
Build · Site / build (pull_request) Successful in 9s
Tests · Go / test (pull_request) Successful in 3m14s
Tests · UI / test (pull_request) Successful in 3m14s
Editorial pass over site/ru/rules.md (on top of the verbatim port):
- moved the lore intro to the RU home page, rewritten in a modern voice;
- fixed typos, replaced the TODO/WTF cargo-tech note and the abandoned
  (---ссылка---) marker with the verified mechanic and a real cross-link,
  dropped the report TODO row;
- wove organic intra-page cross-links (#combat, #movement, #victory, ...);
- documented engine nuances verified against the code: ore auto-farming
  and the capital / "запасы промышленности" store (industry capped at
  population); cargo lost with ships destroyed in battle; and that a
  losing race's colonists at a neutral planet are NOT lost — they stay
  aboard (this corrects the audit note, verified in route.go).

Migration: delete game/rules.txt (its content now lives, authoritative,
in site/ru/rules.md) and repoint every reference to it (ui/frontend code
comments + tests, ui/docs, tools, ui/PLAN.md links). Record the
RU-authoritative rule in site/README.md and CLAUDE.md. The English
site/rules.md mirror follows in a separate stage.
2026-05-31 15:56:00 +02:00
Ilia Denisov d3770e7f77 docs(site): port game rules to site/ru/rules.md (verbatim markdown)
Faithful Markdown rendering of game/rules.txt for the site: headings with
stable anchors, GFM tables and LaTeX formulas — the text itself is
unchanged (typos, the TODO/WTF notes, the broken (---ссылка---) marker and
the lore intro are all preserved as-is). The editorial pass (clarity,
nuances, organic cross-links, intro moved to the home page) follows in a
separate commit so its diff isolates exactly what changed relative to the
original. Registers the page in the RU sidebar.
2026-05-31 14:07:50 +02:00
developer 3d7e4d30bb Merge pull request 'ci(ui-test): clean root-owned build artifacts so runner teardown succeeds' (#84) from feature/ci-ui-test-build-cleanup into development
Tests · UI / test (push) Successful in 3m15s
2026-05-31 10:35:01 +00:00
Ilia Denisov eb549e6049 ci(ui-test): clean root-owned build artifacts so runner teardown succeeds
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 3m24s
In host-mode the ui-test job runs as root, so vite (test:pwa),
svelte-kit and Playwright write build/, .svelte-kit/, test-results/ and
playwright-report/ root-owned into the shared host workspace. The
act_runner (non-root) then cannot remove them at teardown
("unlinkat ui/frontend/build: permission denied"), which spuriously
marks this or a sibling job that inherits the dirty workspace as failed
— it hit go-unit on the #83 merge even though every test passed.

Add an `if: always()` step that removes those generated dirs while the
job still has root, after the artifact uploads. Keeps the shared
workspace clean for the runner's own teardown and for later jobs.
2026-05-31 12:15:32 +02:00
developer bc838d72af Merge pull request 'chore(fbs): pin flatc toolchain to 25.9.23 and guard codegen drift' (#83) from feature/flatc-pin-25-9-23 into development
Tests · FBS codegen / codegen (push) Successful in 5s
Deploy · Dev / deploy (push) Successful in 49s
Tests · Go / test (push) Failing after 1m58s
Tests · Integration / integration (push) Successful in 1m51s
Tests · UI / test (push) Successful in 3m15s
2026-05-31 10:02:23 +00:00
Ilia Denisov 658ab7f6e7 chore(fbs): pin flatc toolchain to 25.9.23 and guard codegen drift
Tests · FBS codegen / codegen (push) Successful in 5s
Tests · Go / test (push) Successful in 2m29s
Tests · FBS codegen / codegen (pull_request) Successful in 6s
Tests · UI / test (push) Waiting to run
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 3m20s
Tests · UI / test (pull_request) Successful in 3m19s
The committed FlatBuffers bindings were generated by flatc 25.x (the TS
runtime is flatbuffers@25.9.23), but nothing pinned the compiler, so a
regen on a box with an older flatc (Debian apt ships 23.5.26) silently
churns output and flips nullable-scalar builder defaults. PR #82 hit this
and shipped 5 report files from the wrong compiler.

Unify the whole toolchain on 25.9.23 (the only version available as an
npm package, a prebuilt flatc binary, and a Go tag) and make the bindings
reproducible:

- Downgrade the flatbuffers Go module 25.12.19 -> 25.9.23 (schema,
  transcoder, gateway, integration) so compiler and both runtimes match.
- Regenerate every schema with flatc 25.9.23. The only resulting change
  is order/command-item.ts: the lone straggler still on the old
  optional-scalar builder default (cmd_applied/cmd_error_code: 0 -> null).
  Inert in practice — the TS side never builds those response-only fields
  (the engine sets them in Go); the reader is unchanged.
- Pin the version in tooling: a flatc-check guard in ui/Makefile (fbs-ts)
  and a new pkg/schema/fbs/Makefile (fbs-go); both refuse a mismatched
  flatc and point at the release binary. Fix the stale apt install hint.
- Add a path-filtered CI guard (.gitea/workflows/fbs-codegen.yaml) that
  regenerates with the pinned flatc and fails on any diff.
- Document the pinned version and the regen commands in the schema README.

No wire-format change: Go build/vet, transcoder roundtrip + engine tests,
pnpm check and the full vitest suite (888) stay green.
2026-05-31 11:51:20 +02:00
developer afb8c1225c Merge pull request 'feat(game): race exit warnings in the turn report (#12)' (#82) from feature/game-race-exit-warnings into development
Deploy · Dev / deploy (push) Successful in 56s
Tests · Go / test (push) Successful in 2m8s
Tests · Integration / integration (push) Successful in 1m51s
Tests · UI / test (push) Successful in 3m53s
2026-05-31 09:12:33 +00:00
Ilia Denisov 9e9977d5f1 feat(game): race exit warnings in the turn report (#12)
Tests · Go / test (push) Successful in 2m29s
Tests · UI / test (push) Waiting to run
Tests · Go / test (pull_request) Successful in 2m17s
Tests · Integration / integration (pull_request) Successful in 1m53s
Tests · UI / test (pull_request) Successful in 3m37s
Surface the inactivity-removal countdown the rules promise but the
engine never reported. A race within five turns of being auto-removed
for inactivity gets a personal warning in its own report; every race
within three turns is listed publicly to all participants.

- model: Report.PersonalExitWarning + RacesLeavingSoon ([]RaceExitNotice)
- fbs: RaceExitNotice table + Report.personal_exit_warning /
  races_leaving_soon (regenerated Go + TS bindings)
- transcoder: encode/decode both fields
- engine: ReportExitWarnings fills the recipient's TTL (1..5) and lists
  other non-extinct races with TTL 1..3, excluding the recipient itself
- ui: danger-styled personal banner + "races leaving soon" section
  (hidden when empty), wired into the report view, EN/RU i18n
- docs: rules.txt report-section list, FUNCTIONAL.md 6.4 + RU mirror

Voluntary quit and idle timeout share the TTL countdown and are not
distinguished, per the agreed scope.
2026-05-31 10:34:50 +02:00
developer 9dce15c7bb Merge pull request 'fix(game): small reconciliation fixes (science, generation, dismantle, report)' (#81) from feature/game-small-fixes into development
Deploy · Dev / deploy (push) Successful in 49s
Tests · Go / test (push) Successful in 2m4s
Tests · Integration / integration (push) Successful in 1m45s
2026-05-31 07:35:59 +00:00
Ilia Denisov dc621cc715 fix(game): small reconciliation fixes (science, generation, dismantle, report)
Tests · Go / test (push) Successful in 1m58s
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m1s
A bundle of small rules-vs-engine corrections:

- Science proportions: accept a sum that equals 1 only up to float
  rounding (was an exact != 1 comparison); the rules example is reworded
  so it is unambiguous that proportions are fractions summing to 1.
- Generation: super-big planets get a resource strictly above 0 (minimum
  0.001, was a hard 0.1); the rules table is fixed for big planets (1-10,
  not 0.1-10) and the false "0.1-20 / average 1.5" resource claim removed.
- Dismantle over a neutral planet now unloads the colonists and settles
  it (the planet becomes the race's); over a foreign planet they are
  still lost. The rules clause is clarified for own / neutral / foreign.
- Report: ship-production entries are written at the compacted report
  index (was the planet's map index, which could write past the grown
  slice and panic); the incoming-group "remaining distance" is measured
  from the group's current hyperspace position, not its origin planet
  (matching OtherGroup).
- validator: the cargo-value error now carries the cargo value, not the
  shields value.

Tests added for each behavioural fix; rules.txt updated in the same patch.
2026-05-31 09:29:07 +02:00
developer bef6c46a1c Merge pull request 'fix(game): release a banished race's assets during turn generation' (#80) from feature/game-banish-wipe into development
Deploy · Dev / deploy (push) Successful in 45s
Tests · Integration / integration (push) Successful in 1m39s
Tests · Go / test (push) Successful in 3m6s
2026-05-31 07:17:16 +00:00
Ilia Denisov 3b1c52cd02 fix(game): release a banished race's assets during turn generation
Tests · Go / test (push) Successful in 1m57s
Tests · Integration / integration (pull_request) Successful in 1m49s
Tests · Go / test (pull_request) Successful in 2m2s
TurnWipeExtinctRaces iterated only non-extinct races, so an
administratively banished race (flagged extinct, TTL untouched) was never
wiped: its planets stayed owned and its ships lingered, while the race
itself could no longer act. The loop now covers every race and wipes when
either an active race's TTL has run out (idle / quit) or an extinct race
still holds assets (banish). The asset check makes repeated passes
idempotent.

wipeRace already matched the rules for exclusion (ships removed, planets
uninhabited, industry and capital cleared, material retained), so the
behaviour is just documented in game/README.md.

Tests: banish releases planets and ships on the next turn (and is
idempotent); idle-timeout wipe still fires under the new iterator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 09:03:01 +02:00
developer 200236369f Merge pull request 'fix(game): bomb in descending power order, collapse industry on wipe' (#79) from feature/game-bombing-order into development
Deploy · Dev / deploy (push) Successful in 48s
Tests · Go / test (push) Successful in 2m11s
Tests · Integration / integration (push) Successful in 1m43s
2026-05-31 06:59:44 +00:00
Ilia Denisov a01f39e4a7 fix(game): bomb in descending power order, collapse industry on wipe
Tests · Go / test (push) Successful in 1m57s
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 2m2s
Per the rules ("Бомбардировка планет"), a planet is bombed from the
strongest attacking power downwards, and a planet bombed to extinction
keeps its material and capital stockpiles but loses its working industry.

ProduceBombings now sorts attacking races by total bombing power
(descending) instead of iterating the attacker map in random order, and
on a wipe zeroes the planet's industry (Free already keeps capital and
material). bombingPower is extracted as a shared helper.

The rules already describe both, so no documentation change. Tests:
bombing order by power, and industry collapse with capital/material kept
on a wipe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 08:47:45 +02:00
developer 6c00a24577 Merge pull request 'fix(game): gate group visibility by visibility range, report battle classes' (#78) from feature/game-visibility-report into development
Deploy · Dev / deploy (push) Successful in 48s
Tests · Go / test (push) Successful in 2m8s
Tests · Integration / integration (push) Successful in 1m41s
2026-05-31 06:42:44 +00:00
Ilia Denisov 6ec1098f15 fix(game): gate group visibility by visibility range, report battle classes
Tests · Go / test (push) Successful in 1m55s
Tests · Integration / integration (pull_request) Successful in 1m49s
Tests · Go / test (pull_request) Successful in 2m6s
Bring the report's foreign-group and foreign-class visibility in line
with the rules (game/rules.txt "Движение" and the report sections):

- incoming groups (heading to one of the recipient's planets) are shown
  only within the recipient's visibility range (driveTech*30); beyond it
  a group is hidden even though it is inbound;
- the unidentified-group list now uses the visibility range (it used the
  flight range, driveTech*40), excludes groups heading to the recipient's
  planets (those belong to the incoming list), and reports each group
  once (it previously emitted an entry per in-range owned planet);
- ship classes met in a battle the recipient took part in or witnessed
  now appear in OtherShipClass, with the design looked up from the owner
  race's ship types (the battle report carries only the class name).

The rules already describe this behaviour and the report wire shape is
unchanged, so no documentation change. Tests added for all three.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 08:36:33 +02:00
developer f877a199c2 Merge pull request 'fix(game): fight before departure and reorder the turn sequence' (#77) from feature/game-turn-order-departures into development
Deploy · Dev / deploy (push) Successful in 48s
Tests · Integration / integration (push) Successful in 1m42s
Tests · Go / test (push) Successful in 3m9s
2026-05-31 06:29:07 +00:00
Ilia Denisov 53b3cafbc4 fix(game): charge a ship upgrade against production only once
Tests · Go / test (push) Successful in 2m7s
Tests · Go / test (pull_request) Successful in 2m10s
Tests · Integration / integration (pull_request) Successful in 1m41s
TurnPlanetProductions started its production budget from
PlanetProductionCapacity, which already subtracts the reserved upgrade
cost, and then subtracted each applied upgrade's cost again in the apply
loop — charging every applied upgrade twice. That both starved the
planet's build/research budget and could skip upgrades that were in fact
affordable.

The budget now starts from the planet's full production potential and the
apply loop deducts each upgrade once; PlanetProductionCapacity stays the
report's net-of-upgrades "free L".

Test: TestUpgradeDoesNotDoubleChargeProduction; the TestProduceShips MAT
expectation is updated to the once-charged value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 08:24:46 +02:00
Ilia Denisov b4abf90ec5 fix(game): fight before departure and reorder the turn sequence
Tests · Go / test (push) Successful in 1m58s
Tests · Integration / integration (pull_request) Successful in 1m50s
Tests · Go / test (pull_request) Successful in 2m5s
Per the documented turn order (game/rules.txt "Последовательность
действий"), no ship should dodge the pre-departure battle by slipping
into hyperspace. MakeTurn now runs merge -> battle -> load+launch routed
groups -> fly -> merge -> battle, so:

- ships ordered to depart (Launched) and ships being upgraded now take
  part in the pre-departure battle at their planet (CollectPlanetGroups /
  FilterBattleGroups); only survivors then enter hyperspace;
- routed transports are loaded and launched AFTER that battle, so they
  fight empty and cannot escape it.

A just-launched group has no stored hyperspace position, so moveShipGroup
starts its first leg from the origin planet; the previous code read the
nil launch coordinate and would panic.

Because upgrading groups can now lose ships in the battle, the pending
upgrade cost is recomputed from the group's current ship count instead of
the value stored when the order was validated.

Rules: reordered "Последовательность действий" and rewrote the combat
note that ordered/routed ships skip the battle.

Tests: launched-group move from origin, launched/upgrade groups taking
part in battle, upgrade cost tracking ship losses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:25:46 +02:00
developer 5e86ca9999 Merge pull request 'fix(game): resolve battles ship by ship, matching the combat rules' (#76) from feature/game-combat-correctness into development
Deploy · Dev / deploy (push) Successful in 47s
Tests · Go / test (push) Successful in 2m3s
Tests · Integration / integration (push) Successful in 1m46s
2026-05-30 22:06:44 +00:00
Ilia Denisov cc67364113 fix(game): resolve battles ship by ship, matching the combat rules
Tests · Go / test (push) Successful in 2m2s
Tests · Integration / integration (pull_request) Successful in 1m48s
Tests · Go / test (pull_request) Successful in 2m0s
The battle engine diverged from the documented combat model
(game/rules.txt "Сражения") in three ways:

- the destruction roll was inverted (rand >= p), so a near-certain hit
  destroyed its target only ~(1-p) of the time;
- a whole group fired as a single ship (Armament shots per round)
  regardless of its ship count, so fleet size never affected offence;
- the defending mass used the whole group's full mass instead of one
  target ship's, weakening grouped ships' shields by ~Number^(1/3).

SingleBattle now resolves ship by ship: every living ship fires once per
round in random order across all groups, each gun targets a random enemy
ship (weighted by group size), and the destruction roll matches the
documented probability. FilterBattleOpponents evaluates per-ship mass.

Also fixes opponent-map initialisation in ProduceBattles that kept only
an attacker's last opponent.

The rules already describe this model, so no documentation change is
needed. Tests: per-ship one-sided wipe, destruction-roll direction, and
the updated per-ship-mass probability expectation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:57:27 +02:00
developer 97b5535c02 Merge pull request 'chore(cleanup): purge /command residuals — fakeEngine, canon golden, openapi' (#75) from feature/post-command-cleanup into development
Deploy · Dev / deploy (push) Successful in 47s
Tests · Go / test (push) Successful in 2m6s
Tests · Integration / integration (push) Successful in 1m51s
Tests · UI / test (push) Successful in 3m20s
2026-05-30 17:54:47 +00:00
Ilia Denisov bde9d535dc chore(cleanup): purge /command residuals — fakeEngine, canon golden, openapi
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 2m4s
Tests · Go / test (push) Successful in 2m28s
Tests · UI / test (push) Successful in 3m22s
Follow-up tidy after the cross-service /command removal (#73):

- Rename the router test double dummyExecutor -> fakeEngine (and the
  newExecutor / setupRouterExecutor helpers -> newFakeEngine /
  setupRouterEngine): it implements handler.Engine now, "executor" was a
  leftover of the removed adapter. Test-only.

- Regenerate the ui/core canon signing golden onto user.games.order
  (request_user_games_command.json -> request_user_games_order.json, fresh
  canonical bytes + Ed25519 signature) and drop the last
  user.games.command references from the Go/TS tests and docs.

- Align game openapi: CommandRequest.cmd no longer carries minItems: 1. It
  is now used only by PUT /api/v1/order, which accepts an empty batch
  (clearing the player's stored order, equivalent to removing every
  command); the contract test freezes the empty-allowed shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:16:17 +02:00
developer 40d6ba6ba4 Merge pull request 'fix(backend): retry migrations on transient connection errors' (#74) from feature/pg-migration-transient-retry into development
Deploy · Dev / deploy (push) Successful in 54s
Tests · Integration / integration (push) Successful in 1m44s
Tests · Go / test (push) Successful in 3m7s
2026-05-30 12:51:40 +00:00
Ilia Denisov 06a2e631c9 fix(backend): retry migrations on transient connection errors
Tests · Go / test (push) Successful in 2m1s
Tests · Go / test (pull_request) Successful in 3m0s
Tests · Integration / integration (pull_request) Successful in 1m42s
Backend e2e tests (and, more rarely, service startup) intermittently
failed applying migrations with `driver: bad connection`: a freshly
started Postgres — notably a test container — can reset a pooled
connection moments after it reports ready, killing the migration
transaction. The harness already waits for the double "ready" log and
pings before migrating, yet goose can still draw a connection postgres
then resets.

ApplyMigrations now wraps the schema-create + goose run in a bounded
retry that fires only on transient connection errors (driver.ErrBadConn
and the connection-failure messages Postgres drivers surface); both
steps are idempotent, so a retry resumes cleanly. Deterministic SQL
errors still fail fast.

Fixes the intermittent TestDiplomailAsyncFallbackOnUnsupportedPair (and
the eight other testcontainer e2e harnesses that share ApplyMigrations).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:45:53 +02:00
developer 2f55fc4988 Merge pull request 'refactor(game): lock-free storage, remove /command, flatten engine wrapper' (#73) from feature/game-engine-refactor into development
Deploy · Dev / deploy (push) Successful in 45s
Tests · Go / test (push) Failing after 1m53s
Tests · Integration / integration (push) Successful in 1m53s
Tests · UI / test (push) Successful in 3m29s
2026-05-30 12:23:28 +00:00
Ilia Denisov 601970b028 refactor(game): lock-free storage, remove /command, flatten engine wrapper
Tests · Go / test (push) Successful in 2m27s
Tests · UI / test (push) Waiting to run
Tests · Integration / integration (pull_request) Successful in 1m45s
Tests · Go / test (pull_request) Successful in 3m13s
Tests · UI / test (pull_request) Successful in 3m8s
Three-stage refactor of the game-engine plumbing (game logic untouched):

Stage 1 — lock-free persistence + admin serialisation. Remove the file
lock from repo/fs (the .lock file, the Read/Write-vs-*Safe duality and the
dead ReadSafe polling) and replace the two-step rename with a single atomic
rename so concurrent reads are torn-free without a lock. Serialise the
state-mutating admin writers (init/turn/banish) with one shared router
LimitMiddleware, rewritten to block on the request context instead of a
racy shared 100ms timer.

Stage 2 — remove the obsolete immediate-command path end to end. Players
submit through PUT /api/v1/order; the legacy PUT /api/v1/command path is
deleted across game (route, handler, 24 command factories, Ctrl), backend
(Commands handler/route, engineclient.ExecuteCommands), gateway (dispatch +
executeUserGamesCommand + routing entry), the FlatBuffers/model contract
(UserGamesCommand[Response]) and transcoder, plus every affected
OpenAPI/README/FUNCTIONAL/ARCHITECTURE doc. The integration proxy test is
converted to the order path.

Stage 3 — flatten the REST->engine wrapper. Replace the executor adapter,
the controller package functions and RepoController with one concrete
controller.Service; drop the single-implementation Repo and Storage
interfaces (repo.Repo / fs.FS are now concrete). Handlers depend on a thin
handler.Engine seam and own the domain->REST projection; storage is
resolved once at startup instead of per request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:37:07 +02:00
developer e36d33482f Merge pull request 'feat(game): canonical gameId in POST /api/v1/admin/init' (#72) from feature/canonical-game-id-init into development
Deploy · Dev / deploy (push) Successful in 54s
Tests · Go / test (push) Successful in 2m13s
Tests · Integration / integration (push) Successful in 1m46s
2026-05-29 11:29:20 +00:00
Ilia Denisov 15d35f6f1f feat(game): canonical gameId in POST /api/v1/admin/init
Tests · Go / test (push) Successful in 1m57s
Tests · Integration / integration (pull_request) Successful in 1m48s
Tests · Go / test (pull_request) Successful in 2m0s
Engine no longer mints its own game UUID. The orchestrator (backend)
generates the game UUID at game-create time and passes it in the
admin/init request body as the required `gameId` field, so the value
that names the engine container and host bind-mount directory also
ends up inside the engine's state.json.

The engine rejects the zero UUID with 400 and any init that conflicts
with an existing state.json with 409 (a second init on the same gameId
is also a conflict; full idempotency is not part of the contract).

Updates rest.InitRequest, openapi.yaml (schema + 409 response),
controller.GenerateGame/NewGame/buildGameOnMap signatures, the engine
HTTP handler/executor, the backend runtime worker, and the relevant
unit and contract tests. Documentation in game/README.md,
docs/ARCHITECTURE.md, backend/README.md, and backend/docs/{runtime,flows}.md
is updated in the same patch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 13:13:31 +02:00
developer 4a7bf0be61 feat(game): #59 — per-command rejection on PUT /api/v1/order (#71)
Deploy · Dev / deploy (push) Successful in 46s
Tests · Integration / integration (push) Successful in 1m47s
Tests · UI / test (push) Successful in 3m32s
Tests · Go / test (push) Successful in 2m34s
Closes #59. Engine returns 202 + per-command cmdApplied/cmdErrorCode/cmdErrorMessage instead of blanket 500; pkg/error consts reshelved onto 1xxx/2xxx/3xxx; UI keeps sync banner green on per-command rejection, surfaces the engine reason inline, and hydrates per-command verdicts from the server on game re-entry. Dev-deploy now recycles game containers when galaxy-engine:dev SHA drifts.
2026-05-29 10:18:15 +00:00
Ilia Denisov 2ffd7527a6 test(ui): align rejected-submit e2e specs with per-command sync semantics
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 1m59s
Tests · UI / test (pull_request) Successful in 3m8s
The `rename-planet` and `ship-classes` rejected-submit specs broke on
the previous commit because:

1. `tests/e2e/fixtures/order-fbs.ts` builds the FBS response without
   `forceDefaults(true)`, and flatbuffers@25's TS codegen now elides
   `cmd_applied=false` against its int8 default of 0. The encoded
   payload no longer carried the rejection, so the UI decoded the row
   as `applied` and the assertions on the `rejected` status text
   failed first. The production Go transcoder already force-slots
   the field; mirror that behaviour in the e2e fixture.
2. The specs themselves still asserted the old blanket
   `data-sync-status="error"` on per-command rejection. After the
   previous commit's behaviour change the bar stays `synced` for
   per-command rejection (only genuine transport failures keep the
   red banner + Retry), so the assertions now read the row's inline
   reason text instead.

`tests/e2e/fixtures/order-fbs.ts` also gains the `cmdErrorMessage`
field so future fixtures can mirror the engine's rejection reason
through the round trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 12:11:50 +02:00
Ilia Denisov 723885e74e fix(order): surface rejection reason, keep sync green, hydrate verdicts
Tests · UI / test (push) Has been cancelled
Tests · Go / test (push) Successful in 2m3s
Tests · Go / test (pull_request) Successful in 2m5s
Tests · Integration / integration (pull_request) Successful in 1m44s
Tests · UI / test (pull_request) Failing after 4m28s
Three issues surfaced once the per-command rejection from the previous
commit actually reached the UI:

1. Sync banner falsely red. `OrderDraftStore.runSync` flipped
   `syncStatus = "error"` whenever any command was rejected and
   advertised a Retry button. A per-command rejection is a
   player-correctable state — the round trip succeeded, the engine
   just refused that command — so the retry can't help. Keep
   `syncStatus = "synced"` on `success`; the red row highlight is
   the visible cue.

2. Rejection reason missing. Add `cmd_error_message: string` to
   `CommandItem` in `pkg/schema/fbs/order.fbs` (appended last to
   preserve existing slot offsets) and regenerate the Go + TS stubs
   for that one type. Plumb the message through `CommandMeta`,
   `Controller.applyCommand`'s `m.Result(code, message)` call, the
   Go transcoder, the UI decoders in `submit.ts` /  `order-load.ts`,
   and the `OrderDraftStore.errorMessages` map. `order-tab.svelte`
   renders it as an italic danger-coloured line under rejected
   commands, with new CSS for `.error-reason`.

3. Verdict lost on navigation. `order-load.ts.decodeCommand` never
   read `cmdApplied`/`cmdErrorCode`, so `hydrateFromServer` fell
   back to a blanket "applied" status — a previously-rejected
   command came back green after a lobby → game round trip. Extend
   the fetch decoder to populate `statuses`/`errorCodes`/
   `errorMessages` maps and have `hydrateFromServer` use them.
   Engine-side persistence already records the verdict on disk —
   verified against the live `0000/order/<id>.json`.

`flatbuffers@25` elides default-int8/int64 fields on write; the Go
transcoder force-slots `cmd_applied=false` / `cmd_error_code=0`
already, the new test fixtures flip `builder.forceDefaults(true)` to
mirror that behaviour so the round trip survives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 11:42:27 +02:00
Ilia Denisov e038ea6154 fix(dev-deploy): recycle engine containers on galaxy-engine:dev SHA drift
Tests · Integration / integration (pull_request) Successful in 1m48s
Tests · Go / test (pull_request) Successful in 2m1s
`backend`'s reconciler adopts pre-existing `galaxy-game-*` containers
without comparing their image SHA against the freshly-built
`galaxy-engine:dev`, so a long-lived sandbox would otherwise keep
serving the previous engine code after a redeploy. Issue #59 surfaced
this: after the per-command-rejection fix was deployed via
`workflow_dispatch`, the running sandbox container was still on the
old image SHA and the browser kept seeing the 503/unavailable response.

Adds a `Recycle engine containers on image drift` step right before
`Reap stray dev-deploy containers`. The step compares the new
`galaxy-engine:dev` SHA against every running `galaxy-game-*`
container and, on drift, stops the backend, removes the container,
wipes the bind-mounted per-game state directory (Engine.Init() writes
turn-0 over any pre-existing `turn-N` files — silent state corruption
otherwise), and cascade-deletes the lobby `games` row. The
`dev-sandbox` bootstrap on the next backend boot finds no live
sandbox and provisions a fresh one on the new engine image.

When the engine sources are unchanged, the BuildKit cache hits and
the SHA stays the same — the recycle step is a no-op and the running
games keep their state across the deploy. Verified end-to-end against
the live dev environment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 10:47:25 +02:00
Ilia Denisov af30846091 fix(game): #59 — per-command rejection on PUT /api/v1/order
Tests · Go / test (push) Successful in 2m2s
Tests · Go / test (pull_request) Successful in 3m3s
Tests · Integration / integration (pull_request) Successful in 1m40s
Validation of a player's order now applies every command against a
transient game-state snapshot and records the per-command outcome
(cmdApplied, cmdErrorCode) in each command's meta. The order is
persisted even when some commands are rejected, and the response is
202 + UserGamesOrder so clients can surface the partial failure
without the chain collapsing into "downstream service is unavailable".

Pkg/error consts are reshelved onto three explicit ranges with a
package doc and helpers (IsInternalCode/IsInputCode/IsGameStateCode):
1xxx internal/server (500/501), 2xxx structural input (400), 3xxx
game-state per-command rejection (400 when escaping HTTP, otherwise
recorded as cmdErrorCode). Two pre-existing typos fixed mechanically
(ErrBeakGroupNumberNotEnough -> ErrBreakGroupNumberNotEnough,
ErrRaceExinct -> ErrRaceExtinct) along with all callsites.

Engine errorResponse maps *GenericError by shelf rather than mapping
everything to 500. The Quit-not-last structural check in
Controller.ValidateOrder is preserved and its type assertion fixed
(was a value assertion against a pointer-typed command, so the check
silently never fired).

Backend, gateway and UI are unchanged — they were already correct on
the 202 path; only the engine collapsing per-command rejection into
500 was needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 09:36:29 +02:00
developer ce1dc19a29 Merge pull request 'feat(ui): F8-12 — map polish (#55)' (#70) from feature/issue-55-map-polish into development
Deploy · Dev / deploy (push) Successful in 51s
Tests · UI / test (push) Successful in 3m12s
Closes #55.
2026-05-28 12:21:17 +00:00
Ilia Denisov a37b784452 perf(ui): F8-12 — toggle responsiveness on 700-planet legacy reports (#55)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m11s
Profiling KNNTS041 (700 planets, 1283 primitives, 29 LOCAL fog
circles) flushed three independent costs out of the toggle path:

* `setVisibilityFog` rebuilt the inverse mask + 29 × 9 paint ops on
  every effect run, even when the input was identical. Caches a
  fingerprint of the circles + wrap mode and bails on a no-op
  call — knocks ~1 ms off every flip, more on heavier maps.
* `paintLabelEntry` was split into `paintLabelLayout` (hit-area /
  line positions / frame geometry — runs on every content change)
  and `paintLabelSelection` (text fills + frame visibility — runs
  only when the selection identity actually flips). The incremental
  path now skips the 6300 redundant `Text.style.fill = ...` writes
  it used to perform on every `planetNames` flip, which is what
  forced Pixi to invalidate the underlying text textures.
* `applyLabelContent` no longer blanks `nameText.text` when the
  toggle hides the name — it just flips `visible`. The cached text
  texture survives, so the next paint frame skips ~700 texture
  rebuilds.

Also enables Pixi-side culling on every per-copy primitive / outline
/ label container. With 9 torus copies × ~700 planets the scene
graph holds thousands of nodes, most of which sit outside the
visible viewport at any moment — the cullable flag lets Pixi skip
them in the per-frame traversal.

The legacy `KNNTS041` probe (chromium-desktop, headless) shows
`applyVisibilityState` collapsing from ~24 ms to ~5 ms after a
cache-warm flip; `app.render` drops from ~46 ms to ~22 ms. Reading
the toggle delay end-to-end inside the browser still measures
~460 ms in headless, which is consistent with the runner's RAF
cadence — owner can confirm on the real machine where the previous
~1 s delay was reported.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:10:29 +02:00
Ilia Denisov f4670c1831 perf+fix(ui): F8-12 — max-zoom clamp + planet-names toggle responsiveness (#55)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m16s
* Max-zoom clamp: `MIN_VISIBLE_WORLD_AT_MAX_ZOOM = 5` world units on
  the longest viewport axis. Tuned against the owner's
  debug-overlay readings — mobile longest ≈ 412 px clamps at
  scale ≈ 82, desktop longest ≈ 1200 px clamps at scale ≈ 240.
  Same formula adapts to both shapes automatically; no separate
  mobile / desktop branch.
* Planet-names toggle no longer rebuilds every Pixi.Text on a flip.
  When `setPlanetLabels` sees the same planet set (which is the
  common case — only the `name` lines toggling on / off), it walks
  the live label containers and just retunes text content +
  visibility instead of destroying and recreating 9 × N Text
  instances. A 500-planet map flips the toggle inside a frame now.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:58:43 +02:00
Ilia Denisov 4d729c1f50 feat(ui): F8-12 — smooth planet discs + ?debug=1 overlay (#55)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m18s
* Planet discs (and every other circle the renderer draws —
  outlines, picker hover ring, reach / bombing rings, etc.) trace
  a fixed 32-segment polygon instead of leaning on Pixi's adaptive
  bezier subdivision. PixiJS v8 picks the segment count from the
  world-space radius, which collapsed to 6-8 segments once the
  parent container's scale climbed — so the planet read as a
  visible polygon at high zoom. The custom path stays cheap (~64
  floats per disc) and gives a perceptually round silhouette at
  every zoom level.
* Opt-in dev overlay activated by `?debug=1` in the URL. A small
  bottom-left panel shows the current `scale`, the
  "whole world fits" reference scale, the current zoom ratio
  (scale / scale_ref), and the world-units rectangle visible in
  the viewport — so the owner can decide what `maxScale` to clamp
  to on the next iteration without guessing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:33:23 +02:00
Ilia Denisov 24d75564bb fix(ui): F8-12 — owner feedback round 3 (#55)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m28s
* Hit-test: a click inside a planet's visible disc always picks the
  planet, regardless of overlapping route shafts or battle X-crosses
  with higher base `priority`. Closes the #1, #2, #4 reports
  (picker hover would only catch the circumference, planet+routes
  swallowed disc clicks, label click on a battled planet routed to
  the battle viewer). Slop-only hits (cursor near a line but not on
  any disc) still use the existing priority order.
* Labels and planet outlines render in all nine torus copies again
  so they follow the player into wrap tiles — closes #3 (labels
  vanished on the wrong half of the viewport whenever the camera
  was panned past the wrap seam). The fingerprint guard keeps the
  per-toggle / per-selection rebuild cheap.
* Pixi.Text gets a few px of `padding` so the rasteriser no longer
  clips the last letter on a half-pixel measurement — closes #5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 10:17:08 +02:00
Ilia Denisov eb5018342e feat(ui): F8-12 — owner feedback round 2 (#55)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m18s
* Bug fix: theme flip no longer leaves planets oversized. The
  camera-preserving remount now calls a new
  `RendererHandle.refreshCameraDerivedDraws` explicitly after the
  manual moveCenter/setZoom pair so the post-mount geometry tracks
  `viewport.scaled` even if pixi-viewport's `'zoomed'` listener
  races the next Ticker tick.
* Doc #3: clicks on a planet label route through the same hit-test
  path as a click on the disc. The label `Container` now has a
  pointer hit area sized to the text + frame padding; pointertap
  simulates a click at the planet centre, so selection and
  pick-mode resolution behave identically.
* Doc #4: battle X-crosses + cargo arrowhead wings grow
  sub-linearly with zoom (PLANET_SIZE_ZOOM_ALPHA). New
  `Style.softLengthAnchor` ('center' / 'start') makes the renderer
  treat the recorded endpoints as the geometry "at the reference
  scale" and rescale around the midpoint (X-cross) or the start
  endpoint (arrow wings). Arrowhead base length is halved from 6
  to 3 world units to match the owner's "in half" request.
* Doc #5: picker overlay loses the anchor ring at the source, the
  cursor line drops to a cargo-route-thin 0.6 px stroke, and the
  hover ring around the destination is replaced by a planet-style
  outline (visible disc + 1 px padding) in the `pickHighlight`
  accent — so candidate destinations read like selection in warm
  yellow.
* Doc #6: regression test pins the in-disc hit zone.
* Perf #1: camera-driven redraws are throttled onto the next
  Ticker tick. A rapid wheel / pinch burst now coalesces into at
  most one `clear() + redraw` pass per painted frame, which keeps
  the 500-planet map responsive on zoom and toggle flips.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:40:20 +02:00
Ilia Denisov 6c3cd25476 add converted legacy report
Tests · UI / test (pull_request) Successful in 3m25s
2026-05-28 08:53:27 +02:00
Ilia Denisov 6996a79286 perf(ui): F8-12 — pixel-space planet sizing + single-copy label/outline layers (#55)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m20s
* Planet size formula moves to pixel-space:
  `pointRadiusBasePx = 2 + 2 * cbrt(size / SIZE_NORMALIZER)`. The
  on-screen disc now reads ~4-7 px at the reference zoom regardless
  of how large the world rectangle is — the previous `world-units`
  formulation blew up on small maps and made Source-class planets
  swallow their neighbours.
* Labels + outlines live in the origin copy only. The 9× replication
  across torus copies was the dominant cost on a 100+ planet map
  (Pixi.Text creation + Graphics rebuilds on every zoom step); the
  origin-copy layout is what the camera-wrap listener guarantees
  the user actually sees.
* `setPlanetLabels` and `setPlanetOutlines` skip Pixi-object
  rebuilds when the input fingerprint is unchanged — toggle flips
  and selection changes now keep the existing Text / Graphics
  instances alive and only repaint the affected pieces.
* `renderer.md` updated to the new contract.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:39:19 +02:00
Ilia Denisov 75a4211373 fix(ui): F8-12 — settle e2e regressions from the polish PR (#55)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m25s
* state-binding.ts: normalise planet size by the engine's typical
  mid-range (`SIZE_NORMALIZER = 100`) so legacy fixtures recording
  Size in the hundreds do not blow up the world-unit disc and start
  overlapping neighbouring planets. The cube-root growth stays;
  Size-800 reads twice as big as Size-100.
* cargo-routes.spec.ts: retire the selection-ring CirclePrim from
  the expected primitive count (4 planets + 3 cargo arrow lines = 7).
* map-toggles.spec.ts: bombing-rings → planet outlines (the high-bit
  0xc… range is permanently empty); planet-names persist test waits
  for the renderer's debug providers and for the IndexedDB write to
  flush before reload.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:14:14 +02:00
Ilia Denisov 680ebac919 feat(ui): F8-12 — map polish (zoom invariance, labels, selection, soft radius) (#55)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Failing after 5m16s
* Honest pixel-space sizing for `pointRadiusPx` / `strokeWidthPx`: the
  renderer divides by the current camera scale on every
  `viewport.zoomed` so thin lines / small markers stay the same on-screen
  size at any zoom.
* Known-size planets switch to `pointRadiusWorld`, softened against the
  reference scale by `PLANET_SIZE_ZOOM_ALPHA = 0.33`; unidentified
  planets pin to a 3-px disc.
* New planet label layer renders a two-line `name / #N` legend under
  each planet (`#N` only for unidentified or when the new `planetNames`
  toggle is off). Selection now paints an inverse-fill frame around the
  selected planet's label plus an outline on the disc; the old
  selection-ring primitive is retired.
* Bombing markers swap the separate CirclePrim for a planet-outline
  overlay (damaged / wiped colour); the report deep-link moves to a
  "view bombing report" link in the planet inspector.
* Docs + tests follow: `renderer.md` reflects the new sizing contract +
  label / outline layers, vitest covers the sizing math, label
  formatting, and the new toggle, and the map-toggles e2e adds a
  persistence case for `planetNames`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 23:51:16 +02:00
developer ba93a9092e Merge pull request 'feat(ui): F8-11 — battles table under table submenu (#54)' (#69) from feature/issue-54-battles-table into development
Deploy · Dev / deploy (push) Successful in 48s
Tests · UI / test (push) Successful in 2m50s
2026-05-27 20:29:52 +00:00
Ilia Denisov 209f8508cd feat(ui): F8-11 — battles table under table submenu (#54)
Tests · UI / test (push) Successful in 2m53s
Tests · UI / test (pull_request) Successful in 3m0s
Adds a sortable battles list as a new entity under the existing
`view → table` submenu (entity slug `battles`), replacing the
standalone top-level `battle log` shortcut which always opened a
"battle not found" placeholder. The single-battle viewer stays put
and is reached only by clicking a row (or a battle marker on the
map), identical to the existing `section-battles.svelte` flow.

Columns are planet (via the shared `planetLabel` helper) and shots
(the per-battle action count carried by `BattleSummary`), sortable
both ways with shots-desc default. No backend / FBS / map changes:
the wire payload is unchanged. Participants / observers / total
mass require the full BattleReport and were intentionally dropped
to avoid N round trips per menu open.

The top-level `battle log` item is removed from `header/view-menu`
and `sidebar/bottom-tabs` (and their stale comment blocks updated);
the now-orphan `game.view.battle` i18n key is dropped from both
locales.
2026-05-27 22:12:51 +02:00
developer e4fbb6644c Merge pull request 'feat(ui): F8-10 — tables planets / ship-groups / fleets, ship-classes delete guard (#53)' (#68) from feature/issue-53-table-planets-groups-fleets into development
Deploy · Dev / deploy (push) Successful in 55s
Tests · UI / test (push) Successful in 2m56s
2026-05-27 19:28:09 +00:00