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

355 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Master of Vega — build plan and findings
**Read this before touching `src/games/mastervega/`.** It records the traps
found while building it; several were only discovered by instrumenting the soak
and would be very easy to reintroduce.
Master of Orion clone. Registered as `mastervega`, category `arcade-console-pc`
("Video Games"), iconFrame **92**. MOO1 rules with three MOO2 conveniences
(colony building queue, colony detail view, hireable leaders). Preset ship
hulls with an auto-refitting **Mark**, no ship designer. Tactical grid combat.
Empires are the ten **species** themselves — this game deliberately does *not*
use `data/opponents.json` or `ui/Portrait.js`.
## Architecture
Split enforced by import discipline, modelled on `totalannihilation/` rather
than `civilization/` (whose scene grew to 1792 lines).
**Headless — zero Phaser imports, importable by Node:**
| File | Role |
|---|---|
| `VegaRules.js` | `compileRules(json)` — validate + index the rules file |
| `VegaGalaxyGen.js` | Deterministic galaxy: shapes, stars, planets, Gabriel-graph starlanes, homeworlds |
| `VegaLogic.js` | The engine: colonies, sliders, buildings, research, fleets, combat dispatch, council, victory |
| `VegaShips.js` | Preset hulls + Mark auto-refit; the knapsack loadout |
| `VegaCombat.js` | Tactical battle **stepper** |
| `VegaAI.js` | AI empire controller |
| `VegaDiplomacy.js` | Treaties, attitudes, council politics |
| `VegaLeaders.js` | Leader pool, offers, postings |
| `VegaZoom.js` | Star-map zoom ladder (Phaser-free so it is headlessly checkable) |
**Render tier:** `MasterOfVegaGame.js` (scene), `VegaStarMap.js`,
`VegaSidePanel.js`, `VegaNebula.js`, `VegaSystemView.js`, `VegaColonyView.js`,
`VegaCombatView.js`, `VegaScreens.js`, `VegaArt.js`, `VegaFx.js`.
### The star map's command panel
`VegaSidePanel.js` is MOO1's right-hand column: docked, hidden until something
is selected, three modes — **star** (class, worlds, colonies with their
production read-out, forces in orbit, and a *View System* button), **fleet**
(one row per ship stack with a /+ count), **order** (distance, ETA, who is
waiting there, Accept / Cancel).
The panel owns no state. `MasterOfVegaGame` decides what is selected and calls
`showStar` / `showFleet` / `showOrder`; the panel only reads the engine and
reports clicks back through callbacks. Selecting a fleet arms it: the next star
click is a destination, not an inspection — except the fleet's *own* star, which
drops the selection and shows the system, because otherwise a fleet could never
be deselected by clicking where it already is.
**Splitting is the count selector, not a separate command.** `sendDetachment()`
sends the selection and leaves the rest behind as its own fleet. There is
deliberately no "split and stay" button: `consolidateFleets` merges idle fleets
in the same system at the start of the owner's next turn (trap 17), so a
detachment that does not leave immediately simply un-splits itself. A detachment
gets a fresh fleet id and therefore does **not** inherit the parent's fleet
leader.
Three things in `sendDetachment` are load-bearing and each is verified in
section 4b:
* The request is validated **against the actual stacks before totals are
compared** — otherwise asking for nine of a stack of three reads as "all of
them" and silently sends the whole fleet.
* Duplicate entries for one stack are **summed** before the availability check,
or 2 + 2 of a stack of 3 passes twice.
* A refusal is **total**: the reachability probe runs on a copy, so a rejected
order never leaves the fleet carved in two with the pieces going nowhere.
The star map gained `blockPointer` (a drag or a wheel starting over the panel
must not pan or zoom the galaxy underneath it) and `onEmptyClick`, which uses
the `currentlyOver` list Phaser passes to the input plugin's `pointerup` — an
empty list is a genuine click on the void, which is how the panel is dismissed.
### Colony management is two screens, not one
Everything about a world used to be crammed into the right-hand column of the
system-view modal: header, summary, five live sliders, the build queue, *and*
the whole add-to-queue catalogue. It did not fit, and it knew it — the catalogue
loop ended in a bare `break` once it ran off the bottom of the panel, silently
dropping buildings the player was entitled to build.
It is now split by what the player is actually doing:
* **`VegaSystemView.js`** *inspects* a world. The right column shows the planet
at the full 192px the `planets` sheet is cut at, its type/size/richness/
gravity, and — for our own colonies — a **read-only** picture of the same
numbers: allocation as five tinted bars, the queue as four lines with
cumulative ETAs. Nothing there responds to a click except **View Colony**.
* **`VegaColonyView.js`** *runs* one. Full screen, on the world's own backdrop
art, with a semi-transparent panel carrying the live sliders (each with a
padlock) and a **Build Queue** button that flies out a 760px queue manager to
its left: reorder with ▲▼, remove with ✕, and a scrolling catalogue where every
buildable quotes its cost, its ETA at the current construction rate, and what
it does.
The colony screen opens *directly* from the system view rather than through
`MasterOfVegaGame.openModal`, which refuses a second modal while one is up — and
this one has to stay up, because it is what keeps the star map inert underneath.
`D.colony` (70) sits above `D.modal` (60) so the two stack; the system view hides
its layer and pauses its orrery tick while the colony screen is in front, and
rebuilds on the way back.
Repeat counts are a **display** concern only. `enqueueMany` pushes N separate
entries, so `processColony` and the save format know nothing about repeats;
`collapseQueue` folds a run of identical ships back into one "× N" row for
rendering. A part-built entry never joins a run, so the head item's progress bar
always means something.
Four functions back all of this and they all live in `VegaLogic.js`, not in the
views — `collapseQueue`, `moveQueueRun`, `colonyBuildRate` and `queueEtas` — for
two reasons. The two screens quote the same numbers and must agree, and the
index arithmetic in `moveQueueRun` fails *silently* when it is wrong (see below),
so it needs to be somewhere the Node verifier can reach.
> `moveQueueRun` is the sharp edge. Moving a "× 5" row is five splices, and the
> two directions do **not** use the same indices: going **up**, the copies still
> behind keep their slots so both ends advance together (`+ k`); going **down**,
> pulling the head copy out slides the rest of the run into the slot it vacated,
> so the *same* pair of indices moves every copy. Using `+ k` for both — the
> obvious first guess — interleaves the run with its neighbour
> (`s,s,s,f,f` → `s,f,s,s,f`) without throwing. Section 6 asserts the
> contiguous result in both directions.
State is plain JSON with the RNG cursor inside it (`state.rngState`,
explicit-step mulberry32), so replaying a seed reproduces the galaxy, the
battles and the winner exactly. `serialize()` strips `rules` and every
underscore-prefixed memo cache.
## Traps found during the build
Each of these was a real bug that produced a plausible-looking but broken game.
1. **An immobile hull froze the entire fleet.** `fleetSpeed()` returned 0 if
*any* ship in a fleet was immobile. A completed Star Base joins the fleet
over its own colony — which is the empire's main battle fleet — and from that
moment `canSendFleet` refused every order. Instrumented over one game:
**338 of 338** valid attacks (in range, strong enough, at war) were refused
for this reason. No colony could ever be attacked, so no war could be won.
Fix: `fleetSpeed` skips immobile hulls; `sendFleet` splits them into a
garrison that stays behind; `consolidateFleets` keys on mobility so the
garrison is never re-merged.
2. **Conquest was unreachable without bombardment.** Even at a 2500-turn cap,
*no empire was ever eliminated*: a cornered empire's beaten fleet always
retreats to its last fortified homeworld and denies the attacker the clean
orbit an invasion needs. `bombardPopKill` and the Stellar Converter's
`planetCracker` were declared in the rules but never implemented. Implementing
orbital bombardment (MOO1's actual answer) made conquest work immediately.
3. **The invasion window never opened.** Two versions of this failed the same
way. Requiring `defenseHp === 0` never worked because combat resolves on the
attacker's turn and the *defender's* turn rebuilds the batteries first.
Requiring an empty sky failed too — a besieged colony finishes a ship every
few turns and that one hull blocked the landing indefinitely. Measured: 1268
colony-turns under hostile orbit, **2** invasion attempts. Now the rule is
orbital *superiority*, and surviving defences fight as ground support.
4. **Transports never left home.** `manageFleets` had an early
`if (power <= 0) continue`, and transports carry no guns — so transport
fleets skipped every movement branch. 3495 idle observations against 12 in
transit. They now escort the main battle fleet.
5. **The AI threw transports away.** Without a forecast it launched every
landing regardless of odds: 1971 failures against 130 successes.
`invasionForecast()` now gates it — success rate went to ~91%.
6. **Nobody ever met anybody.** Contact required flying a ship into an occupied
system, so most galaxies had no diplomacy and no wars at all. Empires now
also make contact by proximity (`economy.contactRange`).
7. **The Council could never elect anyone.** Every empire stood as a candidate
and therefore voted for itself — 30 sessions in an 800-turn game, all null.
MOO1's rule: the **two largest** empires stand and everyone else votes
between them.
8. **…and then it elected someone every time.** A dominant empire won its own
council vote a hundred turns before it could finish a war, so conquest never
completed. MOO1's **refusal to submit** (the defeated candidate walks out and
the election is void) restored the balance.
9. **Combat had a decisive first-strike bias.** Firing was sequential, so
whichever side acted first in the round contact was made won. Mirror matches
swung from 100% to 0% attacker depending on tier, because the parity of the
contact round changes with fleet speed. Damage is now **banked and applied
together** at the end of the round.
10. **…and then ties were resolved in a fixed direction.** Symmetric fleets
reach the disengage round *exactly* tied astonishingly often; giving the
attacker every drawn battle produced a 44pp bias. The coin is now flipped.
11. **Every battle stalemated on the round cap.** Two causes: in-combat repair
(6% of hull/round) outpaced damage, and the attrition tail of an even fight
is enormously long. Repair was cut to 3%/6%, and a **disengage rule** now
makes the weaker side withdraw at round 25 rather than grinding to the cap.
12. **An all-missile ship ran dry and stalemated.** At some tiers missiles score
better damage-per-space than beams, so a pure knapsack built ships that
emptied their racks in five rounds and then sat unarmed. `MISSILE_SHARE`
caps missiles at 40% of tonnage. This is a design rule, not an optimisation.
13. **Researching a weapon could make ships worse.** A greedy "best
damage-per-space" fill mounts one oversized gun and strands the leftover
tonnage — the cruiser's beam damage *dropped* from 42 to 28 on learning
Death Ray. The loadout is now an unbounded **knapsack**, which is provably
monotonic in tech because researching only ever adds candidates. The one
sanctioned exception is the tier where missiles first appear (see the
verifier's section 4).
14. **Waste ran away and killed every colony.** Population fell 50 → 20 with 868
accumulated waste. Cleanup is now **mandatory** and taken off the top,
pro-rata from the other channels — MOO1's eco slider snapping to the
minimum. Related: factories beyond what the population can staff are
**mothballed** (`effectiveFactories`), or a shrinking colony keeps polluting
from factories nobody is left to run and can never recover.
15. **Espionage leaked the whole tech tree.** Crediting raw espionage score made
every empire a spy agency; all five reached 60/60 techs, making the
per-species availability roll meaningless. Only the surplus over a baseline
now counts.
16. **The Council never convened.** `colonizedFraction` measured against *every*
star, but a third of systems hold only gas giants and belts, so "half the
galaxy colonised" was unreachable. It now measures against settleable stars.
17. **Fleets proliferated to 281.** Ships completing while the local fleet is in
transit each spawn a new fleet. `consolidateFleets` merges idle fleets each
turn.
18. **Dead empires left ghost fleets** on the map forever. Caught by the soak's
invariant checker, not by playing.
19. **Worldgen fairness could not always be satisfied.** On large sparse
galaxies a homeworld can have *no* star inside its opening fuel range, so
there was nothing to upgrade and that empire simply could not expand.
`guaranteeNearbyWorlds` now seeds new worlds when it must.
20. **Zoom could pull back past the edge of the map.** The zoom ladder was a
fixed `[0.35 … 2.0]`, which only ever suited the *huge* galaxy — on a small
one the bottom two steps showed the map floating in empty space, and
`clampPan` allowed another 160px of slack on every side on top of that. The
ladder is now built per galaxy in `VegaZoom.js`, anchored so its bottom rung
is exactly the zoom at which the galaxy covers the viewport, and the pan
clamp has no slack. Kept Phaser-free so the verifier can assert the
invariant directly (section 3b).
21. **`slider()` only works inside a container at the origin.** Its drag maths
is `apply(p.x - c.x)` — a *screen* coordinate minus a *local* one. That is
only correct while the parent container sits at (0, 0), which every
`modalShell` layer does. Nest one inside a panel container at x=1416, or
anything that tweens, and the knob jumps by the parent's offset and drifts
during the animation. It is why `VegaColonyView`'s `panelLayer` is at the
origin with every child in absolute coordinates, and why the flyout — the
one layer that moves — deliberately contains no sliders.
22. **A mask is a rendering concern; Phaser still hit-tests through it.** The
colony screen's catalogue is the game's first scrolling list, and a row
scrolled out of sight remains perfectly clickable unless something says
otherwise. Every row handler asks `scroller.contains(pointer)` first. Two
related traps in the same helper: the wheel catcher must be added *below*
the content or it swallows every row click, and a `GeometryMask`'s Graphics
has its own world transform and does **not** follow a moving parent — the
flyout tween drags it along by hand in `onUpdate`.
### 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.
## 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 # ~1221 checks, ~60s
node tools/verifyMasterOfVega.js --quick # 1220 checks, ~15s
node tools/verifyMasterOfVega.js --games=50 # a deeper soak
```
Twelve 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), 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, 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.