fertig-classic-games/docs/mastervega-build-plan.md

1894 lines
122 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Master of Vega — build plan and findings
**Read this before touching `src/games/mastervega/`.** It records the traps
found while building it; several were only discovered by instrumenting the soak
and would be very easy to reintroduce.
Master of Orion clone. Registered as `mastervega`, category `arcade-console-pc`
("Video Games"), iconFrame **92**. MOO1 rules with three MOO2 conveniences
(colony building queue, colony detail view, hireable leaders). Preset ship
hulls with an auto-refitting **Mark**, no ship designer. Tactical grid combat.
Empires are the ten **species** themselves — this game deliberately does *not*
use `data/opponents.json` or `ui/Portrait.js`.
## Architecture
Split enforced by import discipline, modelled on `totalannihilation/` rather
than `civilization/` (whose scene grew to 1792 lines).
**Headless — zero Phaser imports, importable by Node:**
| File | Role |
|---|---|
| `VegaRules.js` | `compileRules(json)` — validate + index the rules file |
| `VegaGalaxyGen.js` | Deterministic galaxy: shapes, stars, planets, Gabriel-graph starlanes, homeworlds |
| `VegaLogic.js` | The engine: colonies, sliders, buildings, research, fleets, combat dispatch, council, victory |
| `VegaShips.js` | Preset hulls + Mark auto-refit; the knapsack loadout |
| `VegaCombat.js` | Tactical battle **stepper** |
| `VegaAI.js` | AI empire controller |
| `VegaDiplomacy.js` | Treaties, attitudes, council politics |
| `VegaLeaders.js` | Leader pool, offers, postings |
| `VegaZoom.js` | Star-map zoom ladder (Phaser-free so it is headlessly checkable) |
**Render tier:** `MasterOfVegaGame.js` (scene), `VegaStarMap.js`,
`VegaSidePanel.js`, `VegaNebula.js`, `VegaSystemView.js`, `VegaColonyView.js`,
`VegaCombatView.js`, `VegaScreens.js`, `VegaArt.js`, `VegaFx.js`.
### The star map's command panel
`VegaSidePanel.js` is MOO1's right-hand column: docked, hidden until something
is selected, three modes — **star** (class, worlds, colonies with their
production read-out, forces in orbit, and a *View System* button), **fleet**
(one row per ship stack with a /+ count), **order** (distance, ETA, who is
waiting there, Accept / Cancel).
The panel owns no state. `MasterOfVegaGame` decides what is selected and calls
`showStar` / `showFleet` / `showOrder`; the panel only reads the engine and
reports clicks back through callbacks. Selecting a fleet arms it: the next star
click is a destination, not an inspection — except the fleet's *own* star, which
drops the selection and shows the system, because otherwise a fleet could never
be deselected by clicking where it already is.
**Splitting is the count selector, not a separate command.** `sendDetachment()`
sends the selection and leaves the rest behind as its own fleet. There is
deliberately no "split and stay" button: `consolidateFleets` merges idle fleets
in the same system at the start of the owner's next turn (trap 17), so a
detachment that does not leave immediately simply un-splits itself. A detachment
gets a fresh fleet id and therefore does **not** inherit the parent's fleet
leader.
Three things in `sendDetachment` are load-bearing and each is verified in
section 4b:
* The request is validated **against the actual stacks before totals are
compared** — otherwise asking for nine of a stack of three reads as "all of
them" and silently sends the whole fleet.
* Duplicate entries for one stack are **summed** before the availability check,
or 2 + 2 of a stack of 3 passes twice.
* A refusal is **total**: the reachability probe runs on a copy, so a rejected
order never leaves the fleet carved in two with the pieces going nowhere.
The star map gained `blockPointer` (a drag or a wheel starting over the panel
must not pan or zoom the galaxy underneath it) and `onEmptyClick`, which uses
the `currentlyOver` list Phaser passes to the input plugin's `pointerup` — an
empty list is a genuine click on the void, which is how the panel is dismissed.
### Colony management is two screens, not one
Everything about a world used to be crammed into the right-hand column of the
system-view modal: header, summary, five live sliders, the build queue, *and*
the whole add-to-queue catalogue. It did not fit, and it knew it — the catalogue
loop ended in a bare `break` once it ran off the bottom of the panel, silently
dropping buildings the player was entitled to build.
It is now split by what the player is actually doing:
* **`VegaSystemView.js`** *inspects* a world. The right column shows the planet
at the full 192px the `planets` sheet is cut at, its type/size/richness/
gravity, and — for our own colonies — a **read-only** picture of the same
numbers: allocation as five tinted bars, the queue as four lines with
cumulative ETAs. Nothing there responds to a click except **View Colony**.
* **`VegaColonyView.js`** *runs* one. Full screen, on the world's own backdrop
art, with a semi-transparent panel carrying the live sliders (each with a
padlock) and a **Build Queue** button that flies out a 760px queue manager to
its left: reorder with ▲▼, remove with ✕, and a scrolling catalogue where every
buildable quotes its cost, its ETA at the current construction rate, and what
it does.
The colony screen opens *directly* from the system view rather than through
`MasterOfVegaGame.openModal`, which refuses a second modal while one is up — and
this one has to stay up, because it is what keeps the star map inert underneath.
`D.colony` (70) sits above `D.modal` (60) so the two stack; the system view hides
its layer and pauses its orrery tick while the colony screen is in front, and
rebuilds on the way back.
Repeat counts are a **display** concern only. `enqueueMany` pushes N separate
entries, so `processColony` and the save format know nothing about repeats;
`collapseQueue` folds a run of identical ships back into one "× N" row for
rendering. A part-built entry never joins a run, so the head item's progress bar
always means something.
Four functions back all of this and they all live in `VegaLogic.js`, not in the
views — `collapseQueue`, `moveQueueRun`, `colonyBuildRate` and `queueEtas` — for
two reasons. The two screens quote the same numbers and must agree, and the
index arithmetic in `moveQueueRun` fails *silently* when it is wrong (see below),
so it needs to be somewhere the Node verifier can reach.
> `moveQueueRun` is the sharp edge. Moving a "× 5" row is five splices, and the
> two directions do **not** use the same indices: going **up**, the copies still
> behind keep their slots so both ends advance together (`+ k`); going **down**,
> pulling the head copy out slides the rest of the run into the slot it vacated,
> so the *same* pair of indices moves every copy. Using `+ k` for both — the
> obvious first guess — interleaves the run with its neighbour
> (`s,s,s,f,f` → `s,f,s,s,f`) without throwing. Section 6 asserts the
> contiguous result in both directions.
State is plain JSON with the RNG cursor inside it (`state.rngState`,
explicit-step mulberry32), so replaying a seed reproduces the galaxy, the
battles and the winner exactly. `serialize()` strips `rules` and every
underscore-prefixed memo cache.
## Traps found during the build
Each of these was a real bug that produced a plausible-looking but broken game.
1. **An immobile hull froze the entire fleet.** `fleetSpeed()` returned 0 if
*any* ship in a fleet was immobile. A completed Star Base joins the fleet
over its own colony — which is the empire's main battle fleet — and from that
moment `canSendFleet` refused every order. Instrumented over one game:
**338 of 338** valid attacks (in range, strong enough, at war) were refused
for this reason. No colony could ever be attacked, so no war could be won.
Fix: `fleetSpeed` skips immobile hulls; `sendFleet` splits them into a
garrison that stays behind; `consolidateFleets` keys on mobility so the
garrison is never re-merged.
2. **Conquest was unreachable without bombardment.** Even at a 2500-turn cap,
*no empire was ever eliminated*: a cornered empire's beaten fleet always
retreats to its last fortified homeworld and denies the attacker the clean
orbit an invasion needs. `bombardPopKill` and the Stellar Converter's
`planetCracker` were declared in the rules but never implemented. Implementing
orbital bombardment (MOO1's actual answer) made conquest work immediately.
3. **The invasion window never opened.** Two versions of this failed the same
way. Requiring `defenseHp === 0` never worked because combat resolves on the
attacker's turn and the *defender's* turn rebuilds the batteries first.
Requiring an empty sky failed too — a besieged colony finishes a ship every
few turns and that one hull blocked the landing indefinitely. Measured: 1268
colony-turns under hostile orbit, **2** invasion attempts. Now the rule is
orbital *superiority*, and surviving defences fight as ground support.
4. **Transports never left home.** `manageFleets` had an early
`if (power <= 0) continue`, and transports carry no guns — so transport
fleets skipped every movement branch. 3495 idle observations against 12 in
transit. They now escort the main battle fleet.
5. **The AI threw transports away.** Without a forecast it launched every
landing regardless of odds: 1971 failures against 130 successes.
`invasionForecast()` now gates it — success rate went to ~91%.
6. **Nobody ever met anybody.** Contact required flying a ship into an occupied
system, so most galaxies had no diplomacy and no wars at all. Empires now
also make contact by proximity (`economy.contactRange`).
7. **The Council could never elect anyone.** Every empire stood as a candidate
and therefore voted for itself — 30 sessions in an 800-turn game, all null.
MOO1's rule: the **two largest** empires stand and everyone else votes
between them.
8. **…and then it elected someone every time.** A dominant empire won its own
council vote a hundred turns before it could finish a war, so conquest never
completed. MOO1's **refusal to submit** (the defeated candidate walks out and
the election is void) restored the balance.
9. **Combat had a decisive first-strike bias.** Firing was sequential, so
whichever side acted first in the round contact was made won. Mirror matches
swung from 100% to 0% attacker depending on tier, because the parity of the
contact round changes with fleet speed. Damage is now **banked and applied
together** at the end of the round.
10. **…and then ties were resolved in a fixed direction.** Symmetric fleets
reach the disengage round *exactly* tied astonishingly often; giving the
attacker every drawn battle produced a 44pp bias. The coin is now flipped.
11. **Every battle stalemated on the round cap.** Two causes: in-combat repair
(6% of hull/round) outpaced damage, and the attrition tail of an even fight
is enormously long. Repair was cut to 3%/6%, and a **disengage rule** now
makes the weaker side withdraw at round 25 rather than grinding to the cap.
12. **An all-missile ship ran dry and stalemated.** At some tiers missiles score
better damage-per-space than beams, so a pure knapsack built ships that
emptied their racks in five rounds and then sat unarmed. `MISSILE_SHARE`
caps missiles at 40% of tonnage. This is a design rule, not an optimisation.
13. **Researching a weapon could make ships worse.** A greedy "best
damage-per-space" fill mounts one oversized gun and strands the leftover
tonnage — the cruiser's beam damage *dropped* from 42 to 28 on learning
Death Ray. The loadout is now an unbounded **knapsack**, which is provably
monotonic in tech because researching only ever adds candidates. The one
sanctioned exception is the tier where missiles first appear (see the
verifier's section 4).
14. **Waste ran away and killed every colony.** Population fell 50 → 20 with 868
accumulated waste. Cleanup is now **mandatory** and taken off the top,
pro-rata from the other channels — MOO1's eco slider snapping to the
minimum. Related: factories beyond what the population can staff are
**mothballed** (`effectiveFactories`), or a shrinking colony keeps polluting
from factories nobody is left to run and can never recover.
15. **Espionage leaked the whole tech tree.** Crediting raw espionage score made
every empire a spy agency; all five reached 60/60 techs, making the
per-species availability roll meaningless. Only the surplus over a baseline
now counts.
16. **The Council never convened.** `colonizedFraction` measured against *every*
star, but a third of systems hold only gas giants and belts, so "half the
galaxy colonised" was unreachable. It now measures against settleable stars.
17. **Fleets proliferated to 281.** Ships completing while the local fleet is in
transit each spawn a new fleet. `consolidateFleets` merges idle fleets each
turn.
18. **Dead empires left ghost fleets** on the map forever. Caught by the soak's
invariant checker, not by playing.
19. **Worldgen fairness could not always be satisfied.** On large sparse
galaxies a homeworld can have *no* star inside its opening fuel range, so
there was nothing to upgrade and that empire simply could not expand.
`guaranteeNearbyWorlds` now seeds new worlds when it must.
20. **Zoom could pull back past the edge of the map.** The zoom ladder was a
fixed `[0.35 … 2.0]`, which only ever suited the *huge* galaxy — on a small
one the bottom two steps showed the map floating in empty space, and
`clampPan` allowed another 160px of slack on every side on top of that. The
ladder is now built per galaxy in `VegaZoom.js`, anchored so its bottom rung
is exactly the zoom at which the galaxy covers the viewport, and the pan
clamp has no slack. Kept Phaser-free so the verifier can assert the
invariant directly (section 3b).
21. **`slider()` only works inside a container at the origin.** Its drag maths
is `apply(p.x - c.x)` — a *screen* coordinate minus a *local* one. That is
only correct while the parent container sits at (0, 0), which every
`modalShell` layer does. Nest one inside a panel container at x=1416, or
anything that tweens, and the knob jumps by the parent's offset and drifts
during the animation. It is why `VegaColonyView`'s `panelLayer` is at the
origin with every child in absolute coordinates, and why the flyout — the
one layer that moves — deliberately contains no sliders.
22. **A mask is a rendering concern; Phaser still hit-tests through it.** The
colony screen's catalogue is the game's first scrolling list, and a row
scrolled out of sight remains perfectly clickable unless something says
otherwise. Every row handler asks `scroller.contains(pointer)` first. Two
related traps in the same helper: the wheel catcher must be added *below*
the content or it swallows every row click, and a `GeometryMask`'s Graphics
has its own world transform and does **not** follow a moving parent — the
flyout tween drags it along by hand in `onUpdate`.
23. **A Phaser `Video` does not report a width of zero before it decodes.**
Every video in this game is sized by scaling *from* its source width rather
than calling `setDisplaySize`, guarded as `obj.width || SRC`. That guard
never fires: a freshly created `Video` carries a **placeholder** size until
its first frame decodes, at which point `updateTexture()` builds the real
texture and re-sizes the object to it. So the scale is set from the
placeholder and stays wrong by `placeholder / realWidth` once the real
texture lands. It hid for as long as it did because every clip in the game
**was** 256 px square — placeholder and fallback were the same number, so
both branches agreed. The 960×544 colony clips exposed it: the vignette
opened 2.5× too large (`640 / 256`, which is how the placeholder is known to
be 256 — phaser is not vendored in this repo to read it off), and corrected
itself only on the *second* lap of the loop, when the re-fit ran against a
texture that was real by then. Removing the loop is what made it permanent
and visible. `VegaArt.sourceWidth()` is now the single guard: it gates on
`videoTexture`, null until that moment and the only reliable way to tell the
two states apart, so the placeholder's actual value never matters. Section 2
asserts both branches.
24. **`bestComponents()` computing a flag is not the same as `designFor()`
returning it.** Wiring the `cloaked`/`singularity` tech effects into combat
(2026-08-09) found `singularity` tallied correctly into `comps.singularity`
in `VegaShips.js` — same as `cloaked` — but the object literal `designFor()`
returns only ever copied `cloaked: comps.cloaked` onto the design; there was
no `singularity:` line at all, so every design's `design.singularity` was
`undefined` forever, silently. A combat check that read `shooter.design?.
singularity` would never have fired and nothing would have said why. Caught
only because the new verifier check builds a design with **both** flags
stripped as a sanity assertion (`baseDesign.cloaked && baseDesign.
singularity`) *before* running the A/B battle that depends on them —
without that assertion the A/B test would have "passed" by measuring
nothing. Lesson: any per-tech boolean/stat added to `bestComponents()`
needs a matching line in `designFor()`'s return object, and there is no
compiler to catch the omission — assert the field is actually present on a
fully-teched design, not just that the battle outcome looks plausible.
Also: attrition combat **compounds** small per-shot edges hard. An isolated
A/B fleet fight (same design, one flag flipped) at `cloakEvasion: 0.15`
produced a 99% win rate — more decisive than the suite's own ">0.8" bar for
a full two-tech-tier lead across every field at once. Tuned down empirically
(not from the single-hit-chance formula) against an isolated same-fleet A/B
harness until it landed near what a mild species combat-trait is worth in
that same harness (~62%): `cloakEvasion: 0.02`, `singularityShieldPierce:
0.5`. Both are checked in section 5 with a `> 0.52 && < 0.8` band, loose
enough to absorb RNG noise but tight enough to catch another 0.15-style
overshoot.
25. **A per-function `scene.input`/`scene.events` listener has to be
unregistered by whoever adds it, if that function can run more than once
per scene session.** `VegaCombatCamera.js` (2026-08-09, built for the V2
per-ship combat prototype below) copies `VegaStarMap.js`'s zoom/pan
pattern — but `VegaStarMap` is instantiated exactly once per game and its
listeners live for the whole session, while `openCombatViewV2()` is a
plain function invoked **once per battle**, and the game opens several
battles in a row within one scene session
(`MasterOfVegaGame.playPlayerBattles()` loops over pending fights).
Copying the star map's "bind once, never unbind" habit would stack
another listener — with a stale closure over an already-destroyed
`battleRoot` container — on top of every earlier battle's, first as a
leak, then as a crash the moment a stale listener fires. `bindZoomPan()`s
`destroy()` explicitly `scene.input.off(...)`s every listener it added
(plus `scene.events.off('update', ...)` for the parallax tick) for this
reason, and both of `VegaCombatViewV2.js`'s teardown paths (`finish()`
and the escape-hatch `destroy` it returns) call it unconditionally.
Caught by design review before it was ever wrong, not by debugging a
live crash — worth writing down anyway, since it is exactly the kind of
thing a future contributor copying the star map's pattern elsewhere
would miss on the first pass.
26. **A simultaneous wipe-out must be checked before either single-side win
condition, or it silently favours one side.** Both engines' round-
resolution end with `if (!aLeft.length) {...defender wins...} else if
(!dLeft.length) {...attacker wins...}` — if BOTH sides' last living
unit dies to the same banked round, `!aLeft.length` is true first and
the `else if` never even checks `dLeft`, so the defender is declared the
winner despite being equally dead. This bug is verbatim in
`VegaCombat.js` too, but at stack granularity it needs an entire
multi-ship stack to die in the same round — rare enough that the live
engine's test suite (built around 5-ship stacks) never surfaced it. At
the V2 per-ship prototype's one-ship-per-side granularity it is common
(measured ~19% of 1v1 mirror-match trials) and was the single largest
contributor to an early 1v1 mirror-match bias caught during this
rewrite's own calibration (attacker winning only 38% instead of ~50%).
Fixed in `VegaCombatV2.js` with an explicit `!aLeft.length &&
!dLeft.length → draw` check ahead of the single-side cases. **Not**
fixed in `VegaCombat.js` — out of scope for a same-session parallel-
prototype build, and its real-world impact there is low given multi-ship
stacks — but worth fixing there too if this engine ever graduates, or
even standalone, since it is a genuine one-line correctness bug.
27. **Movement must be banked like damage, or whichever side's tie-break
"goes first" in a round quietly wins.** `VegaCombat.js`'s own comment
explains at length why *damage* is banked (applied together after
everyone has fired, so firing order can't decide who survives to shoot
back) — but the V2 prototype's first pass at *movement* missed the same
lesson: each ship moved immediately, in `order` (initiative, tie-broken
by a stable per-battle `seq`), so a ship that moved earlier in a round
reacted to the *current* (already-updated-this-round) position of any
enemy that had already moved, while a later mover was still working from
stale data — and since ties always favour the lower `seq`, one side
consistently had the informational edge. An isolated mirror-match soak
(identical fleets, no advantage either direction) swung from 0% to 65%
attacker wins purely from fleet size, with nothing else changed. Fixed
by making movement two-phase exactly like damage: every ship computes
its new facing/x/y against the same pre-round snapshot (`computeMove()`,
pure, no mutation), and only once every ship has computed one are any of
them actually applied. Same fix shape as `VegaCombat.js`'s own banked-
damage lesson, just for a different piece of per-round state — a
reminder that "banked, not sequential" is a property every per-round
mutation needs, not something you get once and keep for free.
28. **Hard nearest-neighbour targeting is a chaotic system at 5-20 discrete
ships per side, not a smooth attrition curve.** Even after fixing traps
26 and 27, a 5v5 mirror match still showed a 28-point win-rate bias, and
changing nothing but the placement grid's cell-spacing constant flipped
an otherwise-identical mirror match from 100% attacker wins to 0%.
Root cause: with strict "always target the mathematically nearest enemy"
for both movement and firing, *which* ships end up duelling *which*
other ships is a hard, deterministic function of exact sub-pixel
geometry — a tiny perturbation can flip who wins the first exchange,
which changes who's left to form the next pairing, cascading into a
completely different battle. This is a real property of nearest-
neighbour target-locking at small N, not a coding mistake to hunt down
further. Mitigated (not eliminated) with `pickWeighted()` in
`VegaCombatV2.js`: target choice is weighted by `1/distance²` rather
than winner-take-all nearest, for both movement's "which enemy to close
on" and firing's "which in-range enemy to shoot." This tames the
sensitivity (worst observed mirror-match bias dropped from 78pp to
~15pp) without abandoning "ships prefer close targets," but real
residual variance remains — the V2 verifier section's mirror-bias check
uses a deliberately looser tolerance (25pp) than the live engine's
(12pp), and the softening also dilutes small deliberately-subtle edges
like the cloak/singularity calibration (see trap 24) from ~62% down to
~53-55% — still a real, correctly-directioned, non-noise signal, just a
much smaller one. A smarter, non-degenerate placement/targeting model
(formation-aware, not raw nearest-neighbour) is the natural next step
when ship placement itself is revisited — deliberately deferred for now,
per the feature's own scoping.
29. **In a continuous-time engine, movement is no longer free — a placement
gap overflow starves combat of time to actually happen, not just of
space.** Converting V2 from discrete rounds to continuous seconds (see
the "continuous fluid battle" section below) meant closing distance now
spends real time from the *same* budget that also governs firing
cooldowns and the disengage timer, unlike the old discrete-round model
where movement was effectively free. This turned a latent placement bug
into an acute one: for large/mixed fleets (e.g. 17 ships/side including
battleships), `gap = GAP_MULT(3) × max(gridW)` could compute a gap far
exceeding `worldWidth`, placing ships at literally negative X. With that
much distance to close, only the fastest hulls ever got into range
before the disengage timer fired on a tiny, unrepresentative skirmish —
which is what actually decided the whole battle, reintroducing a 22pp
mixed-hull mirror-match bias that traps 27/28's fixes didn't touch
(they weren't the cause here). Fixed by clamping the gap in
`placeFleets()` to never exceed 85% of `worldWidth`. Lesson: a formula
that "only" affects starting positions can still be a *pacing* bug once
the engine treats travel time as a scarce resource.
30. **A movement "stop at range" distance and a firing "in range" check must
agree, or ships can park forever just outside their own weapons' reach.**
After fixing trap 29, battles still occasionally stalled outright (not
just biased) — surviving ships sat at full HP, unchanged for hundreds of
simulated seconds, all parked at `dist` reading exactly `beamRange` to
one decimal place. Root cause: `computeShipMove()`'s stopping distance
(`want = dist - beamRange`) assumes movement happens straight down the
seek line, but actual movement follows `step.facing`, which drifts off
that line whenever separation steering blends in — i.e. almost any time
a ship has a neighbour inside its `avoidRadius`, exactly the crowded
end-of-battle situation where the last few ships per side are bunched
up. The component of motion actually along the seek line ends up
slightly *less* than the computed `moveDist`, so a ship can settle into
a stable equilibrium a hair outside its hard weapon range and never
close the last fraction of a unit — permanent for any weapon with
finite ammo once its only unlimited-ammo mount (a beam) is excluded
forever by a strict `dist > range`. Fixed with a small tolerance
(`RANGE_EPS = 4` world units) on both the per-mount firing check in
`fireMounts()` and the cooldown-arming check in `advance()` — "close
enough" is the correct semantics for a steering approximation, not
bit-exact geometry. Confirmed fixed by driving the same stalled seeds to
a hard-uncapped duration (`maxRounds` overridden to 5000): every
previously-stalled battle now resolves.
31. **Locking thrust to facing breaks the slowest-turning ships worst —
exactly the ones a "can this hull hold position" feature most needs to
work for.** Adding real linear momentum (Brian's ask: ships shouldn't
"stop on a dime," should have to "turn and engage thrusters again")
first modeled a literal single main engine — thrust only ever applied
along `facing`, so braking meant physically turning the whole hull to a
retrograde heading before any deceleration could happen at all. A
battleship needs 20+ seconds to complete that turn at its
(deliberately slow) turn rate; for all of that time a facing-locked
engine was still thrusting partly *toward* the target it was trying to
stop at. Every isolated battleship-vs-stationary-target smoke test
overshot; the tier-9 case never stopped at all, flying 1250+ units past
a target it was supposed to hold at. Fixed by decoupling thrust
direction from facing entirely at first (vectored maneuvering thrust,
facing purely cosmetic) — this immediately fixed the overshoot, but
also silently made turn rate matter for nothing at all, which broke a
genuinely-load-bearing existing verifier check (`fast-turning hull
closes distance faster`) and quietly contradicted Brian's own "turn and
engage thrusters" wording. Landed on a middle ground: thrust always
points in the correct task direction (never actively wrong-way, so the
original failure mode can't recur), but its *magnitude* is scaled by
`alignment = max(0, cos(angle between current facing and task
direction))` — a fast-turning hull reaches full thrust almost
immediately, a slow-turning one ramps up over several seconds, and a
hull starting 90°+ off gets zero thrust until it's turned enough to
matter. This reintroduced turn rate as a real (if softer) factor without
reopening trap 31's original failure — see `computeShipMove()`'s
comment for the full reasoning. Cost: cruiser and battleship's
`brakeSeconds` needed retuning noticeably stronger (2.2→1.8, plus
battleship stayed at 1.3) to absorb the alignment ramp-up's cost and
still reliably hold at high tech tiers — a size-scaled parameter that
looks "purely" tactical can still be secretly load-bearing for a
*different* mechanic once thrust efficiency depends on turn rate too.
32. **Several ships converging on the same weighted-random target approach
it almost in formation — exactly the case with the least relative
motion for reactive collision avoidance to work with.** `pickWeighted`
(trap 28) already sends multiple ships at one popular target by design;
under real momentum, their bearings toward that single shared point
converge as they close, so by the time they're near it they're moving
at similar speed in a similar direction — normal quadratic-falloff
separation barely nudges ships apart under those conditions before
they're already on top of each other. A dedicated multi-ship
convergence smoke test (mixed fleet, worst gap between any two ships
NOT targeting each other, as a fraction of their combined
`avoidRadius`) measured near-total overlap (worst ratio ~0.00-0.05,
i.e. ships passing almost exactly through each other) once real
momentum replaced the old kinematic model's implicit clamp. Two
independent fixes were needed together — either alone left meaningful
overlap: (a) `avoidAccel`, a flat acceleration budget for collision
avoidance ONLY, deliberately *not* drawn from a hull's own (possibly
weak) `linearAccel` — a frigate's deliberately poor brakes are a
tactical choice about holding position, not a physical inability to
dodge a teammate; (b) `GOLDEN_ANGLE`-spaced approach points — each ship
steers toward its own point spread around the shared target (offset by
its own `avoidRadius`, angle from its `seq` via the golden angle for
even spacing) rather than literally the target's identical coordinate,
so ships sharing a target fan out toward it instead of beelining for
one point. Worst-case ratio across repeated seeds after both fixes:
~0.1-0.4, comfortably real personal space, never the ~0.00-0.05 collapse
from before.
33. **A "can this hull hold position" feature that only appears to work
because something else is quietly doing the braking is not actually
fixed — remove the crutch and the real gap shows up.** Weakening
`avoidAccel` (per Brian's later correction to trap 32 — ships shouldn't
bounce off each other, overlap is fine) had an unexpected side effect:
battleship, the hull explicitly meant to reliably hold position, mostly
stopped doing so at higher tech tiers, flying straight through a
stationary target at full speed. Root cause, invisible until then: a
slow-turning hull's FACING only ever starts steering toward retrograde
once braking genuinely begins (`dist <= beamRange`) — by which point
it's still oriented from the approach phase, and a full 180° reversal
can take 15-20+ seconds for the slowest hulls at their turn rate, so
alignment-scaled thrust (trap 31) stays near zero for most of the close
pass. This was ALWAYS true; it just didn't matter while `avoidAccel`
was tuned at 1000 (10-100x any hull's own tactical thrust) — the
"reduced but nonzero" avoidance against a ship's own target
(`TARGET_AVOID_FRACTION`) was strong enough to arrest velocity on its
own, accidentally doing the tactical job braking was supposed to do.
Once avoidance was correctly turned down to a gentle preference, that
accidental crutch went away and exposed the underlying gap. Fixed with
ANTICIPATORY turning: `computeShipMove()` now compares `worstTurnTime`
(time for a full 180° reversal at the hull's max turn rate) against
`timeToRange` (time until the ship would reach beamRange at its current
speed) and starts steering FACING toward retrograde early whenever
there isn't enough runway left — critically, facing-only, not thrust:
thrust stays pointed at the approach point until real braking is
actually needed, and lets alignment-scaling (trap 31) naturally bleed
off forward efficiency as facing diverges during the pre-turn, rather
than needing a third explicit "how hard to brake" state. An interim
version made thrust itself switch to retrograde this early too, which
overcorrected the other way — ships stopped 5-10+ units short of
`beamRange`, outside their own weapons' reach, since full braking
kicked in before they'd actually arrived. **Lesson, same shape as trap
31: when a fix's test results look right, check WHY they're right — a
passing smoke test can be hiding a different, load-bearing mechanism
doing the actual work, which then breaks silently the moment that
mechanism gets retuned for an unrelated reason.**
34. **A hard snap onto the "prepare to brake" heading is invisible in
isolation and a real bug the instant anything else nudges the ship.**
Trap 33's anticipatory pre-turn snapped `facing`'s steering target
straight onto retrograde the instant the pre-turn window opened, for
however long that window lasted (15-20+ seconds for the slowest
hulls). In a clean 1-ship-vs-a-stationary-target test this was
completely harmless — a ship already flying in a straight line needs
zero corrective thrust, so a cosmetically-backward-pointing nose while
coasting true didn't affect the flight path at all, and that test was
the one used to validate trap 33's fix. But the pre-turn also
collapses `computeShipMove`'s alignment-scaled thrust (trap 31) toward
zero for that entire window, since thrust efficiency is gated by how
well facing matches the approach-direction task angle — meaning for
that whole stretch the ship had almost no ABILITY to correct its
course if something nudged it off-line. A 2-battleship-per-side smoke
test (not the 1-ship isolation trap 33 was validated against) showed
exactly that: gentle collision avoidance from a nearby teammate was
enough to visibly drift a ship off its target with nothing available
to pull it back. Fixed by BLENDING the pre-turn's facing target
(approach direction -> retrograde) proportional to urgency
(`1 - timeToRange/worstTurnTime`) instead of snapping straight onto
retrograde the instant the window opens — keeps real corrective
steering authority for most of the pre-turn window, only fully
committing to retrograde right as braking is about to start. **Same
lesson as trap 33, one level deeper: the fix that gets validated in
isolation is only as good as the isolation scenario — a test with
nothing else nearby cannot expose a failure mode that only manifests
when something else is nearby.**
35. **Steering at a target's CURRENT position, not where it's actually
headed, makes a slow-turning ship unable to catch anything that isn't
trying to reach IT specifically.** Fixing trap 34 improved but didn't
fully resolve the same 2-battleship smoke test — distance to a live,
still-being-chased target would close to a few hundred units, then
reopen to 1000+, repeatedly, over a full minute, never settling.
Root cause, found by logging the CHASED ship's own target: it was
pursuing a completely different third ship, so its motion had nothing
to do with evading the ship chasing it — two independently-moving,
equal-top-speed ships, one a slow turner, produced a classic pursuit
failure (chasing a laterally-moving point you can't out-turn, like a
dog that can't catch a squirrel that keeps changing direction) that
LOOKS exactly like "the target is fleeing" from the chaser's
perspective even though neither ship is choosing to run from the
other. Brian's diagnosis question ("is that the auto-disengage
thing?") was a reasonable guess but wrong — disengage is a rare,
whole-side, once-per-battle event; this was happening continuously,
ship-pair by ship-pair, throughout ordinary combat. Fixed with
`predictIntercept()` — classic lead-pursuit algebra: given the
target's position/velocity and the shooter's assumed closing speed
(its own `effSpeed`), solve the quadratic for the smallest positive
time `t` where a straight course from the shooter meets the target's
projected position, and aim there instead of at the target's current
position. Degenerates exactly to today's behaviour for a stationary
target (validated as an explicit unit case), and falls back to it
gracefully when no real positive-time solution exists (target
receding faster than the shooter can ever close) — this can only ever
match or beat plain pursuit, never make aim worse. Confirmed via the
same smoke test: distance now closes steadily and stabilizes (~340
units and still slowly shrinking) instead of oscillating without
bound. Only affects the APPROACH steering aim point — `dist`/
`inRange`/the braking trigger/weapon range all still read the
target's TRUE current position, unaffected.
36. **A "spread ships apart by X" offset that's multiplied by a magnitude
which is deliberately ZERO for some inputs silently produces zero
offset for those inputs, not the intended spacing.** Speed Swarm's
rear-most band (the biggest hulls) is explicitly meant to sit near the
centerline — `bandSpreadMag = 0` there, by design. The per-ship
within-band offset formula was `arm * (bandSpreadMag + stepIndex *
cell)`, and for the first two ships in ANY band (`stepIndex = 0`),
that's `arm * bandSpreadMag` — which is `arm * 0 = 0` for the rear
band specifically, regardless of the `arm` sign meant to push them to
opposite sides. Two battleships landed on the exact same coordinate
(a same-side ship-overlap smoke test caught it immediately: worst
ratio 0.00, an exact double placement). Fixed by making the per-ship
term ADD to, never multiply through, the band's fan magnitude:
`arm * (bandSpreadMag + (stepIndex + 0.5) * cell)` guarantees a full
cell-width of separation between the first two ships in any band
regardless of what `bandSpreadMag` happens to be that band.
37. **A post-hoc "scale everything down to fit" clamp is unsafe the moment
anything in the thing being scaled was already at its own guaranteed
minimum.** After fixing trap 36, a wide fleet (many frigates fanned far
out under Speed Swarm) still showed two battleships landing inside each
other's `avoidRadius` — 0.77 of the safe minimum, not 0.00, but still a
real violation. Root cause: `placeFleets()` had a vertical-fit
safety net that uniformly multiplied every ship's `spread` coordinate
by a single scale factor if the fleet's total spread exceeded a
world-height budget. That factor applies identically to a ship pair
that was placed at EXACTLY its guaranteed-safe minimum distance and to
one with plenty of slack — shrinking the tight pair below their own
safe minimum right along with everything else, precisely undoing the
guarantee trap 36 exists to provide. Fixed by moving the safety margin
to where it belongs: `layoutSpeedSwarm()` bounds its own fan magnitude
(`maxSpreadMag`) against world height BEFORE any ship is placed, so
the per-ship minimum-spacing term is never something that needs
correcting afterward. **Same shape of lesson as trap 4/22's class of
bug (a fix at the wrong layer looks right until you check what it's
actually doing to values that were already exact): a correction
applied AFTER a value is computed can't tell "this was already exactly
right" from "this needs adjusting," and will happily break the first
case to fix the second.**
38. **A mirror match with formation-shaped placement can be structurally
biased even with allowRetreat disabled, ruling out the obvious
suspect.** Power Pressure's front-loaded heavy-ship clash, for a
mixed-hull fleet mirrored against itself, showed a real 33-34pp
attacker/defender split (same-hull fleets and Speed Swarm showed none)
— and the bias persisted almost unchanged with the auto-disengage
mechanism (already known to cause a smaller, asymmetric bias — see the
"attacker leaves early" rule) fully disabled, which would have been the
natural first guess. The front tier's `packUniformGrid` placed
same-row ships (e.g. two cruisers, `stepIndex` differing only by
column) at the EXACT SAME depth — a perfect-tie geometry, the same
shape of problem as trap 27's collinearity bug, just on the axis this
placement rewrite introduced rather than the one that already had
wobble. Mitigated (not fully eliminated — matches trap 28's own framing
of this whole class of small-N chaos) with a second, decorrelated
wobble on the DEPTH axis (`placementWobbleDepth`, different
multiplier/modulus than the existing spread wobble so the two don't
cancel or reinforce for the same ship) — brought the mixed-fleet bias
down to ~8pp with retreat enabled (how real games actually play),
comparable to or better than other already-tolerated biases in this
engine. The residual with retreat disabled is larger and left
undocumented-as-fixed on purpose — that configuration is a diagnostic
tool this session used to rule out the retreat-timing hypothesis, not
a real gameplay scenario worth chasing further.
39. **A "column" placement function that stacks along the wrong local axis
produces a shape that isn't visually what its name promises, even
though nothing else in the pipeline complains.** `packColumn` (the
original name) stacked ships along DEPTH while holding spread ~flat —
numerically fine (no overlap, valid coordinates), but it meant Power
Pressure's "vertical line in the rear" sat at nearly constant
world-Y the whole time, just extending far into world-X: a horizontal
smear parked through the battlefield's vertical center, not a vertical
line at all. Nothing in the geometry checks caught it because they only
asserted relative depth ordering (heavy-ahead-of-light), never which
local axis a "column" actually varied along. Renamed to
`packVerticalLine` and rewritten to stack along spread instead
(`layoutPowerPressure`'s `packVerticalLine(group, depth, startSpread,
direction)`), with the existing `placementWobbleDepth` now holding
DEPTH ~constant instead of decorrelating it. Caught from Brian's own
report ("many of the units are starting in the center of the screen")
before it was traced to code, not the other way around — a reminder
that "the math checks out" and "it looks like what was asked for" are
different verifications, and only a human watching the screen catches
the second one reliably.
- **Immediate follow-up correction, same day**: the first fix
interpreted "the middle 40% of the screen empty" as a VERTICAL
(spread/Y) requirement — pushing every ship at least
`worldHeight·0.4/2` from the horizontal centerline — matching the
literal words ("vertically speaking… middle 40%… use vertical
space") but not Brian's actual intent, which he clarified
immediately after seeing the first pass land: the empty band is
HORIZONTAL (depth/X, between the two fleets' front lines), and
"vertical space" in the original ask meant ships are free to use
the full height for their columns/arcs, not that the gap itself
runs vertically. Moved the 40% floor from a per-ship spread
constraint inside `layoutPowerPressure`/`layoutSpeedSwarm` to a
single floor on the attacker/defender gap in `placeFleets`
(`gap = Math.max(gap, worldWidth * CENTER_GAP_FRACTION)`, applied
AFTER the existing `maxSpan` safety clamp so it wins even for a
fleet whose own depth would otherwise have squeezed the gap
smaller) — safe to let this exceed `maxSpan` because, post-trap-39,
per-formation depth no longer grows with fleet size the way the old
grid did (ranks/bands stack along spread now, not depth), so
`maxDepth` stays small and bounded regardless of fleet size; this
fixed floor can't runaway the way `GAP_MULT`'s proportional term
could (the failure mode `maxSpan` was originally added to prevent,
see trap 29). Formations themselves no longer carry any center-gap
floor at all — they're free to use the full vertical span.
- Both passes were caught with the same isolated-smoke-test-first
method as every other formation fix this session, run against
`placeFleets` directly (not a full battle) since the invariant is
pure placement geometry: a fresh gap-check script asserting (a) the
attacker/defender front-to-front X gap clears 40% of world width,
(b) both vertical wings are populated, (c) same-side minimum spacing
still holds, and (d) the rear light-ship group varies far more in Y
than X (the corrected column shape) — all four re-verified after
the horizontal-vs-vertical correction, since the first pass's
version of (a) was checking the wrong axis entirely.
- Section 11's "column is deeper than wide" check was inverted to
match the corrected geometry (`lightYRange > lightXRange`, was
`lightXRange > lightYRange`) and a new front-to-front horizontal gap
check was added for both formations.
- Quick suite: 2608 passed, 0 failed. Full suite: 2610 passed, 0 failed.
- Never browser-tested, per [[feedback_no_auto_verify]].
40. **A pure `Math.hypot`+squared-distance-early-out fix for an O(n²) hot
loop only cuts the cost of the common case (an out-of-range pair) — it
does nothing about the *count* of pairs still being examined, which is
still O(n²) regardless.** Discovered the hard way: after V2 was chosen
to become the real game's combat resolver (see the section below),
`node --prof` on a 17-a-side battle showed `Math.hypot` at 41.4% of
total runtime — an easy, low-risk fix (V8's hypot does overflow/
underflow-safety work this game's small bounded coordinates never
need). Swapping it for manual `Math.sqrt(dx*dx+dy*dy)` plus a squared-
distance reject before the sqrt even runs (`computeSeparation`, the
per-ship-per-tick collision-avoidance sweep) took a 17-a-side mirror
from 77.4ms to 18.4ms — a real, measured win. But a genuine self-play
soak (run as due diligence before trusting the verifier's own "AI turn
time" check — which turned out to only time `AI.runAITurn`, never
`resolveCombats`, so it was structurally blind to this whole class of
problem) found a single game turn stalling **46.3 seconds**, caused by
a 278-ship battle. The early-out cheapens examining a far-apart pair;
it doesn't reduce how many pairs get examined — still O(n²) iterations
regardless of how cheap each one is. Fixed with a uniform spatial grid
(`SEPARATION_CELL_SIZE=400`, derived from the largest possible
avoidRadius pair-sum — two battleships, 350 — with headroom) scanning
only a ship's own cell + its 8 neighbors, turning the sweep into
roughly O(n) for a reasonably-spread battle. **A second trap inside the
fix**: forcing every battle through the grid unconditionally regressed
small/typical battles (Map-building overhead exceeds the O(n²) scan's
own cost until a battle is genuinely large) — MEASURED, not assumed,
the same discipline as everything else in this doc. Split into two
fully separate functions, `computeSeparationFlat`/`computeSeparationGrid`
(not one function branching on data shape — V8 optimizes a small
single-purpose function more reliably), dispatched once per tick via
`SEPARATION_GRID_THRESHOLD=60`. Both exported purely for verifier
consumption (matching the existing `placeFleets`/`shipBounds`
pattern), with a correctness check (grid output must exactly match
brute-force flat output for a synthetic ship set, including ships
sitting exactly on a cell boundary) and a performance regression check
(a 278-ship battle must resolve under 5s). This dropped the 46.3s
worst case to 16.9s — real progress, but (see trap 41) not the whole
fix.
41. **Even O(n) collision avoidance degrades when ship DENSITY grows
unboundedly in a FIXED-SIZE world.** Re-running the soak with an
improved diagnostic (measuring fleet sizes *after* `Logic.moveFleetsFor`
runs, not before — the original diagnostic's pre-movement snapshot
missed battles between fleets that arrive and immediately fight in the
same turn) found real late-game wars (turn 400-500+) putting up to
**790 ships** into one battle. The 3600×2400 world doesn't grow with
fleet size, so packing hundreds of ships into it means grid cells get
crowded and per-tick cost keeps climbing even with trap 40's fix:
418 ships → 9.9s, 737 → 14.7s, 790 → 16.9s. This is a structural limit
of "simulate every ship's continuous physics in a fixed-size arena,"
not a bug to keep squeezing — Brian's explicit choice, offered as one
of several options (accept the ~17s worst case as rare; scale the
battle world with fleet size; or cap simulated ships and fold the rest
in via V1's cheap aggregate math): the last one. New
`MAX_SIMULATED_SHIPS_PER_SIDE=100` (200 total) — comfortably below the
tested-safe ~278-ship mark, since real self-play's messier 166-ship
case already cost ~2s, more than a clean synthetic mirror benchmark at
the same size. `capFleetLines()` proportionally downsamples a side's
fleet-lines to the cap using largest-remainder apportionment (preserves
each hull's relative share — a fleet that's 80% frigates still
simulates as ~80% frigates, not skewed by naive truncation), called in
`createBattle()` BEFORE per-ship expansion, so an oversized fleet never
even gets expanded past the cap. The excess (`battle.attackerOverflow`/
`defenderOverflow`, `[]` for the overwhelming majority of battles that
never approach the cap) is resolved in `battleResult()`: if only ONE
side overflowed, those ships never met an opposing force and simply
survive intact; if BOTH sides overflowed, they fight each other as an
isolated battle via **V1's** `createBattle`/`runBattle` (a new but
harmless dependency direction — V1 itself is not modified, still
resolves `resolveInvasion` and nothing about this import touches it),
merged into the simulated portion's own survivor/loss totals. Winner:
if merging leaves one side at zero total ships and the other with some,
that side wins outright (can flip the simulated portion's own result —
a wiped-out simulated force whose untouched reserve overflow still
holds the field); otherwise the simulated (actually-fought) portion's
winner decides. `VegaCombatViewV2.js` shows a one-line disclosure
("+N reserve ships also engaged") when a battle the player watches had
any overflow, so the final counts never silently disagree with what
was shown fighting on screen. Verified: conservation (survivors+losses
always equals the original per-side total — no ship silently vanishes
to rounding), proportional sampling, both winner-decision branches on
constructed edge cases, and — the real gate — a 790-ship battle
(matching real self-play's measured extreme) now resolving in ~1.3s
instead of 16.9s. **Re-running the actual 27-game self-play turn-time
diagnostic** (not just synthetic benchmarks — this exact tool is what
caught that trap 40's fix alone wasn't sufficient) confirmed the real
fix: worst-case single-turn cost across the whole soak dropped from
**46.3s → 16.9s → 4.5s**, with even a 636-ship real-game battle now
resolving in 3.3s.
## Master of Vega V2 — per-ship tactical combat prototype (2026-08-09)
A second, parallel combat engine and view, reachable only via
`?movsim`'s Live/V2 toggle — **`VegaCombat.js`/`VegaCombatView.js` and every
real player battle are completely untouched.** Built because Brian wanted to
*see* individual ships (sized by hull: frigate/destroyer/cruiser/battleship
at 0.5×/1×/1.5×/2.5×, `sizeScale`), watch hull-appropriate movement/turn
speed (`sizeSpeedMult`/`turnRateBase`, new per-hull fields in
`data/mastervega-rules.json`'s `hulls` block — additive, ignored entirely by
the live engine), and zoom/pan a battle with a parallax starfield — none of
which the old one-marker-plus-"×N"-label stack view could show. Brian
explicitly chose **true per-ship targeting** (every ship independently picks
its own target and rolls its own to-hit, no formation aggregation) over a
lower-risk "aggregate math, individual rendering only" option, understanding
that would mean re-deriving/re-validating the balance math — see traps
25-28 above for what that actually took.
- **New files**: `VegaCombatV2.js` (headless, mirrors `VegaCombat.js`'s
`createBattle`/`stepRound`/`runBattle`/`battleResult` contract and
`battleResult()`'s exact output shape, but `battle.ships` is a flat array
of individual entities with real `hp`/`x`/`y`/`facing`, not
`battle.stacks`), `VegaCombatCamera.js` (Phaser-touching zoom/pan/
parallax, adapted from `VegaStarMap.js` — see trap 25),
`VegaCombatViewV2.js` (Phaser rendering, one container per ship not per
stack).
- **World space is continuous 2D**, not the live engine's 1D lane — new
`rules.combatV2` block (`worldWidth`/`worldHeight`/`moveUnitsPerSpeed`/
`beamRange`/`missileRange`), completely separate from `rules.combat`'s own
`beamRange`/`missileRange` (which the live engine still reads unmodified).
1 old lane-tile = 100 new world units, chosen specifically so a
`sizeSpeedMult: 1.0` (destroyer) ship's pacing matches the live engine's
calibrated feel exactly — only the new per-hull multiplier introduces
deviation from it.
- **`hitChance()` and the shield-pierce/singularity math are copied
verbatim** from `VegaCombat.js` — the one thing explicitly not up for
revision. The only aggregation change in `fire()` is dropping the old
`shooter.count` multiplier (the shooter *is* one ship now); `sampleHits()`
is still reused per ship-mount, since a single hull can still carry many
copies of one weapon (e.g. 30+ lasers on a battleship).
- **Facing affects movement only** (Brian's explicit choice) — no firing
arcs, no to-hit modifier from facing, so the calibrated hit-chance formula
needed zero changes despite ships now having real headings.
- **Placement is deliberately a placeholder**: `placeFleets(rules,
attackerShips, defenderShips)`, exported standalone specifically so a
later rework can replace just that one function — a simple
few-columns/few-rows grid per side, per Brian's explicit ask to defer real
formation design. A deterministic per-ship y-wobble is load-bearing, not
decorative — see trap 28's root cause. Originally per-side
(`placeShips(rules, side, ships)`), called once each for attacker and
defender; changed same day (see below) to place both fleets together,
because the gap *between* them needs to know both footprints at once.
- **Sound**: reuses last session's Mark-banded weapon/missile sfx keys and
stagger mechanism, but caps actual cue playback at ~12 per round
(evenly sampled across the round) regardless of how many of the round's
60-150+ individual fire events there are — every event still gets full
visual FX, only audio is capped. Also gained one destroy cue per warship
class (frigate/destroyer/cruiser/battleship), sharing the same round
stagger timeline so a multi-kill round doesn't clip them together.
- Verifier: section 11 (headless like everything else) — ship-expansion
shape, determinism, the turn-rate invariant, a mirror-bias check at a
deliberately looser tolerance than the live engine's (see trap 28), the
cloak/singularity A/B calibration (see the fleet-size note below), and
(same day) formation-strategy resolution/stamping and the
placement/zoom-fit math below.
- Never browser-tested, per [[feedback_no_auto_verify]].
**Formation strategy, same day**: a `formationStrategy` chosen once per side
before a battle starts — `VegaFormations.js` (new, headless) defines two to
start, "Power Pressure" and "Speed Swarm", with `randomFormationStrategy(rnd)`
for a silent pick. `VegaCombatV2.createBattle()` resolves each side's choice
(honouring an explicit, valid one; silently rolling from the battle's own
seeded `rnd` otherwise — this is *all* "AI picks silently" needed, no
separate decision logic) and stamps it onto every ship on that side plus
`battle.attackerFormation`/`defenderFormation`. `VegaCombatSim.js`'s Watch
Battle (V2 mode only) shows a `modalShell` picker for the attacker before
launching; the defender gets no prompt at all. **This is plumbing only —
formation strategy does not yet affect placement, movement, or targeting.**
That's explicitly upcoming work; today it's just data flowing through so
that work has somewhere to read from.
**Bigger battlefield + fleet-size-adaptive initial zoom, same day**: two
requests that turned out to be coupled. `rules.combatV2.worldWidth/worldHeight`
went from 1200×800 to 3600×2400 (3×, same aspect ratio). Separately,
`VegaCombatCamera.bindZoomPan()` gained a `fitBounds` option — given a
bounding box (world units), it picks the zoom ladder's largest rung that
still frames the box without cropping it
(`VegaZoom.pickFitZoomIndex`, pure/headless), instead of always opening at
the ladder's fixed bottom rung. `VegaCombatV2.shipBounds(ships)` computes
that box from the actual battle (every entity including the planet, so a
fleet-vs-planet fight with no defender ships still frames correctly).
The coupling: the **first** placement pass just widened `GRID_MARGIN` — kept
each fleet pinned to a fixed distance from the world's edges, unchanged in
principle from before, just now measured against a 3× bigger world. That
was wrong. Because the margin was fixed regardless of fleet size, the two
fleets always ended up separated by roughly `worldWidth - 2×margin` no
matter how many ships were in them — a 1-ship skirmish and a 20-ship fleet
action opened with the *same* ~3300-unit gap, so `shipBounds`'s width was
dominated entirely by the world's size, not the fleet's, and the "adaptive"
zoom always picked the same bottom rung regardless of ship count — silently
defeating the entire feature while every individual piece of it (in
isolation) looked correct. Fixed by replacing the fixed-margin placement
with `placeFleets(rules, attackerShips, defenderShips)`: the gap between
fleets is now `3×` the larger fleet's own footprint width (floored at 900
world units so even a lone ship has room to use missile range before beam
range), and the whole attacker-gap-defender span is centered in the world.
A small battle now opens as a small, tight, centered cluster; a big one
spans proportionally more of the bigger world — which is what makes
`shipBounds`'s size actually track ship count, which is what makes
`pickFitZoomIndex` actually vary. **Lesson: an "adaptive to X" feature is
only as adaptive as whatever produces the data it reads — the zoom code was
correct from the start and still produced a non-adaptive result, because
the placement code feeding it wasn't actually a function of the thing being
adapted to.** Also had to move the defended-planet's position off a fixed
`worldWidth - 60` (which put it far from a defender fleet now placed near
the world's center) onto `layout.defenderEndX + 220`, returned by
`placeFleets` for exactly this purpose.
Also discovered while re-calibrating after the placement change: the
cloak/singularity A/B check (section 11) needs 10 ships per side, not the 5
used everywhere else in that section, to read a stable signal — at 5-a-side
the softened (weighted-random) targeting's pairing chaos fully swamps a
deliberately subtle 2%/0.5-shield-point edge (~48-50%, indistinguishable
from noise); at 10-a-side there are enough simultaneous ship-vs-ship duels
for the law of large numbers to smooth that out, and the same edge reads a
stable ~69-70% — comparable in spirit to the live engine's own ~62%
calibration. Not a regression to chase further, a real property of small-N
per-ship combat, same family as trap 28. **Superseded by the continuous-time
rewrite below** — the added movement/collision-avoidance chaos washed this
out further; see that section for the current numbers (15/side, ~55-57%).
**Continuous "fluid battle" rewrite, same day**: replaced the discrete
turn/round model with a real continuous simulation, per Brian's explicit
ask — "do away with the turn concept... make this a single fluid battle."
`stepRound(b, orders)` (whole-battle, lockstep) became `advance(b, dt,
{allowRetreat})` (fixed `SIM_DT = 1/30`s tick), driven either headlessly by
`runBattle()`'s tick loop or in real time by the view's own accumulator (see
below). The old discrete "round" became a per-ship **cooldown**: every ship
tracks its own `cooldown` timer, independently arms it (`turnSeconds = 2`s,
`data/mastervega-rules.json`'s `combatV2` block) the moment its target comes
into range — not from battle start — and fires when it hits zero. Damage
stays **banked** (`queueDamage`/`applyPendingDamage`, into `b.pending`) for
the same reason movement already was (trap 27): tried applying it
immediately first, on the theory that independent per-ship cooldowns would
rarely land on the exact same tick — wrong, because every ship shares the
same `turnSeconds` cadence, so ships that arm around the same moment stay
synchronized on every later cycle too, recreating the old "whoever's
processed first this tick wins" bias (22pp on a mixed-hull mirror match)
right back.
Movement gained real physics: ships now carry **angular momentum**
(`angularStep()` — a bang-bang accelerate/decelerate controller with a
`stopDist = ω²/2·accel` braking-distance check, tuned so a hull never
overshoots its target heading and oscillates) instead of snapping to a
heading instantly, and **collision avoidance** (`computeSeparation()` —
quadratic falloff by penetration depth into `avoidRadius = sizeScale ×
separationUnit`, blended with the seek-the-target vector). One easy-to-miss
design point: the tangential push direction in `computeSeparation()` must be
a **fixed** rotation (`px, py = -ry, rx`, always the same handedness) not a
per-ship-parity choice — a per-ship approach was tried first and silently
cancelled out on exactly the case that matters most, two ships approaching
head-on, because that geometry is already anti-symmetric between the two
ships and a parity-based tiebreak fights itself. Movement itself was
deliberately slowed down (`turnRateScale`, `moveUnitsPerSpeed` retuned down)
per Brian's explicit ask, on top of whatever slowdown the momentum/avoidance
math added on its own.
Traps 29 and 30 (above) were both surfaced by this change specifically —
continuous time turns "how long does closing distance take" from a free
action into something that competes for the same time budget as everything
else, which is what made a latent placement-gap bug (29) and a movement/
firing range-check disagreement (30) both newly acute enough to decide
battles or stall them outright. With both fixed, the mixed-hull mirror bias
that trap 28 could only mitigate to ~15-25pp is now **fully gone** (49.5% /
50.5% measured on a 17-ship/side mixed fleet over 200 seeds) — it turned out
to be almost entirely a symptom of traps 29/30, not the targeting chaos
trap 28 described. Same-hull mirrors still lean defender by a modest amount
(~5-12pp across tested fleet sizes) — inherited unchanged from the live
engine's deliberate "an attacker being beaten badly leaves early" early-exit
rule (`applyAutoOrders`, asymmetric by design, not a new bug), just more
visible now that battles reliably run to a real conclusion instead of being
dominated by the old stall/disengage noise.
`VegaCombatViewV2.js` was rewritten to match: "Next round" became
**Play/Pause**, driven by a real-time accumulator on the scene's own
`update` event (same register-once-per-battle/`off()`-in-`destroy()`
lifecycle as trap 25's camera fix — this is a second, independent listener
needing the same discipline). Ship containers are set directly to the
engine's real `x`/`y`/`facing` every frame rather than tweened between
round snapshots, since the sim already produces smooth motion at 30
ticks/sec — a tween on top of that would only add lag. Sound went from
"~12 cues sampled per round" to **rate-limited over real time**
(`SOUND_MIN_GAP_MS`/`DEATH_SOUND_MIN_GAP_MS`, a minimum gap between cue
starts) since fire events now arrive as a steady trickle rather than one
big per-round batch — every event still gets its full visual FX, no
sampling needed for that any more.
**Linear momentum + size-scaled brake authority, same day**: Brian's ask —
"the smaller a ship is the more I want to enforce momentum," battleships
should be able to slow and stop, frigates should constantly be moving and
run strafing runs, and "don't allow any ship to stop on a dime... they
should have to turn and engage thrusters again." Explicitly left the exact
mechanics to design judgment ("whichever decision is most supportive of
[the real-space-battle] concept is the right decision").
- **Real velocity vector (`vx`/`vy`) replaces the old kinematic clamp.**
Previously a ship's position was computed directly from "how far is left
to the stopping point," which meant it could never overshoot — no real
inertia existed for translation (only rotation had momentum, from the
earlier session). Now velocity integrates thrust over time and is capped
at `effSpeed`, same as a real accelerating/decelerating body.
- **New per-hull `brakeSeconds`** (hulls block, live engine ignores it):
time to kill full speed under max thrust. `linearAccel = effSpeed /
brakeSeconds`. A hull whose stopping distance at full speed
(`speed²/(2·linearAccel)`) is comfortably under `beamRange` can decelerate
to a stable hold well inside weapon range; one whose stopping distance
exceeds `beamRange` physically cannot stop before reaching the target and
blows straight through — this ONE consequence of the physics is what
produces both "battleships can hold" and "frigates must strafe," with no
hull-specific tactic branch anywhere in the code. Tuned values:
battleship 1.3s, cruiser 1.8s, destroyer 2.8s, frigate 11s, scout 15s
(unarmed, doesn't matter). Destroyer/cruiser are deliberately marginal at
high tech tiers — ships get faster with tech but brakes don't scale with
it, so a fully-teched destroyer can slip from "holds position" into
orbit-like strafing purely from being fast enough to outrun its own
brakes. Not a bug: a nice emergent "battles get more fluid and chaotic as
tech advances" property, left as-is once understood (see the debugging
story in trap 31 for how this was initially mistaken for one).
- **Two real bugs found and fixed getting here — traps 31 and 32 above.**
Both were caught by isolated smoke tests before ever reaching the full
verifier, same methodology as the original momentum-controller work:
design in isolation, measure, fix, THEN integrate.
- **Thrust/facing decoupling + alignment scaling** (trap 31): facing still
steers toward the ship's current task direction with the same momentum
controller as before, but thrust magnitude (not direction) is scaled by
how well facing has caught up — full strength when aligned, zero at 90°+
off. This is what makes turn rate matter again without reopening the
original battleship-never-stops failure.
- **Collision avoidance gets its own acceleration budget** (`avoidAccel`,
flat across every hull) **plus golden-angle approach-point spreading**
(trap 32) — both needed together to keep ships that share a
weighted-random target from converging on its literal coordinate.
- **A ship's own current target gets a reduced, not zero, avoidance radius**
(`TARGET_AVOID_FRACTION = 0.35` in `computeSeparation`) — fully excluding
it (tried first) let a fast, hard-braking hull occasionally overshoot to
exactly 0 distance, flying dead through the target's center; a small
floor stops that without reintroducing the original "avoidance fights
engagement" problem (personal-space radius summed between two big hulls
can exceed `beamRange` — 350 vs 220 for two battleships — so full-strength
avoidance against your own target made closing to weapon range
impossible in the first place).
- Verifier section 11 gained two permanent checks matching the smoke-test
methodology: a battleship/frigate minDist-vs-stationary-target gradient
(battleship `minDist > 50`, frigate `minDist < 20`, tuned against actually
measured values with margin, not guessed), and a multi-ship convergence
worst-gap-ratio check (`> 0.05`, well below the ~0.1-0.4 normally
measured, so it only fires on a real regression). The existing turn-rate
invariant check had to be rewritten around "time to first reach weapon
range" instead of "net distance closed in a fixed window" — under real
momentum, a fast-turning hull can overshoot through its target and read
as *worse* net progress than a slow hull that hasn't arrived yet, which
is exactly the strafing behaviour this feature is FOR, so the old metric
actively fought the new physics rather than measuring turn rate. The
cloak/singularity A/B check (last retuned two sessions ago for the
continuous-time rewrite) needed retuning again — the added movement chaos
washed the deliberately subtle signal down to noise (~48-50%) at every
fleet size fast enough to test on every run; rather than chase a shrinking
effect through ever-larger, ever-slower trial counts, the check now
asserts the flags don't measurably HURT rather than that they measurably
help, which is the honest thing that's actually still true and stable.
- Quick suite: 2591 passed, 0 failed. Full (non-quick) suite: 2593 passed,
0 failed.
- Never browser-tested, per [[feedback_no_auto_verify]].
**Collision avoidance de-fanged, still 2026-08-09** (Brian's correction —
"I don't need or want the ships to bounce off of each other... let's not
stop them from overlapping as needed"): `avoidAccel` (added same day for
trap 32) had been tuned to 1000 world units/s², a force an order of
magnitude stronger than any hull's own tactical `linearAccel` (~11-70
across hulls/tiers) — strong enough to produce a visible, sharp deflection
the instant two ships got close, which is exactly the "bounce" Brian was
reacting to. That strength existed specifically to STOP ships from ever
getting close, which turns out to have been solving a problem Brian
doesn't want solved: overlap during a strafing pass or a shared-target
convergence is fine, even expected. Turned down to `avoidAccel: 60`
comparable in scale to a hull's own thrust, so avoidance is now a real but
gentle steering preference, easily overridden by whatever the ship's
actual objective requires. The `computeSeparation`/`GOLDEN_ANGLE`
mechanics from trap 32 are unchanged (ships sharing a target still fan out
toward slightly different approach points, purely cosmetic/geometric, not
a force), only the avoidance force's magnitude changed. The multi-ship
convergence verifier check (added same day) had its own assertion inverted
to match: it no longer requires a minimum gap between non-targeting ships
(routinely near 0 now, by design) — it instead asserts no single-tick
velocity change exceeds a small bound, which is the actual thing "no
bounce" means and the thing worth protecting against regressing. **Lesson:
when a fix silently over-corrects a stated design goal into its own
success criterion (here, "ships shouldn't collide" quietly became "ships
must maintain a comfortable gap at all times"), the verifier coverage
written to protect that fix can end up actively hostile to a later,
perfectly reasonable design correction — reread what a check is actually
asserting against the CURRENT intent, not just whether it still passes.**
- Quick and full suites: clean, 0 failures (numbers unchanged from above;
only the one convergence check's assertion changed shape).
- Never browser-tested, per [[feedback_no_auto_verify]].
**Agility variance widened + battleship's braking actually fixed, still
2026-08-09** (Brian: heavier ships "look good," but wants "much more
variance in agility" — frigate tuned like "an X-wing fighter" (turn rate
AND acceleration), destroyer "a good midpoint" between frigate and cruiser
"leaning on more agile." Explicitly asked whether frigate's now-strong
acceleration should be allowed to let it hold position sometimes too
(previously impossible by design) — Brian chose yes, full agility over
preserving the old always-strafing identity).
- **Retuned** (`hulls` block; cruiser/battleship untouched): frigate
`turnRateBase` 150→340, `brakeSeconds` 11→1.0 (now the single most agile
hull in the fleet on both axes, stronger brakes than even battleship);
destroyer `turnRateBase` 100→230, `brakeSeconds` 2.8→1.35 (sits between
frigate and cruiser on both axes, leaning toward frigate's end).
- **This immediately surfaced trap 33** — testing the retune at tier 9
revealed battleship no longer reliably held position at all (flying
through a stationary target at full speed), a regression that had
nothing to do with today's hull changes and everything to do with last
session's `avoidAccel` weakening quietly removing a crutch that had been
propping up braking all along. Fixed with anticipatory pre-turning — see
trap 33 for the full mechanism and the interim version that overcorrected
(ships stopping short of their own weapon range). This fix is what makes
today's frigate retune actually deliver "can hold sometimes" rather than
"flies through even more dramatically than before" — the two changes
landed together, in the order they were found, not independently planned.
- Verifier: the frigate check that used to assert "cannot hold" was
inverted to match the new design intent (now checked identically to
battleship/cruiser/destroyer — all four warship hulls must be able to
decelerate and hold within a stationary-target smoke test). Added a
small set of pure data-integrity checks against the `hulls` block itself
(frigate turns fastest and brakes hardest of any warship; destroyer's
turn rate sits above the frigate/cruiser midpoint) so the AGILITY
ORDERING Brian asked for stays protected even as exact tuning numbers get
adjusted later, independent of how any particular simulated scenario
happens to play out.
- Quick suite: 2596 passed, 0 failed. Full suite: 2598 passed, 0 failed.
- Never browser-tested, per [[feedback_no_auto_verify]].
**Parked ships no longer coast forever, still 2026-08-09** (Brian noticed
ships "just drifting" and asked whether every ship is always actively
targeting/pursuing — answer: yes, always, as long as any enemy is alive,
but a settled ship's residual velocity was never explicitly zeroed).
`computeShipMove()` previously started every tick's velocity from `s.vx`/
`s.vy` unconditionally, even when `hasTask` is false (parked: in range,
already below `BRAKE_SPEED_EPS`) — meaning whatever tiny speed (up to just
under `BRAKE_SPEED_EPS`, ~2 units/s) it had at the exact moment it crossed
the "stopped" threshold was never killed off, and it coasted at that speed
indefinitely. Fixed: velocity now starts from 0 (not `s.vx`/`s.vy`) whenever
`hasTask` is false, with collision avoidance still applied on top of that
clean zero afterward (so a parked ship can still be nudged by a neighbour —
only the target-seeking residual was the bug, not avoidance's own gentle
push). Confirmed via a stationary-target smoke test across all four warship
hulls: `finalSpeed` is now exactly `0.0000` once settled (was ~1-2), and
each hull spends hundreds of ticks sitting at literal zero rather than
perpetually gliding.
- Quick and full suites: clean, 0 failures.
- Never browser-tested, per [[feedback_no_auto_verify]].
**Beam colour by species + V2-only damage multiplier, still 2026-08-09**:
(1) beam weapons (not missiles, a different archetype — those keep their
fixed orange) now render in a vivid, saturation/value-boosted version of
the FIRING ship's species `color` (the same hex already used throughout
the UI for portraits/panels/text), so a battle reads at a glance who's
shooting whom — `vibrantSpeciesColor()` in `VegaCombatViewV2.js`, memoized
per species since the same handful of species recur across every fire
event.
(2) Brian, after watching V2 play out live: battles take "a ton of turns,"
wanted ship power amplified — asked for pros/cons before any change. Key
tradeoff flagged and agreed: weapon damage and hull HP are NOT V2-specific
data, `VegaCombatV2.js` builds ships via the same shared `designFor()` the
live engine reads, so editing those values directly would change the live
game's balance too. New `combatV2.damageMultiplier` (started at 1.6,
"modest") scales each weapon's raw output BEFORE shield mitigation inside
`fireMounts()` only — shields stay a fixed absolute reduction per hit
(amplified guns naturally eat into them relatively more, rather than the
multiplier just padding already-mitigated damage), and the live engine's
weapon numbers are completely untouched.
**Finding, more interesting than the fix itself**: measuring actual battle
duration before/after revealed the auto-disengage timer (`disengageFraction
× maxRounds × turnSeconds` ≈ 102s) is ALREADY firing in effectively every
simulated battle before combat concludes naturally, regardless of damage
level — at multiplier=1, 100/100 sampled 17-ship mixed-fleet battles ended
with ships still fleeing, not dying. 1.6x meaningfully shortens duration
for typical/smaller same-hull fleets (5×cruiser: 96.8s→69.6s; 8×destroyer:
54.4s→27.3s — roughly halved, and the fraction resolving by pure attrition
rather than forced retreat roughly doubled or better) — but the large
17-ship mixed-tier fleet barely moved (102.0s→98.2s) because that
composition's total HP pool still isn't ground down enough by the
disengage deadline even at 1.6x; pushing to 3x got 41/100 to resolve by
pure attrition there, versus 0/100 at baseline. **The disengage-timing
CONSTANT itself, not just raw damage, may be the dominant lever for "battles
take too many turns"** — tuned under different pacing assumptions
(pre-momentum, pre-agility-retune), and worth reconsidering independently
of how far the multiplier gets pushed.
- Quick suite: 2596 passed, 0 failed. Full suite: 2598 passed, 0 failed.
- Never browser-tested, per [[feedback_no_auto_verify]].
**Disengage timer extended, then damage pushed further — the two levers
pull in OPPOSITE directions, still 2026-08-09**: acting on the finding
above, Brian asked for "a meaningful adjustment to the disengage timer."
New `combatV2.maxDurationSeconds` (240, up from the implicit 120 via the
shared `rules.combat.maxRounds`) is V2's own total time budget — a new
`maxDurationSec(b)` helper in `VegaCombatV2.js` replaces every
`b.C.maxRounds * turnSeconds` computation (three call sites: the disengage
trigger, the hard-timeout win condition, `runBattle`'s tick guard), same
V1-isolation reasoning as the damage multiplier — `rules.combat.maxRounds`
is shared with the live engine's own round cap and must stay untouched.
`disengageFraction` also moved 0.85 → 0.9 (216s trigger, was 102s).
**Counterintuitive result, worth remembering**: this made battles LONGER,
not shorter — the opposite of the original ask. The disengage timer hadn't
been making fights drag on; it had been functioning as an IMPLICIT
duration cap, cutting battles off at ~102s whether or not combat was
actually close to a real conclusion. Removing that cap let fights that
genuinely needed more than 102s take it: 17-ship mixed fleet 98.2s→174.9s,
5×cruiser 69.6s→141.1s, 8×destroyer 27.3s→84.8s — all roughly doubled.
Decisiveness DID improve a lot (pure-attrition resolution went from
single digits to 25-38% across scenarios), but "battles take a ton of
turns" and "battles get cut off by an arbitrary timeout before concluding"
turn out to be two different complaints that don't share a fix — solving
the second one directly worked against the first. **Lesson: when a
diagnostic finding motivates a fix, check which of the ORIGINAL stated
goals that fix actually serves — a mechanism that's "wrong" by one measure
(decisiveness) can simultaneously be load-bearing for another (duration),
and removing it doesn't just remove the problem, it removes whatever it
was accidentally solving too.**
With the timer no longer artificially truncating fights, `damageMultiplier`
became the correct lever again — pushed 1.6 → 2.8 after sweeping
1.6/2.2/2.8/3.5 and finding duration drops sharply and monotonically as
multiplier climbs while decisiveness holds or improves (not a tradeoff
against itself, unlike the timer). Net result across the three test
scenarios, timer-extension-and-damage-push combined: 17-ship mixed
102.0s→67.9s (44% pure-attrition, was 0%), 5×cruiser 96.8s→45.2s (50%, was
8%), 8×destroyer 54.4s→17.6s (58%, was 33%) — meaningfully shorter than
the ORIGINAL pre-any-change baseline on every scenario, and far more
decisive throughout.
**Real, expected cost**: mirror-match bias rose to 27.3pp (was passing at
<25pp) lower time-to-kill means whoever lands the first good roll matters
proportionally more, exactly the variance-sensitivity tradeoff flagged
before any of this work started. Section 11's mirror-bias tolerance widened
0.250.30 to match, documented as a deliberate, explained consequence of
the multiplier change (not a targeting regression) rather than dialing the
multiplier back to dodge a threshold tuned for a much slower-TTK combat
model.
- Quick and full suites: clean, 0 failures (after the tolerance widening).
- Never browser-tested, per [[feedback_no_auto_verify]].
**"Ships are running away from each other" two real bugs, not disengage,
still 2026-08-09**: Brian noticed capital ships visually behaving like
they were fleeing each other and asked whether that was the auto-disengage
mechanism. It wasn't disengage is a rare, whole-side, once-per-battle
event; what he was seeing happened continuously, ship-pair by ship-pair,
during ordinary combat. Traced to two separate causes, both fixed same
day see traps 34 and 35 for the full mechanism of each:
- **Trap 34**: the anticipatory pre-turn (trap 33) snapped a ship's nose
straight onto retrograde for its ENTIRE pre-turn window (15-20+ seconds
for the slowest hulls), collapsing corrective steering thrust toward
zero for that whole stretch harmless when nothing else was nearby (the
scenario trap 33 was validated against), a real problem the instant
something WAS (even gentle collision avoidance from a teammate). Fixed
by blending the pre-turn target smoothly instead of snapping.
- **Trap 35**: ships steer at their target's CURRENT position, not where
it's actually headed fine against a stationary or slow target, a real
failure against a target that's independently pursuing a THIRD ship
entirely (common in any multi-ship battle) and therefore moving in a
direction that has nothing to do with this shooter. New
`predictIntercept()` (classic lead-pursuit quadratic, degenerates
exactly to today's plain pursuit for a stationary target, falls back to
it when no real intercept exists) fixed the actual "never closes the
gap" failure.
- Both fixes were found and validated the same way as everything else in
this movement system: isolated math/scenario smoke tests first (8 unit
cases for the intercept algebra alone stationary target, closing
head-on, receding-but-catchable, receding-faster-than-shooter, matched
speeds, degenerate shooter-at-target-position), THEN wired in, THEN
re-run against the actual 2-battleship trace that exposed the bug in the
first place to confirm the specific symptom was gone, THEN the full
verifier.
- New permanent verifier check (section 11): the same 2-battleship-per-side
scenario, asserting a still-alive, still-being-chased target's distance
closes to within 3× beamRange over a full minute rather than growing
without bound protects the actual regression found, not a proxy for
it.
- Quick suite: 2597 passed, 0 failed (one more check than before). Full
suite: 2598 passed, 0 failed.
- Never browser-tested, per [[feedback_no_auto_verify]].
**Strategy formations actually shape placement, still 2026-08-09**: from
the very first formation-strategy session ("this is plumbing only
formation strategy does not yet affect placement, movement, or
targeting"), Brian specified exactly how each strategy should arrange a
fleet and asked for per-hull-type spacing instead of the uniform grid at
the same time. Both landed together, since they're the same rewrite.
- **Per-hull spacing, not a uniform grid**: `fleetFootprint`/`placeFleet`
(one grid cell size for the whole fleet, sized by whichever hull happened
to be biggest present a lone battleship among many destroyers used to
force every destroyer to keep battleship-sized distance for no reason)
replaced entirely. New primitives `packUniformGrid` (a small grid for
one hull-homogeneous group), `packColumn` (a single-file line, spacing
each adjacent PAIR by their own two `avoidRadius` values, so a mixed-hull
column still gets correct per-pair spacing) are built on `avoidRadius`,
which already existed for collision avoidance rather than a second,
independent size concept.
- **Local (depth, spread) coordinate space**: every layout works in depth
(distance behind this side's own front line "the front of battle,"
Brian's own framing, as the reference point) and spread (perpendicular
offset from centerline) BEFORE `placeFleets()` translates into world
coordinates, flipping which raw-X direction is "deeper" per side.
- **Power Pressure** ("bigger tougher ships on the front line... well
ahead of the frigates and destroyers who form a vertical line in the
rear... support the larger battleships and cruisers getting to the
middle area first"): hulls above `HEAVY_SIZE_THRESHOLD` (sizeScale > 1.0
— cruiser/battleship; destroyer sits at exactly 1.0 and stays rear, per
Brian's explicit naming) form a compact cluster at shallow depth;
everyone else forms one tall single-file column well behind them.
- **Speed Swarm** ("large ships in the rear, fanning out smaller and
smaller ships towards the center... a tall arch... as wide an angle as
possible"): hulls grouped into size bands (same hullId = same
`avoidRadius` = same band), swept along an eased curve
(`sin(t·π/2)`, not linear, so the curve stays narrow near the rear and
only fans out as hulls actually get smaller) from rear-and-narrow
(biggest) to front-and-wide (smallest) — the smallest, fastest hulls end
up both closest to the front AND furthest to the sides, so a longer,
wider diagonal path is offset by a head start and (now, post-agility-
retune) genuinely higher speed, aiming for a simultaneous multi-angle
arrival rather than exact-to-the-second synchronization.
- **Three real bugs, all caught by the same isolated-first methodology used
all session** (design → dedicated same-side-overlap smoke test → full
battle simulation → verifier) — see traps 36-38 for the full mechanism
of each: (36) a spacing term multiplied through a fan magnitude that's
deliberately 0 for the rear band, collapsing to zero offset instead of
real spacing; (37) a vertical-fit safety clamp that scaled already-exact
minimum distances down along with everything else — fixed at the source
(bound the fan shape before placing, never scale placed coordinates
after) instead of patched at the symptom; (38) a genuine mixed-hull
mirror-match bias (33-34pp) from Power Pressure's front tier placing
same-row ships at the exact same depth — a perfect-tie geometry, same
shape as trap 27's collinearity bug — mitigated with a second,
decorrelated depth wobble down to ~8pp with retreat enabled.
- Three EXISTING verifier checks broke, not from a regression in this
rewrite but from an outdated test assumption: none of them pinned an
explicit `formationStrategy`, which was harmless while formation was
pure plumbing but now lets each side silently resolve to a DIFFERENT
strategy (mismatched footprint shapes) or makes results depend on
exactly which strategy a given seed happens to draw. Fixed by pinning
explicit, matching formations in every geometry-focused check, and by
switching the "footprint grows with fleet size" check from literal
fleet-to-fleet gap to overall `shipBounds` width, since Speed Swarm's
footprint growth is often more about spread than depth.
- New permanent verifier checks (section 11): Power Pressure's heavy/light
depth ordering and column shape, Speed Swarm's size-based fan spread and
forward lean, and same-side minimum-spacing safety for both formations.
- Quick suite: 2604 passed, 0 failed (6 more checks than before). Full
suite: 2606 passed, 0 failed.
- Never browser-tested, per [[feedback_no_auto_verify]].
- **Follow-up, same day (trap 39)**: Brian watched it and reported units
still clustering near screen-center — the front tier's "column" was
actually stacking along depth, not spread, so it never varied in world-Y
at all. Fixed by rewriting the column packer to stack along spread, and
a horizontal (not vertical) 40%-of-world-width floor was added on the
attacker/defender front-line gap in `placeFleets` after Brian corrected
an initial vertical-axis misread of his own "middle 40% empty" ask. See
trap 39 for the full mechanism of both the bug and the correction.
**Battles favor staying centered until ships are defeated, still
2026-08-09** (Brian: "battles wind up being great at the beginning and then
drift off screen towards the end... have the ships favor centered battles
until such time as ships are defeated"): confirmed two contributing facts
before writing any code — `VegaCombatCamera.js`'s `bindZoomPan` computes its
starting zoom/pan ONCE, from `fitBounds` at battle creation, and never
re-fits as the fight moves; and nothing in `computeShipMove()` constrained a
ship's position at all — pursuit, strafing, and chasing survivors were free
to wander anywhere in the open 3600×2400 world. Fixed at the physics level
(not the camera): a new gentle, always-on "return toward roughly where the
battle started" force, same shape as the existing `avoidAccel` — its own
flat acceleration budget (`combatV2.centeringAccel`, 45), never
alignment-gated, purely additive so real task/avoidance thrust can always
override it.
- **The anchor is FIXED, computed once in `createBattle`, not a live
centroid recomputed every tick.** A live centroid (mean of wherever every
ship currently is) can't actually counteract net drift — by definition
it's already wherever the fight currently is, so pulling ships toward it
only tightens clustering around the fight's current location, it doesn't
stop that location itself from translating. `battle.centerX/centerY` is
the mean position of every entity (both fleets, planet included) at the
moment they're placed; `battle.centeringComfortRadius` is the farthest
any entity actually started from that point, ×1.2 slack.
- **The comfort radius is sized to the battle's OWN starting spread, not a
guessed fraction of world size.** This was the one real design trap:
Power Pressure's front/rear depth split and the newly-added trap-39
horizontal center-gap both deliberately spread ships far from world
center at t=0 — a generic fixed-fraction radius would have undercut that
and made the centering force immediately fight the placement work from
earlier the same day. Deriving the radius from the actual starting
geometry instead guarantees the force is exactly zero at t=0 for any
formation, fleet size, or fleet-vs-planet configuration, by construction
— confirmed by a verifier check asserting `worstStart <=
centeringComfortRadius` for a real mixed-hull Power-Pressure battle.
- `computeCenteringForce(s, center)` mirrors `computeSeparation`'s falloff
shape: zero inside the comfort radius, then ramps in QUADRATICALLY
(`t*t`), capped at full strength by 2× the comfort radius so it can't
out-muscle real combat thrust indefinitely once a fight has genuinely
wandered far.
- Verified in three stages before touching the verifier: (1) a stranded
ship placed 3× its comfort radius from center, given no target so no task
thrust competes, measurably moves toward center over one tick; (2) eight
seeds of a real mixed-hull mirror battle, comparing max distance any
living ship ever reached from its own battle's center — centering ON
averaged 2951 units vs. OFF's 8081, and was never-worse on 8/8 seeds; (3)
the same cruiser×5/destroyer×8 mirror-match bias check used throughout
the damage-tuning work, confirming centering doesn't shift attacker win
rate at all (0.53/0.60, bit-for-bit identical on vs. off at the same
seeds) — it's purely a geometry/timing effect, not something that touches
the hit-chance/RNG path. Bonus, not the ask, but worth recording: duration
dropped too (cruiser×5 78.9s→42.8s, destroyer×8 38.8s→31.8s) — pulling
stray ships back together lets them re-engage instead of coasting apart.
- New permanent verifier checks (section 11): the zero-force-at-start
invariant, and a 5-seed on/off average-max-drift comparison.
- Quick suite: 2610 passed, 0 failed. Full suite: 2612 passed, 0 failed.
- Never browser-tested, per [[feedback_no_auto_verify]].
### Ship rows carry video, so they have to be pooled
Every place a ship is listed — the side panel's task force, its in-transit,
garrison and order lists, and the colony catalogue — now shows a looping
**commander video** beside an upside-down picture of the hull, and opens a
centred detail window (`VegaShipDetail.js`, depth `D.detail` 76) with the clip at
its full 256px, the hull at 192px, `hull.desc`, the derived stat block and the
loadout the knapsack settled on. Everything in that window comes out of
`designFor`; no new data was added.
Two things about it are load-bearing.
**The portraits cannot be created inline.** Both hosts rebuild their entire body
on every click — `VegaSidePanel.rebuild()` on each / +, `rebuildFlyout()` on
each enqueue — so building the portraits with the rows would tear down and
re-create a decoder per row per click, restarting every loop under the player's
cursor. `createShipMediaPool` in `VegaShipMedia.js` keeps them in a layer the
rebuild does not touch, bracketed `beginFrame()``endFrame()`; unclaimed
entries are hidden and paused, never destroyed. The colony catalogue wipes with
`removeAll(true)`, so its pool layer is lifted out with `detachPool()` first and
re-homed into the fresh scroll column afterwards — inside `content`, so it
scrolls and masks with the rows it belongs to.
**Duplicate Video objects on one cached video are fine — the note at the top of
`openSpeciesDetail` is more cautious than it needs to be.** Checked against the
Phaser 3.90 source: `Video.loadHandler` builds its own element with
`document.createElement('video')`, and `preDestroy``removeVideoElement()`
detaches only that instance's. The cache holds a URL, not a shared element. That
is what makes the fallback ladder usable at all: a species with no ship clips
shows its portrait video on all seven rows at once. The cost is decode time, and
pausing is how it is paid — the panel pauses on `hide()`, and both hosts pause
while the detail window is up, since that window has its own portrait at 256px
and there is no point running both. (The reparenting in `openSpeciesDetail` is
still fine and still halves the decoding there; it just is not a correctness
requirement.)
A missing clip is a **legitimate fallback, not a bug**, which is why the verifier
reports `7/80 recorded` rather than failing. What it does assert is that nothing
*declared* is wrong — every key must equal `shipVideoKey(species, hull)`, which
is the trap the `colonyship` / `ship-human-colony.mp4` filename mismatch sets.
### Founding a colony is a cutscene, not a report row
Planting a colony is the most consequential thing a player does on the map, and
it used to be announced the *next* turn as one row in the "New Turn" popup —
the same weight the game gives a finished refit. `VegaColonyIntro.js` replaces
that with a full-screen vignette the moment the Found Colony button is pressed:
the soundtrack ducks, the world's own 1920×1080 backdrop fills the screen, a clip
of *this world* being settled plays in a framed window over it alongside
`assets/music/vega/colony.mp3`, and the founding numbers are read out underneath.
`colonised` is **out of `NOTABLE_TYPES`** as a result — put it back and the same
event is announced twice, the second time smaller.
Seven things about it.
**The clips are fetched just in time, not by the asset manifest — and Phaser's
video loader does not fetch anything at all.** `VideoFile.load()` records the
URL, marks itself complete and returns; its own comment reads *"we don't
actually load anything (the Video Game Object does that)"*. The bytes arrive
when a `Video` sets `el.src`. So putting 13 clips in the manifest never cost
19 MB at game-room entry — it cost 13 cache entries — and a "warm-up" that only
went through `scene.load` would have warmed nothing. What keeping the block out
of the manifest actually buys is **ownership of the timing**:
`ensureColonyVideo()` registers the URL *and* pulls the bytes through a detached
`<video preload="auto">` on the same URL, so the game object's own request hits
the browser cache. The system view calls it the moment the Found Colony button
appears — a colony ship in orbit over a settleable world, the strongest signal
available that the vignette is about to be needed — which is a better moment
than "entered the room" and a far better one than "the vignette is already on
screen". If the clip still has not landed when the vignette opens, the window
says `ACQUIRING COLONY FEED…` and the picture fades into it on arrival. Three
details it is built around: `filecomplete-video-<key>` is emitted with the key
as a *string* while `FILE_LOAD_ERROR` is emitted with the *File* and fires for
every failing file in the queue, so only the error handlers filter by key; the
in-flight set is a `WeakMap` keyed by scene, because the loader is per-scene and
leaving mid-fetch must not wedge a key against a loader that no longer exists;
and the panel re-asks on every rebuild, so a warm-up with no callback registers
no listeners at all. Section 2 asserts the block stays out of the manifest — the
resolver runs headlessly against a cache stub — because putting it back would
quietly restore the 19 MB and nothing else would notice.
**Two pictures of the world are on screen at once, and the clip is the optional
one.** The backdrop is always the opaque 1920×1080 still (`worldBackgrounds`,
gradient-painted from `type.color` when that art does not exist). The 960×544
clip (`colonyVideos`) plays in a window between the masthead and the
congratulation line, right-aligned so its edge lands on the centre line of the
orrery disc in the top corner — the third picture of the same world, next to the
disc that is the first. A type with no clip simply gets **no window**: the
vignette reads as a still-backed report card rather than a broken frame, which
is why the verifier only *reports* the clip count while asserting that a type
without one still has a backdrop to stand on.
**Only colonisable types are declared.** `canColonize()` rejects `gasgiant` and
`asteroids` on the static flag *before* any species trait is consulted, so a
clip for either could never play. Section 2 fails a `colonyVideos` entry naming
a type that cannot be settled, the same way it fails a key that does not equal
`colonyVideoKey(typeId)`.
**The window is cut to the clip, and the clip is scaled by width alone.** The
frame is 640×363 — 960×544 exactly — so one axis settles the other and nothing
has to reason about two. The scale goes through `VegaArt.sourceWidth()`, which
is load-bearing rather than a nicety: see trap 23, which this clip is what
finally exposed.
**It plays once, holds the last frame, and offers a replay.** `setLoop(false)`
and `play(false)` — an `HTMLVideoElement` stops on its final frame and the
texture keeps it, so ending *is* the still. On Phaser's `complete` a badge fades
in at the picture's bottom-right and a hit zone over the *whole* picture is
armed; clicking anywhere replays it. Both go in **after** the video (a Container
renders in insertion order), the hit zone is armed and disarmed with
`setVisible`, which is also what gates Phaser's input (`inputCandidate`), and
`complete` is bound with `on`, not `once`, so it re-arms after every replay.
`play()` restarts cleanly because `completeHandler` clears Phaser's
`_playCalled` and an ended element seeks back to 0 by itself.
**Keeping the sound on took three separate guards.** This is **the only video in
the game with audio** — `setVolume(0.9)` over the founding cue, loaded with
`noAudio: false` while `services/assetLoader.js` passes `true` for every
portrait and ship clip — and three things in Phaser 3.90 will silence it: (1)
`loadHandler()` sets `el.muted` **and `el.defaultMuted`** from the cache entry's
`noAudio` flag, so a key ever registered with `noAudio: true` stays muted for
the session; (2) `playSuccess()` calls `setMute(true)` if the Sound Manager is
muted; (3) the first-frame handler copies `el.muted` back into `_codeMuted`.
`applyAudio()` re-asserts all of it on creation, on `created` and on `playing`,
and clears `defaultMuted` on the element directly — Phaser never does, and
`el.load()` re-applies it. The autoplay-refusal fallback hangs off **`locked`**
(`VIDEO_LOCKED`, emitted on a `NotAllowedError`); `playfailed` and `playerror`,
used here first, **do not exist in Phaser** and were dead code. Muting is the
whole fallback: Phaser retries from `preUpdate` on its own, and calling `play()`
again would no-op against `_playCalled`.
**Only the human ever sees it.** `VegaAI.js` plants colonies through the same
`Logic.colonize()`; the vignette is opened by the *view* that called it, never
by the engine. The event still fires for both, because the ticker log and the
turn-report classifier still walk it.
Section 6b covers what is checkable headlessly: that `colonised` is not notable
and has no label, that colonising still pushes its event, that the new colony is
findable by orbit the way the view finds it, and that every number the vignette
prints — max pop, output, construction, factory cap — is finite and sane on a
colony *one tick old*, for all 13 colonisable world types. That last state is
one no other screen ever sees.
**V2 becomes the official combat engine, still 2026-08-09** (Brian: "Let's
make V2 the new official way battles happen"): the guardrail stated
throughout every V2 entry above — `VegaCombat.js`/`VegaCombatView.js`
"completely untouched," every real player battle running the original
engine, V2 confined to `?movsim`'s toggle — is deliberately lifted here, at
Brian's explicit request, after V2's momentum/collision-avoidance/lead-
pursuit/formation/centering work (all documented above) had matured enough
to trust with real games.
- **Performance had to come first.** Benchmarked headlessly before writing
any migration code: V1 gets CHEAPER as fleets grow (0.26ms for
17-a-side, discrete-round aggregate math); V2 got far MORE expensive for
the identical fight (77ms) — the opposite trend, and dangerous, since
`VegaLogic.resolveCombats()` runs on every empire's turn for every star
with hostile fleets sharing orbit, against a 50ms/turn AI budget the
self-play soak already enforced. Brian's explicit choice, given this:
optimize first, then swap — not swap now and firefight later.
- **Traps 40-41** (above) are what that optimization pass actually took —
not the Math.hypot fix alone (which looked sufficient at the synthetic
benchmark scale that motivated it, 77.4ms→18.4ms for 17-a-side, but a
real self-play soak found a 46.3-second single-turn stall from a
278-ship battle), and not the spatial grid alone either (fixed the
278-ship case but a real soak found 790-ship late-game mega-wars still
stalling 16.9s). Both real problems were found by the SAME tool — an
actual 27-game self-play turn-time diagnostic, not synthetic mirror
benchmarks — which is also what confirmed the final fix: worst-case
turn time across a real soak dropped **46.3s → 16.9s → 4.5s**.
- **The wiring itself was a mechanical swap**, once the performance work
was done: `VegaLogic.js`'s `createBattle`/`runBattle` import switched
from `VegaCombat.js` to `VegaCombatV2.js` (`resolveInvasion` stays on
`VegaCombat.js` — ground combat has zero shared surface with either
space-combat engine); `MasterOfVegaGame.js`'s `playPlayerBattles()`
switched from `openCombatView` to `openCombatViewV2` (identical call
signature, confirmed by reading both before touching either). Both
`battleResult()` shapes were already kept identical from V2's original
design specifically so this swap would be mechanical when the day came.
One real semantic difference, checked and found harmless: `result.rounds`
is now elapsed SECONDS, not a discrete round count — confirmed by grep
that nothing in the real game UI ever reads it (only `VegaCombatSim.js`'s
own simulator does, already V2-aware).
- **`VegaCombat.js`/`VegaCombatView.js` were NOT deleted.** They still
power `resolveInvasion` and `?movsim`'s "Live" toggle option (kept for
direct side-by-side comparison during the transition) — cleanup, if ever
wanted, is a separate, easily-reversible decision made later, not
bundled into this change.
- Every check in this migration was gated on the FULL verifier and, twice,
on a real end-to-end self-play soak — not just synthetic benchmarks —
because that's specifically what caught both real problems traps 40-41
describe.
- Never browser-tested, per [[feedback_no_auto_verify]] — Brian to verify
a real game turn/battle in-browser.
**Ship-type commander roster along the bottom, still 2026-08-09** (Brian:
per-side row of each unique ship type's commander video, smallest hull
closest to that side's own screen corner, biggest toward the center; a
type that's completely wiped out swaps to a procedurally-generated static/
snow loop). `VegaCombatViewV2.js` only — headless `VegaCombatV2.js` is
untouched, this is pure presentation.
- `uniqueHullsFor(side)` reads the hull types actually present in
`battle.ships` at battle start — deliberately the SIMULATED roster only,
not `battle.attackerOverflow`/`defenderOverflow` (trap 41's mega-battle
cap): an overflow ship never appears on screen at all, so a commander
video for a type that's never rendered would be actively confusing, not
informative. Sorted by `rules.hulls[hullId].sizeScale` ascending — the
same "how big/tough is this hull" axis used everywhere else in V2
(formation placement, `HEAVY_SIZE_THRESHOLD`, etc.), reused rather than
inventing a second size concept.
- Positioned outward from each side's own corner (attacker bottom-left,
defender bottom-right) toward the horizontal center as size increases —
literally the spec, "smallest at the edge, strongest toward the middle."
Icon size itself stays UNIFORM across hulls (only position encodes size)
so a scout's portrait is exactly as readable as a battleship's — a
deliberate implementation choice, not asked for explicitly, made for
legibility. Verified with an isolated pure-JS re-derivation of the
layout math against synthetic fleets (can't run `VegaCombatViewV2.js`
itself headlessly — it imports Phaser): correct sort order, correct
edge-to-center growth direction per side, no same-side icon overlap, and
the two sides' rows never collide even at 6 unique hull types per side
(comfortably past what a real battle fields).
- The roster is fixed for the whole battle — who STARTED fighting, not who
currently has ships left. A wiped hull's SLOT stays in place; it goes
dark, it doesn't disappear or shift its neighbors' positions.
- Reuses `VegaShipMedia.js`'s `makeCommanderPortrait()` directly (same call
shape already used by `VegaShipDetail.js`) rather than its pooling
wrapper (`createShipMediaPool`) — the pool exists specifically for views
that rebuild their whole row list on every click (side panel, colony
catalogue); this roster is built once per battle and never rebuilt, so
the simpler direct call is the right tool, not the pool.
- **The "wiped" check reuses `syncMarkers`' own alive definition**
(`hp > 0 && !retreated`), not `hp > 0` alone — a side's last ship of a
type retreating counts as that type being gone from the fight the same
as it dying, consistent with how the rest of this view already treats
retreat.
- **Snow/static is procedurally generated, no video asset**: a small
(32×32) canvas texture per wiped slot (`scene.textures.createCanvas`,
the same pattern already used by Total Annihilation's fog-of-war/Zuma's
portal/Balatro elsewhere in this repo), filled with random greyscale
noise and refreshed on a `scene.time.addEvent` timer (~11fps — a video
loop doesn't need to update every render frame to read as "static"),
NEAREST-filtered (not the engine default LINEAR) so it stays crisp
blocky noise instead of blurring into grey mush. **One texture per
wiped slot, not shared** — several lost commanders in the same battle
don't flicker in lockstep, matching how real uncorrelated static
actually looks.
- **Cleanup matches this file's existing scene.time/input/events
discipline** (trap 25's lesson, still being applied): the refresh timers
are a `scene.time` resource independent of any game object's own
lifecycle, so `teardown()` explicitly removes every one, plus every
wiped slot's canvas texture — `MasterOfVegaGame.js` replays this view
once per battle, potentially many times in one scene session
(`playPlayerBattles`'s loop), so a leaked timer or texture per wiped
ship type would accumulate across every battle fought that session.
- Never browser-tested, per [[feedback_no_auto_verify]] — Brian to verify
in-browser, including how the static loop actually reads visually
(contrast/size/refresh-rate are first-pass judgment calls, easy to retune
once seen).
**Building icon moved below the text, still 2026-08-09**
(`VegaTurnReportScreen.js`): a completed building's picture used to sit
right-aligned BESIDE its synopsis text (reserving side wrap width so long
descriptions wouldn't run under it) — Brian asked for it below the text
instead, on the left, matching how a completed ship's hull icon + commander
portrait already sit. Now both `buildingDone` and `shipDone` rows share the
same "full-width text, then a left-aligned media block below it" shape;
the now-always-0 side-reserved wrap width (`mediaW`/`BUILDING_MEDIA_W`) was
removed rather than left dead. Never browser-tested.
**A direct War/Cease Hostilities toggle for diplomacy-incapable species,
still 2026-08-09** (Brian, after establishing the player had NO path to
war with Lithox at all): `canNegotiate()` returns false whenever either
side's `traits.diplomacy <= -100` — Lithox's flag — which meant "Seek
Audience" sat permanently disabled for that row with no other diplomacy
entry point anywhere in the game. Worse, the only way war could ever start
was Lithox itself auto-declaring during its own AI turn
(`runDiplomacyTurn`'s "species that cannot negotiate still go to war, more
readily" branch), and that only fires when LITHOX's own power exceeds the
target's by 1.25× — never the reverse — so a player stronger than Lithox
could be locked out of ever fighting them, permanently.
- **Scoped to diplomacy-incapable rows only**, confirmed with Brian before
building it: capable empires keep the existing Audience-based Declare
War / negotiated Sue-for-Peace flow completely unchanged. Making this
universal would have let the player unilaterally force peace on ANY
empire, bypassing `wouldAccept`'s negotiation — a real balance change,
not the gap being closed here.
- `VegaScreens.js`'s `openDiplomacyScreen` now branches per row on the same
`traits.diplomacy <= -100` check `canNegotiate` already makes: incapable
rows get a `Declare War` / `Cease Hostilities` toggle (label driven by
current `atWar()`) in place of the disabled Seek Audience button.
- **Reuses `declareWar()`/`makePeace()` directly** — the exact same
functions the Audience screen's own Declare War button and an accepted
Sue for Peace offer already call — rather than inventing a parallel
relationship state. This only changes WHO can trigger them and how
(instantly, unilaterally, no negotiation UI at all), never what they
actually do to `treaties`/attitude/events.
- On click: tears down and reopens `openDiplomacyScreen` itself (same
`onClose`/`onChanged`) rather than calling `onClose` — the player stays
on the diplomacy list and sees the updated stance immediately, instead
of the whole modal closing the way Seek Audience does.
- Verified the underlying state transitions headlessly (can't run
`VegaScreens.js` itself, it's Phaser): exactly one default species
(`lithox`) trips the incapable gate; `declareWar`/`makePeace` correctly
flip `atWar()` both directions for a human/Lithox pair even though
`canNegotiate` stays false throughout, confirming the toggle works
precisely where the normal negotiation path cannot. Full verifier
unaffected (2622 passed, 0 failed) — this UI file isn't Node-importable,
so it was never going to move that number.
- Never browser-tested, per [[feedback_no_auto_verify]].
## Balance reference (27-game AI soak)
Re-measured 2026-08-09 after V2 became the official combat engine (see
traps 40-41 and the narrative entry above) — the figures below reflect V2,
not the original V1-era numbers this section used to hold:
```
outcomes: { conquest: 14, council: 7, timeout: 6 }
turns: min 110, median 285, max 800
AI-decision time: 1.27 ms average worst-case (budget 50 ms — this measures
AI.runAITurn only, NOT resolveCombats; see traps 40-41 for combat's own
separately-measured cost, which is what those fixes were about)
```
Timeout share roughly doubled (3→6 of 27) and conquest dropped a bit
(17→14) relative to the old V1 reference — plausibly real (V2's combat
dynamics genuinely differ: continuous physics, momentum, formations, and
the disengage/duration tuning documented earlier in this file all shape
how decisively a fight resolves), but this is one 27-game sample, not
re-tuned against; worth re-checking if it drifts further in a future soak.
Mirror-match-bias and species-spread assertions (verifier sections 5/11)
both still passed at their existing tolerances — not re-measured as a
standalone figure here.
## Guided tutorial (2026-08-28)
An in-game guided tutorial that opens on every brand-new game (never a
resumed/loaded one) and is re-triggerable from the ☰ menu ("Replay tutorial",
greyed once the starting fleet has moved/split). Phase 1 ships the framework
plus four steps: a centred intro modal, a "select your fleet" callout, a
"these are your ships" callout over the side panel's ship rows, and a
"change ship counts" callout over the /+/✕ cluster.
- **The script is data.** `data/mastervega-tutorial.json` — an ordered `steps[]`
list, each with `kind` (`modal` | `callout`), `body`/`calloutText`, `voice`
(path under `assets/speech/`, no `.mp3`, or `null`), `highlights[]` /
`anchor` (string ids), `advanceOn` (`hotspot` = click the lit anchor;
`shipDetailClosed` = the player opened and closed a ship detail window),
and `buttons[]` (`{action, label}`, action ∈ next/back/skip/finish). A step
with `buttons: []` is legal only when `advanceOn` is set. `{token}`
placeholders are interpolated against a `vars` allow-list (only `species`
so far → `rules.species[emp.speciesId].plural`). Adding later steps =
editing this file; no code change unless a step needs a **new** highlight
target or advance mode.
- **Target ids** (`TUTORIAL_TARGET_IDS`): `homeStar` / `homeFleet` on the star
map (accent ring), `fleetShipProfiles` / `fleetCountControls` on the side
panel (yellow box). Panel regions resolve through a new
`VegaSidePanel.tutorialRegion(name)` — screen-space union of per-stack rects
recorded in `stackRow()` into `this._tutorRows` on every `rebuild()`, keyed
off `this.x0/this.y0` (the panel's resting position, so it is right even
while the panel is still sliding in).
- **Two modules, split like `VegaGnn` / `VegaGnnScreen`.**
`VegaTutorialData.js` is **Phaser-free** (schema validation, interpolation,
`TUTORIAL_TARGET_IDS`) so `tools/verifyMasterOfVega.js` imports it (section
12). `VegaTutorial.js` is the Phaser half (overlay, callout, hotspot,
state machine). A highlightable thing needs an id in `TUTORIAL_TARGET_IDS`
**and** a `_resolveTarget` case in `VegaTutorial.js`.
- **Darken = four opaque strips framing one rectangular hole** (the union of
the step's resolved highlight rects, padded), NOT a mask cutout — every
target worth highlighting is rectangular. A pulsing accent ring is stroked
around each target on top. Empty `highlights` → one full-screen dim rect.
- **The map is frozen by `scene.modalOpen = true`** for the tutorial's whole
life. That one flag blocks star-map pan/zoom (`blockPointer`/`blockWheel`)
and every map/HUD handler — **but not the side panel's own controls**
(`detailHit`, `tinyButton`, `tinyCircleButton`, `openShipDetail` — none
check `modalOpen`), which is what lets the fleet-ship steps work: the hole
over the panel exposes real, clickable panel widgets (`input.topOnly` is on,
so the dark strips must genuinely not cover them — the four-strip hole does
exactly that). `centerOn(emp.homeStar)` on start. `finish()` restores
`modalOpen = false` + `refreshAll()`, mirroring `openModal`'s `done()`.
- **`D.tutorial = 75`** — deliberately just *below* `D.detail` (76). The
"these are your ships" step tells the player to click a ship profile, which
opens the real `openShipDetail` window; sitting below `D.detail` lets it
layer cleanly on top of the overlay. The step then advances when
`panel.detailOpen` goes true-then-false (polled in `VegaTutorial.update()`).
- The "click here to select them" hotspot is a transparent interactive rect
over the fleet marker; its handler does the real low-level selection
(`scene.selectedFleet = f; map.setSelectedFleet(f); panel.showFleet(f)`,
bypassing `onFleetClick`'s `modalOpen` guard) then `advance()`.
- The skip button opens a small confirm prompt drawn on the tutorial's own
container (not `openModal`), so it never touches `modalOpen`. Skip is a
per-step button in the JSON, present only on the intro step — once the
player clicks Next it is gone. A step with an empty `buttons[]` is legal
**only** when it has `advanceOn: "hotspot"` (the callout step advances by
clicking the fleet, nothing else); `validateTutorialData` enforces that.
- No `VegaLogic` change — runs every new game, so there is no "seen" flag to
serialize. A malformed JSON file is validated in `create()` and disables the
feature with a `console.warn` rather than crashing.
## Files touched to register the game
`src/data/gamesRegistry.js`, `src/main.js`, `src/scenes/GameRoomScene.js`
(`slugDispatch`), `src/data/assetManifest.js`, `src/scenes/PreloadScene.js`
(eager-loads `mastervega-artwork.json`), `src/services/soundtrack.js`
(`hacker`).
## Verification
```bash
node tools/verifyMasterOfVega.js # ~1421 checks, ~60s
node tools/verifyMasterOfVega.js --quick # 1420 checks, ~15s
node tools/verifyMasterOfVega.js --games=50 # a deeper soak
```
Thirteen sections; section 2 runs the real procedural painters against a Proxy
fake canvas and cross-checks every declared artwork path against the filesystem
(portraits, stills, world backdrops, colony clips), section 4b is the fleet-order
engine behind the command panel, section 6 covers the colony economy plus the
slider padlocks and the queue reorder/repeat API the colony screen drives,
section 6b is the founding vignette and what the turn report will no longer
interrupt for, and section 10 is the self-play soak with invariants and a
turn-time budget.
Note for section 6: the padlock and queue blocks mutate `st.colonies[0]`, and the
soak below them measures every colony against its own ceiling — so they snapshot
that colony's sliders/locked/queue and put them back. A unit test that leaves a
colony reconfigured makes the soak measure something else.
Note for 4b and anything like it: `addFleet` **merges into an existing fleet at
the same star**, so a test that adds ships to a homeworld is really testing "the
starting fleet plus mine" and its counts mean nothing. Clear `st.fleets` first.
**Never browser-tested.** Everything above is engine- and Node-verified only.
## Art
All sheets are optional and start `path: null`; `VegaArt.js` paints stand-ins at
the identical frame geometry. See `src/games/mastervega/sprites.md` for the
frame maps. Frame indexes are append-only.
The video and full-screen blocks in `data/mastervega-artwork.json` follow the
same drop-in contract — declare it, drop the file, no code changes:
| block | shape | size | fallback |
| --- | --- | --- | --- |
| `portraitVideos` | species → clip | 256×256 | `portraitStills`, then the sheet |
| `portraitStills` | species → image | — | the painted sheet |
| `shipVideos` | species → hull → clip | 256×256 | that species' portrait |
| `worldBackgrounds` | planet type → image | 1920×1080 | gradient from `type.color` |
| `colonyVideos` | *colonisable* planet type → clip | 960×544 | `worldBackgrounds`, then gradient |
`colonyVideos` is the one block **not** in `src/data/assetManifest.js` — see the
vignette section above; it is fetched a clip at a time by `ensureColonyVideo()`.