1894 lines
122 KiB
Markdown
1894 lines
122 KiB
Markdown
# 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.25→0.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()`.
|