1018 lines
64 KiB
Markdown
1018 lines
64 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.
|
||
|
||
## 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]].
|
||
|
||
### 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.
|
||
|
||
## Balance reference (27-game AI soak)
|
||
|
||
```
|
||
outcomes: { conquest: 17, council: 7, timeout: 3 }
|
||
turns: min 110, median 282, max 800
|
||
AI turn time: 0.45 ms average (budget 50 ms)
|
||
mirror-match bias: < 5.2pp at every tier, zero stalemates
|
||
wins spread across 8 of 10 species
|
||
```
|
||
|
||
## 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()`.
|