492 lines
28 KiB
Markdown
492 lines
28 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.
|
||
|
||
### 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()`.
|