feat(mastervega): split colony management into full-screen colony view

Move colony management (sliders, build queue, catalogue) from the
system-view modal into a dedicated full-screen colony view
(VegaColonyView.js). The system view now inspects worlds read-only
with a "View Colony" button to open the management screen.

Key changes:
- Add VegaColonyView.js with colony screen, build queue flyout,
  scrolling catalogue, allocation sliders with padlocks, and
  planet-type backdrops
- Add world backdrop artwork support (1920×1080, one per planet type)
  declared in mastervega-artwork.json worldBackgrounds
- Add headless queue helpers to VegaLogic.js: collapseQueue,
  moveQueueRun, colonyBuildRate, queueEtas, enqueueMany
- Fix star map pointer blocking (drags no longer pan map through modal)
- Move ORBIT constant to VegaScreens.js, add D.colony depth layer
- Update docs (build-plan, sprites) and verification suite

The colony screen stacks over the system view without requiring a
second modal, hiding the system layer and pausing the orrery while
open.
This commit is contained in:
Brian Fertig 2026-08-02 11:49:33 -06:00
parent c42851f09c
commit 508feea4c9
27 changed files with 1225 additions and 104 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -30,6 +30,25 @@
"lithox": { "key": "vega-still-lithox", "path": "assets/images/vega/char-lithox.png" }
},
"_worldBackgroundsReadme": "Full-screen 1920x1080 OPAQUE backdrops for the colony screen, one per planetType id. These are NOT the 'planets' sheet — that one holds transparent 192x192 discs for the orrery. A null path falls back to a gradient painted from planetTypes[].color, so the roster can be finished one world at a time. The '-01' suffix is a variant slot. NOTE: the gas giant's file is spelled 'world-gas-giant-01.png' with a hyphen while the rules id is 'gasgiant' — the path below points at the real filename. 'jungle' is the one colonisable type still outstanding.",
"worldBackgrounds": {
"terran": { "key": "vega-world-terran", "path": "assets/images/vega/world-terran-01.png" },
"ocean": { "key": "vega-world-ocean", "path": "assets/images/vega/world-ocean-01.png" },
"jungle": { "key": "vega-world-jungle", "path": null },
"steppe": { "key": "vega-world-steppe", "path": "assets/images/vega/world-steppe-01.png" },
"arid": { "key": "vega-world-arid", "path": "assets/images/vega/world-arid-01.png" },
"desert": { "key": "vega-world-desert", "path": "assets/images/vega/world-desert-01.png" },
"tundra": { "key": "vega-world-tundra", "path": "assets/images/vega/world-tundra-01.png" },
"minimal": { "key": "vega-world-minimal", "path": "assets/images/vega/world-minimal-01.png" },
"barren": { "key": "vega-world-barren", "path": "assets/images/vega/world-barren-01.png" },
"dead": { "key": "vega-world-dead", "path": "assets/images/vega/world-dead-01.png" },
"inferno": { "key": "vega-world-inferno", "path": "assets/images/vega/world-inferno-01.png" },
"toxic": { "key": "vega-world-toxic", "path": "assets/images/vega/world-toxic-01.png" },
"radiated": { "key": "vega-world-radiated", "path": "assets/images/vega/world-radiated-01.png" },
"gasgiant": { "key": "vega-world-gasgiant", "path": "assets/images/vega/world-gas-giant-01.png" },
"asteroids": { "key": "vega-world-asteroids", "path": null }
},
"sheets": {
"ships": {
"key": "vega-ships",
@ -43,7 +62,7 @@
},
"planets": {
"key": "vega-planets",
"path": null,
"path": "assets/images/vega/planets.png",
"kind": "planet",
"frameWidth": 192,
"frameHeight": 192,

View File

@ -31,8 +31,8 @@ than `civilization/` (whose scene grew to 1792 lines).
| `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`, `VegaCombatView.js`,
`VegaScreens.js`, `VegaArt.js`, `VegaFx.js`.
`VegaSidePanel.js`, `VegaNebula.js`, `VegaSystemView.js`, `VegaColonyView.js`,
`VegaCombatView.js`, `VegaScreens.js`, `VegaArt.js`, `VegaFx.js`.
### The star map's command panel
@ -73,6 +73,56 @@ 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
@ -195,6 +245,24 @@ Each of these was a real bug that produced a plausible-looking but broken game.
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`.
## Balance reference (27-game AI soak)
```
@ -215,14 +283,22 @@ wins spread across 8 of 10 species
## Verification
```bash
node tools/verifyMasterOfVega.js # ~1004 checks, ~60s
node tools/verifyMasterOfVega.js --quick # 1003 checks, ~15s
node tools/verifyMasterOfVega.js # ~1082 checks, ~60s
node tools/verifyMasterOfVega.js --quick # 1081 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, section 4b is the fleet-order engine behind the command panel, section 10
is the self-play soak with invariants and a turn-time budget.
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

View File

@ -99,6 +99,7 @@ export const MANIFEST = {
(scene) => sheetsFrom(scene, 'mastervega-artwork', ['sheets']),
(scene) => videosFrom(scene, 'mastervega-artwork', 'portraitVideos'),
(scene) => imagesFromMap(scene, 'mastervega-artwork', 'portraitStills'),
(scene) => imagesFromMap(scene, 'mastervega-artwork', 'worldBackgrounds'),
image('vega-menu-bg', 'assets/images/vega/background-menu.png'),
image('vega-menu-title', 'assets/images/vega/menu-title.png'),
{ type: 'audio', key: 'laser-zap', path: 'assets/fx/laser-zap.mp3' },

View File

@ -469,7 +469,12 @@ export default class MasterOfVegaGame extends Phaser.Scene {
onFleetClick: (fleet) => this.onFleetClick(fleet),
onEmptyClick: () => this.clearSelection(),
blockWheel: () => this.modalOpen,
blockPointer: (p) => !this.modalOpen && !!this.panel?.containsPoint(p.x, p.y),
// A modal blocks the map outright; otherwise only the side panel's own
// footprint does (so dragging a slider there doesn't pan the galaxy
// underneath it). The old `!this.modalOpen && ...` form always
// short-circuited to false while a modal was open, which let drags
// pan the star map right through the System View window.
blockPointer: (p) => this.modalOpen || !!this.panel?.containsPoint(p.x, p.y),
});
// The command panel is the only place a selection is acted on; the scene

View File

@ -626,6 +626,26 @@ export function sizeSpeciesPortrait(obj, size) {
return obj;
}
// --------------------------------------------------------------------------
// World backdrops
//
// Full-screen 1920x1080 opaque art for the colony screen, one per planet type,
// declared in the artwork manifest's `worldBackgrounds` block. Deliberately NOT
// part of the `planets` sheet: that one holds transparent 192px discs for the
// orrery, which is a different picture of the same world.
//
// Same drop-in contract as the species portraits — a type with no art returns
// null and the caller paints a gradient from the type's own colour, so the set
// can be finished one world at a time with no code change.
export const worldBgKey = (typeId) => `vega-world-${typeId}`;
/** Loaded backdrop texture key for a planet type, or null if there is no art. */
export function worldBackground(scene, typeId) {
const key = worldBgKey(typeId);
return scene.textures.exists(key) ? key : null;
}
// Frame helpers — the one place that knows how the sheets are indexed.
export const shipFrame = (rules, speciesId, hullId) => {
const s = rules.species[speciesId];

View File

@ -0,0 +1,611 @@
// Master of Vega — the full-screen colony screen.
//
// The system view is where you look at a world; this is where you run it. It
// takes the whole screen because the two things that need room — five live
// allocation sliders and the build queue with everything you could add to it —
// were both being squeezed into one 780px column of the system-view modal,
// which visibly truncated its own catalogue rather than admitting it did not
// fit.
//
// Layout, left to right:
//
// the world itself — the 1920x1080 backdrop for this planet type, or a
// gradient painted from the type's colour if that art has
// not been made yet
// the flyout — the build-queue manager, hidden behind the panel until
// the Build Queue button pulls it out
// the panel — semi-transparent so the world reads through it:
// summary, the five sliders with their padlocks, and a
// read-out of what is being built
//
// TWO PHASER TRAPS THIS FILE SITS ON, both of which have bitten this game
// before and are the reason for the container structure below:
//
// 1. `slider()` (VegaScreens.js) does its own pointer maths as `p.x - c.x`,
// mixing a screen coordinate with a local one. That is only correct while
// the slider's parent container sits at the origin. So `panelLayer` is at
// (0, 0) with every child in ABSOLUTE coordinates, and the sliders are never
// nested inside anything that moves. The flyout DOES move, which is exactly
// why it contains no sliders.
// 2. A Container's origin is locked at 0.5, so its hit area is always centred.
// Everything interactive here is a Rectangle, a Text or a Button.
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { FONT, D, ORBIT, slider } from './VegaScreens.js';
import { worldBackground, buildingFrame, shipFrame } from './VegaArt.js';
import {
CHANNELS, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany,
dequeue, collapseQueue, moveQueueRun, queueItemCost, queueEtas, empireDesign,
} from './VegaLogic.js';
const ACCENT = 0x6fc4ff;
const PANEL = 0x0b1220;
const PANEL_W = 480;
const PANEL_X = GAME_WIDTH - PANEL_W - 24;
const PANEL_Y = 24;
const PANEL_H = GAME_HEIGHT - PANEL_Y * 2;
const PAD = 22;
const FLY_W = 760;
const FLY_X = PANEL_X - FLY_W - 16;
/** Matches the read-only allocation bars in the system view. */
export const CHANNEL_COLOUR = {
ships: 0x6fc4ff, defense: 0xe08a8a, industry: 0xffd88a, ecology: 0x7fd8a0, research: 0xb89cff,
};
export const etaText = (turns) => (Number.isFinite(turns)
? `${turns} turn${turns === 1 ? '' : 's'}`
: 'stalled — no construction budget');
// ---------------------------------------------------------------------------
/**
* A vertically scrolling column. This is the first one in the game: every other
* list either fits or drops its tail on the floor (`VegaSystemView` used to
* `break` out of the catalogue mid-loop). Kept local until a second caller
* wants it rather than promoted to src/ui/ on spec.
*
* The mask is a Graphics with its own world transform, so it does NOT follow a
* moving parent `syncMask` is what keeps it aligned while the flyout slides.
*/
function scrollColumn(scene, parent, x, y, w, h) {
// The catcher goes in FIRST so it sits below the rows: it exists only to tell
// the wheel handler whether the pointer is over this column, and an
// interactive rectangle added afterwards would swallow every row click.
const catcher = scene.add.rectangle(x, y, w, h, 0xffffff, 0.001)
.setOrigin(0, 0).setInteractive();
parent.add(catcher);
const content = scene.add.container(x, y);
parent.add(content);
const shape = scene.make.graphics({ x: 0, y: 0 }, false);
const paint = (dx) => {
shape.clear();
shape.fillStyle(0xffffff);
shape.fillRect(x + dx, y, w, h);
};
paint(0);
content.setMask(shape.createGeometryMask());
const api = {
content,
contentHeight: 0,
offset: 0,
/** Masks are not children, so a moving parent has to drag this along. */
syncMask(dx) { paint(dx); },
/** Phaser hit-tests through a mask, so every row handler must ask this. */
contains(pointer) { return catcher.getBounds().contains(pointer.x, pointer.y); },
scrollBy(dy) {
const max = Math.max(0, api.contentHeight - h);
api.offset = Phaser.Math.Clamp(api.offset + dy, -max, 0);
content.y = y + api.offset;
},
setOffset(v) { api.offset = 0; api.scrollBy(v); },
destroy() { shape.destroy(); },
};
const onWheel = (pointer, over, dx, dy) => {
if (!api.contains(pointer)) return;
api.scrollBy(-dy * 0.6);
};
scene.input.on('wheel', onWheel);
const origDestroy = api.destroy;
api.destroy = () => { scene.input.off('wheel', onWheel); origDestroy(); };
return api;
}
// ---------------------------------------------------------------------------
/** The holographic frame both the panel and the flyout are drawn in. */
function frame(scene, layer, x, y, w, h, alpha) {
const box = scene.add.rectangle(x, y, w, h, PANEL, alpha).setOrigin(0, 0);
box.setStrokeStyle(1.5, ACCENT, 0.55);
layer.add(box);
const ticks = scene.add.graphics();
ticks.lineStyle(2.5, ACCENT, 0.9);
const t = 24;
for (const [cx, cy, dx, dy] of [
[x, y, 1, 1], [x + w, y, -1, 1], [x, y + h, 1, -1], [x + w, y + h, -1, -1],
]) {
ticks.lineBetween(cx, cy, cx + dx * t, cy);
ticks.lineBetween(cx, cy, cx, cy + dy * t);
}
layer.add(ticks);
}
// ---------------------------------------------------------------------------
/**
* @param opts.onChanged fired after every mutation so the map/panel/HUD refresh
* @param opts.onClose fired once, when the screen is dismissed
*/
export function openColonyView(scene, rules, state, colony, art, opts = {}) {
const { onChanged = null, onClose = null } = opts;
const star = state.galaxy.stars[colony.starIdx];
const planet = star.planets[colony.orbit];
const type = rules.planetTypes[planet.typeId];
const emp = state.empires[colony.empireIdx];
const root = scene.add.container(0, 0).setDepth(D.colony);
// --- the world itself
const bgKey = worldBackground(scene, planet.typeId);
if (bgKey) {
root.add(scene.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, bgKey)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT));
} else {
// No backdrop art for this type yet. Paint one from the type's own colour
// rather than dropping the player onto a black screen — same drop-in
// contract as the procedural spritesheets.
const top = Phaser.Display.Color.HexStringToColor(type.color).darken(72).color;
const bottom = Phaser.Display.Color.HexStringToColor(type.color).darken(28).color;
const g = scene.add.graphics();
g.fillGradientStyle(top, top, bottom, bottom, 1);
g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
root.add(g);
}
// Interactive so nothing falls through to the system view underneath.
root.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0.35)
.setOrigin(0, 0).setInteractive());
// --- masthead, over the art
root.add(scene.add.text(48, 40, `${star.name} ${ORBIT[colony.orbit] ?? colony.orbit + 1}`, {
fontFamily: FONT, fontSize: '56px', color: '#e8f4ff',
}).setShadow(0, 3, '#000814', 10, false, true));
root.add(scene.add.text(48, 108,
`${emp.name}${colony.capital ? ' — capital world' : ''}`, {
fontFamily: FONT, fontSize: '24px', color: emp.color,
}).setShadow(0, 2, '#000814', 8, false, true));
root.add(scene.add.text(48, 144,
`${type.name} · ${rules.planetSizes[planet.sizeId]?.name ?? ''} · `
+ `${rules.richness[planet.richId]?.name ?? ''} · ${rules.gravity[planet.gravId]?.name ?? ''}`
+ ` · Year ${2300 + state.turn}`, {
fontFamily: FONT, fontSize: '19px', color: '#c8dcf0',
}).setShadow(0, 2, '#000814', 8, false, true));
root.add(scene.add.text(48, 176, type.desc, {
fontFamily: FONT, fontSize: '16px', color: '#9fb6cc', wordWrap: { width: 560 },
}).setShadow(0, 2, '#000814', 8, false, true));
// --- layers
//
// panelLayer sits at the origin FOREVER (see the header note on slider()).
// flyLayer is the one that moves, and it holds no sliders.
const flyLayer = scene.add.container(0, 0).setVisible(false);
root.add(flyLayer);
const panelLayer = scene.add.container(0, 0);
root.add(panelLayer);
const FLY_HIDDEN = PANEL_X - FLY_X; // parked exactly behind the panel
flyLayer.x = FLY_HIDDEN;
let flyOpen = false;
// Both of the flyout's lists can grow without limit — the queue as well as
// the catalogue — so both scroll and both have a mask to keep in step.
let scrollers = [];
const syncMasks = (dx) => scrollers.forEach((s) => s.syncMask(dx));
const dropScrollers = () => { scrollers.forEach((s) => s.destroy()); scrollers = []; };
// ------------------------------------------------------------------ close
let closed = false;
function close() {
if (closed) return;
closed = true;
dropScrollers();
scene.input.keyboard?.off('keydown-ESC', onEsc);
root.destroy();
onClose?.();
}
function onEsc() {
if (flyOpen) { toggleFlyout(); return; }
close();
}
scene.input.keyboard?.on('keydown-ESC', onEsc);
// ------------------------------------------------------------- the panel
function buildPanel() {
panelLayer.removeAll(true);
frame(scene, panelLayer, PANEL_X, PANEL_Y, PANEL_W, PANEL_H, 0.82);
const x = PANEL_X + PAD;
const w = PANEL_W - PAD * 2;
let y = PANEL_Y + 24;
const add = (obj) => { panelLayer.add(obj); return obj; };
const text = (str, size, color, gap = 6, wrap = w) => {
const t = add(scene.add.text(x, y, str, {
fontFamily: FONT, fontSize: `${size}px`, color, lineSpacing: 4,
wordWrap: { width: wrap },
}));
y += t.height + gap;
return t;
};
const heading = (str) => {
y += 12;
text(str.toUpperCase(), 14, '#6f8aa3', 4);
add(scene.add.rectangle(x, y, w, 1, ACCENT, 0.22).setOrigin(0, 0));
y += 10;
};
add(scene.add.text(x, y, 'COLONY', {
fontFamily: FONT, fontSize: '26px', color: '#cfe8ff',
}));
add(new Button(scene, PANEL_X + PANEL_W - 38, y + 16, '✕', close,
{ width: 40, height: 36, fontSize: 18, variant: 'ghost' }));
y += 48;
// --- summary
const maxPop = colonyMaxPop(rules, state, colony);
const capF = colonyFactoryCap(rules, state, colony);
const effF = effectiveFactories(rules, state, colony);
const prod = colonyProduction(rules, state, colony);
const rate = colonyBuildRate(rules, state, colony);
heading('Status');
text(`Population ${colony.pop.toFixed(1)} / ${maxPop}`, 17, '#c8dcf0', 2);
text(`Factories ${Math.floor(effF)} / ${capF}`, 17, '#c8dcf0', 2);
text(`Output ${prod.toFixed(1)} BC per turn`, 17, '#c8dcf0', 2);
text(`Construction ${rate.toFixed(1)} BC per turn`, 17, '#9fd8ff', 2);
text(`Trade ${colonyTrade(rules, state, colony).toFixed(1)} BC`, 17, '#c8dcf0', 2);
text(`Defences ${Math.round(colony.defenseHp)} / ${colonyDefenseCap(rules, state, colony)}`,
17, '#c8dcf0', 2);
// Factories nobody is left to staff are mothballed rather than demolished —
// worth saying out loud, because the number simply reads as wrong otherwise.
if (colony.factories > effF + 0.5) {
text(`${Math.floor(colony.factories - effF)} factories mothballed — too few people to staff them`,
14, '#e0b08a', 2);
}
if (colony.waste > 0.5) {
text(`Uncleaned waste ${colony.waste.toFixed(1)} — this is capping your population`,
14, '#e08a8a', 2);
}
// --- allocation
//
// A padlock holds its channel while the others renormalise around it —
// setSlider has honoured colony.locked since the engine was written, and
// this is the first UI to ever set it.
heading('Allocation');
const sliders = [];
const lockedCount = CHANNELS.filter((c) => colony.locked[c]).length;
CHANNELS.forEach((ch, i) => {
const locked = !!colony.locked[ch];
// Locking the last free channel would leave setSlider with nothing to
// renormalise (its `others.length === 0` early return), freezing the
// allocation with no way back out. So the last one open cannot be shut.
const lockable = locked || lockedCount < CHANNELS.length - 1;
const box = add(scene.add.rectangle(x + 11, y + 28, 22, 22,
locked ? 0x2a3550 : 0x16253c).setStrokeStyle(1, ACCENT, lockable ? 0.55 : 0.18));
add(scene.add.text(x + 11, y + 27, locked ? '■' : '□', {
fontFamily: FONT, fontSize: '15px', color: locked ? '#ffd88a' : (lockable ? '#8fa8c0' : '#3c4c60'),
}).setOrigin(0.5));
if (lockable) {
box.setInteractive({ useHandCursor: true });
box.on('pointerup', () => {
if (locked) delete colony.locked[ch];
else colony.locked[ch] = true;
buildPanel();
});
}
const s = slider(scene, x + 34, y, w - 34,
rules.economy.channelNames[ch] ?? ch, colony.sliders[ch] ?? 0, (v) => {
setSlider(rules, state, colony, ch, v);
// setSlider renormalises everything else, so they all have to be
// redrawn — including this one, which may have been clipped by the
// room the locked channels left.
sliders.forEach((other, j) => other.setValue(colony.sliders[CHANNELS[j]] ?? 0));
onChanged?.();
}, CHANNEL_COLOUR[ch]);
panelLayer.add(s.container);
sliders.push(s);
y += 56;
});
y += 4;
text('Ecology is funded first — a shortfall is taken from the other channels automatically. '
+ 'A locked channel holds its share while the rest renormalise.',
13, '#6f8aa3', 4);
// The button is PINNED to the bottom of the column, and everything below
// here is length-variable — a long queue, a colony with a dozen buildings.
// So the two lists below give way rather than spilling past the frame, the
// same way VegaSidePanel's `room()` works. The status block and the sliders
// are never allowed to be the thing that gets dropped.
const buttonY = PANEL_Y + PANEL_H - 76;
const room = (px) => y + px < buttonY - 12;
// --- what is being built
heading('Building');
const rows = collapseQueue(colony);
const etas = queueEtas(rules, state, colony);
if (!rows.length) {
text('Idle — construction spills into research.', 15, '#6f8aa3', 4);
} else {
const head = rows[0];
const cost = queueItemCost(rules, state, colony, head.item);
text(`${itemName(rules, state, colony, head.item)}${head.count > 1 ? ` x ${head.count}` : ''}`,
19, '#ffd88a', 6);
add(scene.add.rectangle(x, y, w, 8, 0x1b2b42).setOrigin(0, 0));
add(scene.add.rectangle(x, y, w * Phaser.Math.Clamp((head.item.progress ?? 0) / cost, 0, 1),
8, 0xffd88a).setOrigin(0, 0));
y += 16;
text(`${Math.round(head.item.progress ?? 0)} / ${Math.round(cost)} BC · ${etaText(etas[head.lastIndex])}`,
14, '#9fb6cc', 6);
let shown = 1;
for (const r of rows.slice(1)) {
if (!room(22)) break;
text(`${itemName(rules, state, colony, r.item)}${r.count > 1 ? ` x ${r.count}` : ''}`
+ ` · ${etaText(etas[r.lastIndex])}`, 14, '#8fa8c0', 2);
shown += 1;
}
if (shown < rows.length && room(22)) {
text(`…and ${rows.length - shown} more — open the build queue`, 13, '#6f8aa3', 2);
}
}
// Lowest priority: it is reference, and the flyout's catalogue already
// hides anything already built here.
if (colony.buildings.length && room(60)) {
heading('Built here');
text(colony.buildings.map((b) => rules.buildings[b].name).join(' · '),
13, '#ffd88a', 2, w);
}
y = buttonY;
add(new Button(scene, PANEL_X + PANEL_W / 2, y + 24,
flyOpen ? 'Close Build Queue' : 'Build Queue', toggleFlyout,
{ width: w, height: 48, fontSize: 20 }));
}
// ------------------------------------------------------------- the flyout
function toggleFlyout() {
flyOpen = !flyOpen;
scene.tweens.killTweensOf(flyLayer);
if (flyOpen) {
flyLayer.setVisible(true);
buildFlyout();
scene.tweens.add({
targets: flyLayer,
x: 0,
duration: 220,
ease: 'Cubic.easeOut',
// A scroll mask is a Graphics with its own world transform and does
// not follow a moving parent, so it has to be dragged along by hand.
onUpdate: () => syncMasks(flyLayer.x),
onComplete: () => syncMasks(0),
});
} else {
scene.tweens.add({
targets: flyLayer,
x: FLY_HIDDEN,
duration: 180,
ease: 'Sine.easeIn',
onUpdate: () => syncMasks(flyLayer.x),
onComplete: () => {
flyLayer.setVisible(false);
dropScrollers();
flyLayer.removeAll(true);
},
});
}
buildPanel();
}
function rebuildFlyout() {
buildFlyout();
buildPanel();
onChanged?.();
}
function buildFlyout() {
// Queueing something rebuilds the whole flyout, so both lists would jump
// back to the top under the player's cursor. Put them back where they were.
const keep = scrollers.map((s) => s.offset);
dropScrollers();
flyLayer.removeAll(true);
frame(scene, flyLayer, FLY_X, PANEL_Y, FLY_W, PANEL_H, 0.9);
const x = FLY_X + PAD;
const w = FLY_W - PAD * 2;
flyLayer.add(scene.add.text(x, PANEL_Y + 24, 'BUILD QUEUE', {
fontFamily: FONT, fontSize: '26px', color: '#cfe8ff',
}));
const rate = colonyBuildRate(rules, state, colony);
flyLayer.add(scene.add.text(x, PANEL_Y + 60,
`${rate.toFixed(1)} BC per turn reaching construction`, {
fontFamily: FONT, fontSize: '15px', color: '#9fd8ff',
}));
// ---- in the queue
let y = PANEL_Y + 100;
flyLayer.add(scene.add.text(x, y, 'IN THE QUEUE', {
fontFamily: FONT, fontSize: '14px', color: '#6f8aa3',
}));
y += 22;
flyLayer.add(scene.add.rectangle(x, y, w, 1, ACCENT, 0.22).setOrigin(0, 0));
y += 12;
const rows = collapseQueue(colony);
const etas = queueEtas(rules, state, colony);
if (!rows.length) {
flyLayer.add(scene.add.text(x, y, 'Nothing queued — construction spills into research.', {
fontFamily: FONT, fontSize: '15px', color: '#6f8aa3',
}));
}
// The queue gets a fixed band of its own. Nothing bounds how long a queue
// can get, and letting it push the catalogue off the bottom of the screen
// would be the same failure the old system-view column had.
const queueH = 340;
const qs = scrollColumn(scene, flyLayer, x, y, w, queueH);
scrollers.push(qs);
let qy = 0;
rows.forEach((r, ri) => {
const cost = queueItemCost(rules, state, colony, r.item);
const isHead = ri === 0;
qs.content.add(scene.add.rectangle(0, qy, w, 42, 0x0f1a2c, isHead ? 0.85 : 0.5)
.setOrigin(0, 0).setStrokeStyle(1, ACCENT, isHead ? 0.35 : 0.12));
qs.content.add(scene.add.text(12, qy + 5,
`${itemName(rules, state, colony, r.item)}${r.count > 1 ? ` x ${r.count}` : ''}`, {
fontFamily: FONT, fontSize: '17px', color: r.item.kind === 'building' ? '#ffd88a' : '#9fd8ff',
}));
qs.content.add(scene.add.text(12, qy + 25,
(isHead ? `${Math.round(r.item.progress ?? 0)} / ${Math.round(cost)} BC · ` : `${Math.round(cost)} BC each · `)
+ etaText(etas[r.lastIndex]), {
fontFamily: FONT, fontSize: '13px', color: '#7f97b3',
}));
const bx = w - 22;
// Guarded like the catalogue rows: a mask hides a row, it does not stop
// it being clicked.
pusher(qs.content, bx, qy + 21, '✕', () => {
// Remove one copy: the tail of a run, so a "x 5" ticks down to "x 4"
// and the part-built head is the last thing you can lose.
dequeue(rules, state, colony, r.lastIndex);
rebuildFlyout();
}, true, qs);
pusher(qs.content, bx - 34, qy + 21, '▼', () => moveRun(rows, ri, 1),
ri < rows.length - 1, qs);
pusher(qs.content, bx - 68, qy + 21, '▲', () => moveRun(rows, ri, -1), ri > 0, qs);
qy += 48;
});
qs.contentHeight = qy;
qs.setOffset(keep[0] ?? 0);
y += queueH + 6;
// ---- the catalogue, in a scrolling column of its own
flyLayer.add(scene.add.text(x, y, 'AVAILABLE — CLICK TO QUEUE', {
fontFamily: FONT, fontSize: '14px', color: '#6f8aa3',
}));
y += 22;
flyLayer.add(scene.add.rectangle(x, y, w, 1, ACCENT, 0.22).setOrigin(0, 0));
y += 10;
const colH = PANEL_Y + PANEL_H - 24 - y;
const scroller = scrollColumn(scene, flyLayer, x, y, w, colH);
scrollers.push(scroller);
let cy = 0;
const entry = (icon, label, sub, colour, onAdd, onAddFive) => {
const bg = scene.add.rectangle(0, cy, w, 52, 0x0f1a2c, 0.0)
.setOrigin(0, 0).setInteractive({ useHandCursor: true });
bg.on('pointerover', () => bg.setFillStyle(0x18293f, 0.9));
bg.on('pointerout', () => bg.setFillStyle(0x0f1a2c, 0.0));
// Scrolled out of sight is still hit-testable — a mask is a rendering
// concern only, so the visible band has to be checked explicitly.
bg.on('pointerup', (p) => { if (scroller.contains(p)) onAdd(); });
scroller.content.add(bg);
// Both sheets are optional and procedural until real art lands, so this
// always resolves to something.
scroller.content.add(scene.add.image(28, cy + 26, icon.key, icon.frame)
.setDisplaySize(40, 40));
scroller.content.add(scene.add.text(58, cy + 5, label, {
fontFamily: FONT, fontSize: '17px', color: colour,
}));
scroller.content.add(scene.add.text(58, cy + 27, sub, {
fontFamily: FONT, fontSize: '13px', color: '#7f97b3', wordWrap: { width: w - 180 },
}));
if (onAddFive) {
// The repeat count. Its own button so the row click stays "add one".
const r = scene.add.rectangle(w - 40, cy + 26, 46, 26, 0x16253c)
.setStrokeStyle(1, ACCENT, 0.55).setInteractive({ useHandCursor: true });
r.on('pointerover', () => r.setFillStyle(0x22405f));
r.on('pointerout', () => r.setFillStyle(0x16253c));
r.on('pointerup', (p) => { if (scroller.contains(p)) onAddFive(); });
scroller.content.add(r);
scroller.content.add(scene.add.text(w - 40, cy + 25, '+5', {
fontFamily: FONT, fontSize: '15px', color: '#cfe8ff',
}).setOrigin(0.5));
}
cy += 56;
};
const turns = (cost) => (rate > 0 ? `${Math.max(1, Math.ceil(cost / rate))} turns` : 'no budget');
for (const hull of rules.hullList) {
const d = empireDesign(rules, state, colony.empireIdx, hull.id);
entry({ key: art.ships, frame: shipFrame(rules, emp.speciesId, hull.id) },
d.name, `${d.cost} BC · ${turns(d.cost)} · ${hull.desc}`, '#9fd8ff',
() => { enqueue(rules, state, colony, 'ship', hull.id); rebuildFlyout(); },
() => { enqueueMany(rules, state, colony, 'ship', hull.id, 5); rebuildFlyout(); });
}
for (const b of rules.buildingList) {
if (colony.buildings.includes(b.id)) continue;
if (b.prereq && !emp.known[b.prereq]) continue;
const queued = colony.queue.some((q) => q.kind === 'building' && q.id === b.id);
entry({ key: art.buildings, frame: buildingFrame(rules, b.id) },
`${b.name}${queued ? ' — queued' : ''}`,
`${b.cost} BC · ${turns(b.cost)} · ${b.upkeep} BC upkeep · ${b.desc}`,
queued ? '#6f8aa3' : '#ffd88a',
() => { if (enqueue(rules, state, colony, 'building', b.id)) rebuildFlyout(); });
}
scroller.contentHeight = cy;
scroller.setOffset(keep[1] ?? 0);
}
const moveRun = (rows, ri, dir) => {
moveQueueRun(rules, state, colony, rows, ri, dir);
rebuildFlyout();
};
/**
* A small square push-button; Button is too heavy for a row of these.
* `guard` is the scroll column it lives in, if any a masked row is hidden
* but still hit-testable, so it has to be asked whether the click landed
* inside the visible band.
*/
function pusher(layer, x, y, label, fn, enabled = true, guard = null) {
const r = scene.add.rectangle(x, y, 26, 26, enabled ? 0x16253c : 0x101a29)
.setStrokeStyle(1, ACCENT, enabled ? 0.55 : 0.18);
layer.add(r);
layer.add(scene.add.text(x, y - 1, label, {
fontFamily: FONT, fontSize: '15px', color: enabled ? '#cfe8ff' : '#3c4c60',
}).setOrigin(0.5));
if (!enabled) return;
r.setInteractive({ useHandCursor: true });
r.on('pointerover', () => r.setFillStyle(0x22405f));
r.on('pointerout', () => r.setFillStyle(0x16253c));
r.on('pointerup', (p) => { if (!guard || guard.contains(p)) fn(); });
}
buildPanel();
return { root, close };
}
/** Display name for a queue entry — ships carry their current Mark. */
export function itemName(rules, state, colony, item) {
return item.kind === 'building'
? rules.buildings[item.id].name
: empireDesign(rules, state, colony.empireIdx, item.id).name;
}

View File

@ -166,6 +166,54 @@ export function colonyDefenseCap(rules, state, colony) {
return Math.round((100 + comps.planetaryShield * 30) * buildingMult(rules, colony, 'defense'));
}
/**
* BC per turn actually reaching the build queue, used for the "N turns" figures
* the colony screen quotes. That is the `ships` share PLUS the two spillovers
* `processColony` folds in: industry whose factories are already capped, and
* defence whose batteries are already topped up.
*
* It has to live here rather than in a view because two different screens quote
* the same ETA and they must agree. It is an estimate by nature mandatory
* ecology cleanup is taken pro-rata off every channel first, and how much that
* costs depends on waste generated during the turn so it is deliberately
* quoted before cleanup rather than trying to predict it.
*/
export function colonyBuildRate(rules, state, colony) {
const prod = colonyProduction(rules, state, colony);
let rate = prod * (colony.sliders.ships ?? 0);
if (colony.factories >= colonyFactoryCap(rules, state, colony)) {
rate += prod * (colony.sliders.industry ?? 0);
}
if (colony.defenseHp >= colonyDefenseCap(rules, state, colony)) {
rate += prod * (colony.sliders.defense ?? 0);
}
return Math.max(0, rate);
}
/** Turns for a queue item to finish at the current rate, or Infinity if idle. */
export function queueItemEta(rules, state, colony, item, alreadyQueued = 0) {
const rate = colonyBuildRate(rules, state, colony);
if (rate <= 0) return Infinity;
const remaining = queueItemCost(rules, state, colony, item) - (item.progress ?? 0);
return Math.max(1, Math.ceil((alreadyQueued + remaining) / rate));
}
/**
* Turns until each queue entry finishes, CUMULATIVE down the queue the engine
* spends on `queue[0]` alone, so an item's ETA includes everything ahead of it.
* Indexed to match `colony.queue`, so a collapsed run reads its figure off its
* own `lastIndex`. Both the colony screen and the system view quote these and
* they have to agree, which is why it is here rather than in either view.
*/
export function queueEtas(rules, state, colony) {
const rate = colonyBuildRate(rules, state, colony);
let acc = 0;
return colony.queue.map((item) => {
acc += Math.max(0, queueItemCost(rules, state, colony, item) - (item.progress ?? 0));
return rate > 0 ? Math.max(1, Math.ceil(acc / rate)) : Infinity;
});
}
export function colonyTrade(rules, state, colony) {
const emp = state.empires[colony.empireIdx];
const spec = rules.species[emp.speciesId];
@ -1579,12 +1627,98 @@ export function enqueue(rules, state, colony, kind, id) {
return true;
}
/**
* Queue `n` copies of the same thing the repeat-count button on the colony
* screen. They are stored as n SEPARATE entries rather than one entry with a
* count, so `processColony` and the save format need no knowledge of repeats at
* all; the view is what collapses a run of identical ships into a "x N" row.
* Buildings are unique per colony, so `enqueue` refuses every copy after the
* first and this returns 1. Returns how many were actually added.
*/
export function enqueueMany(rules, state, colony, kind, id, n) {
let added = 0;
for (let i = 0; i < Math.max(0, Math.floor(n)); i += 1) {
if (!enqueue(rules, state, colony, kind, id)) break;
added += 1;
}
return added;
}
export function dequeue(rules, state, colony, index) {
if (index < 0 || index >= colony.queue.length) return false;
colony.queue.splice(index, 1);
return true;
}
/**
* Reorder the queue. `progress` is a property of the item object, so it travels
* with the move for free demoting the half-built head item banks its BC
* rather than losing them, and promoting something else does not hand it the
* head's progress. `processColony` only ever looks at `queue[0]`, so a splice
* between turns is safe.
*/
export function moveQueueItem(rules, state, colony, from, to) {
const n = colony.queue.length;
if (from < 0 || from >= n) return false;
const dest = Math.max(0, Math.min(n - 1, to));
if (dest === from) return true;
const [item] = colony.queue.splice(from, 1);
colony.queue.splice(dest, 0, item);
return true;
}
/**
* Collapse the queue into display runs: consecutive identical ships become one
* `{ index, lastIndex, item, count }` row. `enqueueMany` stores a repeat as N
* separate entries so the turn processor and the save format never learn about
* repeats, and this is what folds them back up for a UI.
*
* A part-built entry never joins a run its progress has to stay individually
* legible, and it is always at the head anyway, since slot 0 is the only one
* the engine ever spends on.
*/
export function collapseQueue(colony) {
const rows = [];
colony.queue.forEach((item, index) => {
const last = rows[rows.length - 1];
const runnable = item.kind === 'ship' && (item.progress ?? 0) === 0;
if (last && runnable && last.item.kind === 'ship' && last.item.id === item.id
&& (last.item.progress ?? 0) === 0) {
last.count += 1;
last.lastIndex = index;
return;
}
rows.push({ index, lastIndex: index, item, count: 1 });
});
return rows;
}
/**
* Move a whole collapsed run one row up (dir -1) or down (dir +1), where `rows`
* came from `collapseQueue`. A "x 5" row is five queue entries, so this is five
* splices and the two directions do NOT use the same indices, because every
* splice shifts what comes after it:
*
* up the copies still waiting behind keep their original slots, so both
* ends of the move advance together (`+ k`).
* down pulling the head copy out slides the rest of the run down into the
* slot it just vacated, so the SAME pair of indices moves every copy.
*
* Getting this wrong does not throw; it silently interleaves the run with its
* neighbour, which is why it is here in the headless tier and checked in the
* verifier rather than left in the view.
*/
export function moveQueueRun(rules, state, colony, rows, ri, dir) {
const run = rows[ri];
const neighbour = rows[ri + dir];
if (!run || !neighbour) return false;
for (let k = 0; k < run.count; k += 1) {
if (dir < 0) moveQueueItem(rules, state, colony, run.index + k, neighbour.index + k);
else moveQueueItem(rules, state, colony, run.index, neighbour.lastIndex);
}
return true;
}
export function hireLeader(rules, state, e, leaderId) {
const emp = state.empires[e];
const leader = rules.leaders[leaderId];

View File

@ -17,7 +17,16 @@ import { leaderOffers } from './VegaLeaders.js';
import { makeSpeciesPortrait } from './VegaArt.js';
export const FONT = '"Julius Sans One"';
export const D = { map: 1, hud: 30, modal: 60, toast: 80 };
// `colony` sits above `modal` so the colony screen can stack over the system
// view that opened it without either having to be torn down.
export const D = { map: 1, hud: 30, modal: 60, colony: 70, toast: 80 };
/**
* Orbit numerals. Worldgen rolls at most five planets, but
* `guaranteeNearbyWorlds` can append more to a star that needed help, so the
* list runs long and every caller still falls back to `orbit + 1`.
*/
export const ORBIT = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII'];
const ACCENT = 0x6fc4ff;
const PANEL = 0x0b1220;

View File

@ -30,7 +30,7 @@ import {
colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
colonyDefenseCap, habitableForEmpire, reachableStars, atWar,
} from './VegaLogic.js';
import { FONT, D } from './VegaScreens.js';
import { FONT, D, ORBIT } from './VegaScreens.js';
const W = 400;
const PAD = 18;
@ -38,8 +38,6 @@ const COL = W - PAD * 2;
const ACCENT = 0x6fc4ff;
const PANEL = 0x0b1220;
const ORBIT = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII'];
export default class VegaSidePanel {
constructor(scene, rules, state, art, cb = {}) {
this.scene = scene;

View File

@ -1,25 +1,29 @@
// Master of Vega — the system view: a live orrery on the left, and the colony
// console on the right when the selected world is yours.
// Master of Vega — the system view: a live orrery on the left, and a planet
// inspector on the right.
//
// The orrery is where the closest semantic-zoom step lands, and it is the
// drop-in point for planet artwork: every body is a frame on the `planets`
// sheet, procedural until real art arrives.
//
// The right-hand column INSPECTS a world; it does not run one. Running a colony
// — the five live sliders and the build queue with its catalogue — needs far
// more room than one column of a modal, and now lives on its own full-screen
// screen behind the "View Colony" button (VegaColonyView.js). What is left here
// is a read-only picture of the same numbers, which is what you want when the
// question is "which of these six worlds should I be looking at".
import * as Phaser from 'phaser';
import { Button } from '../../ui/Button.js';
import { modalShell, slider, FONT } from './VegaScreens.js';
import { modalShell, FONT, ORBIT } from './VegaScreens.js';
import { planetFrame, starFrame } from './VegaArt.js';
import { openColonyView, CHANNEL_COLOUR, itemName, etaText } from './VegaColonyView.js';
import {
CHANNELS, coloniesAt, canColonize, colonize, colonyMaxPop, colonyProduction,
colonyFactoryCap, effectiveFactories, colonyDefenseCap, setSlider, enqueue, dequeue,
queueItemCost, empireDesign, invasionForecast, invade, bombard, atWar,
colonyFactoryCap, effectiveFactories, colonyDefenseCap, colonyBuildRate,
collapseQueue, queueItemCost, queueEtas, invasionForecast, invade, bombard, atWar,
} from './VegaLogic.js';
const CHANNEL_COLOUR = {
ships: 0x6fc4ff, defense: 0xe08a8a, industry: 0xffd88a, ecology: 0x7fd8a0, research: 0xb89cff,
};
export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
const { viewerIdx = state.humanIndex, onChanged = null, onClose = null } = opts;
const star = state.galaxy.stars[starIdx];
@ -58,14 +62,42 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
star.planets.forEach((planet, i) => {
const type = rules.planetTypes[planet.typeId];
const size = 34 + (rules.planetSizes[planet.sizeId]?.basePop ?? 40) * 0.18;
// Orbit ellipses are stroked every tick alongside the bodies themselves.
const img = scene.add.image(0, 0, art.planets, planetFrame(rules, planet.typeId))
.setDisplaySize(34 + (rules.planetSizes[planet.sizeId]?.basePop ?? 40) * 0.18,
34 + (rules.planetSizes[planet.sizeId]?.basePop ?? 40) * 0.18)
.setDisplaySize(size, size)
.setInteractive({ useHandCursor: true });
img.on('pointerup', () => select(i));
orrery.add(img);
bodies.push({ planet, img, type, angle: planet.orbitAngle, radius: planet.orbitRadius * 1.35 });
// The planet and its colony marker move together, so they share one
// container — that container's position (not the image's) is both what
// the tick loop sets and what orrery.sort('y') depth-sorts on.
const body = scene.add.container(0, 0);
body.add(img);
orrery.add(body);
// A small downward-pointing arrow hovering over any colonised world,
// tinted to its owner's colour. Hidden by default; the tick loop toggles
// it on/off (and recolours it) every frame so a colony founded or
// captured mid-view picks up the right owner immediately without
// rebuilding the orrery.
//
// The three points MUST be given in Phaser's own 0-to-width/0-to-height
// space (as its Triangle examples do), not pre-centred about (0,0).
// FillPathWebGL renders each raw point shifted by `-displayOriginX`
// (= originX * width) — with origin 0.5 that already re-centres a
// 0..width shape. Points that are already centred get shifted a SECOND
// time, landing the whole triangle half its width off to one side.
const markerBaseY = -(size / 2) - 13;
const marker = scene.add.triangle(0, markerBaseY, 8, 18, 0, 0, 16, 0, 0xffffff)
.setOrigin(0.5)
.setStrokeStyle(1.5, 0x0a1420, 0.85)
.setVisible(false);
body.add(marker);
bodies.push({
planet, img, body, marker, markerBaseY, type, angle: planet.orbitAngle, radius: planet.orbitRadius * 1.35,
});
});
// --- right-hand console
@ -88,18 +120,22 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
const colony = coloniesAt(state, starIdx).find((c) => c.orbit === selected);
let y = shell.body.y + 10;
console_.add(scene.add.text(panelX, y, `${star.name} ${['I', 'II', 'III', 'IV', 'V', 'VI'][selected] ?? selected + 1}`, {
fontFamily: FONT, fontSize: '28px', color: '#cfe8ff',
}));
y += 40;
console_.add(scene.add.text(panelX, y,
`${type.name} · ${rules.planetSizes[planet.sizeId]?.name ?? ''} · `
+ `${rules.richness[planet.richId]?.name ?? ''} · ${rules.gravity[planet.gravId]?.name ?? ''}`, {
fontFamily: FONT, fontSize: '16px', color: '#9fb6cc',
// The world at full resolution. The `planets` sheet is cut at 192x192 and
// every other use of it scales down, so this is the one place the art is
// seen at the size it was drawn — hence no setDisplaySize.
console_.add(scene.add.image(panelX + 96, y + 96, art.planets, planetFrame(rules, planet.typeId)));
console_.add(scene.add.text(panelX + 208, y + 18,
`${star.name} ${ORBIT[selected] ?? selected + 1}`, {
fontFamily: FONT, fontSize: '30px', color: '#cfe8ff',
}));
y += 26;
console_.add(scene.add.text(panelX + 208, y + 60,
`${type.name} · ${rules.planetSizes[planet.sizeId]?.name ?? ''}\n`
+ `${rules.richness[planet.richId]?.name ?? ''} · ${rules.gravity[planet.gravId]?.name ?? ''}`, {
fontFamily: FONT, fontSize: '17px', color: '#9fb6cc', lineSpacing: 4,
}));
y += 200;
console_.add(scene.add.text(panelX, y, type.desc, {
fontFamily: FONT, fontSize: '14px', color: '#6f8aa3', wordWrap: { width: panelW },
fontFamily: FONT, fontSize: '15px', color: '#6f8aa3', wordWrap: { width: panelW },
}));
y += 44;
@ -162,84 +198,86 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
return;
}
// --- our colony: the five MOO1 allocation sliders
// --- our colony: a read-out, not a console. Everything below is what the
// colony screen shows you editable; nothing here responds to a click.
const rate = colonyBuildRate(rules, state, colony);
console_.add(scene.add.text(panelX, y, 'ALLOCATION', {
fontFamily: FONT, fontSize: '17px', color: '#cfe8ff',
}));
y += 28;
const sliders = [];
CHANNELS.forEach((ch, i) => {
const s = slider(scene, panelX, y, panelW - 40,
rules.economy.channelNames[ch] ?? ch, colony.sliders[ch] ?? 0, (v) => {
setSlider(rules, state, colony, ch, v);
sliders.forEach((other, j) => {
if (j !== i) other.setValue(colony.sliders[CHANNELS[j]] ?? 0);
});
onChanged?.();
}, CHANNEL_COLOUR[ch]);
console_.add(s.container);
sliders.push(s);
y += 52;
});
console_.add(scene.add.text(panelX, y,
'Ecology is funded first — a shortfall is taken from the other channels automatically.', {
fontFamily: FONT, fontSize: '13px', color: '#6f8aa3', wordWrap: { width: panelW - 40 },
y += 30;
const barW = panelW - 200;
CHANNELS.forEach((ch) => {
const v = colony.sliders[ch] ?? 0;
console_.add(scene.add.text(panelX, y - 3, rules.economy.channelNames[ch] ?? ch, {
fontFamily: FONT, fontSize: '15px', color: '#a8c4e0',
}));
y += 34;
console_.add(scene.add.rectangle(panelX + 160, y + 4, barW, 8, 0x1b2b42).setOrigin(0, 0.5));
console_.add(scene.add.rectangle(panelX + 160, y + 4, barW * v, 8, CHANNEL_COLOUR[ch])
.setOrigin(0, 0.5));
console_.add(scene.add.text(panelX + panelW, y - 3,
`${Math.round(v * 100)}%${colony.locked[ch] ? ' ■' : ''}`, {
fontFamily: FONT, fontSize: '15px', color: colony.locked[ch] ? '#ffd88a' : '#e8f4ff',
}).setOrigin(1, 0));
y += 28;
});
y += 14;
// --- build queue
// --- build queue, read-only
console_.add(scene.add.text(panelX, y, 'BUILD QUEUE', {
fontFamily: FONT, fontSize: '17px', color: '#cfe8ff',
}));
y += 26;
if (!colony.queue.length) {
console_.add(scene.add.text(panelX, y, 'Idle — output spills into research.', {
// The number every ETA below is derived from — without it the turn counts
// look arbitrary.
console_.add(scene.add.text(panelX + panelW, y + 2,
`${rate.toFixed(1)} BC per turn`, {
fontFamily: FONT, fontSize: '15px', color: '#9fd8ff',
}).setOrigin(1, 0));
y += 28;
const rows = collapseQueue(colony);
if (!rows.length) {
console_.add(scene.add.text(panelX, y, 'Idle — construction spills into research.', {
fontFamily: FONT, fontSize: '15px', color: '#6f8aa3',
}));
y += 26;
}
// Cumulative, so a run's figure is when the LAST of it is done — which is
// why each row reads its ETA off its own lastIndex.
const etas = queueEtas(rules, state, colony);
rows.slice(0, 4).forEach((r, ri) => {
const cost = queueItemCost(rules, state, colony, r.item);
console_.add(scene.add.text(panelX, y,
`${itemName(rules, state, colony, r.item)}${r.count > 1 ? ` x ${r.count}` : ''}`
+ (ri === 0 ? ` ${Math.round(r.item.progress ?? 0)} / ${Math.round(cost)} BC` : '')
+ ` · ${etaText(etas[r.lastIndex])}`, {
fontFamily: FONT, fontSize: '15px', color: ri === 0 ? '#ffd88a' : '#8fa8c0',
}));
y += 24;
});
if (rows.length > 4) {
console_.add(scene.add.text(panelX, y, `…and ${rows.length - 4} more`, {
fontFamily: FONT, fontSize: '14px', color: '#6f8aa3',
}));
y += 24;
}
colony.queue.forEach((item, i) => {
const cost = queueItemCost(rules, state, colony, item);
const name = item.kind === 'building'
? rules.buildings[item.id].name
: empireDesign(rules, state, colony.empireIdx, item.id).name;
const row = scene.add.text(panelX, y, `${name} ${Math.round(item.progress)}/${Math.round(cost)}`, {
fontFamily: FONT, fontSize: '15px', color: '#c8dcf0',
}).setInteractive({ useHandCursor: true });
row.on('pointerup', () => { dequeue(rules, state, colony, i); onChanged?.(); rebuild(); });
console_.add(row);
y += 22;
});
y += 12;
// --- what can be added
console_.add(scene.add.text(panelX, y, 'ADD TO QUEUE (click to queue)', {
fontFamily: FONT, fontSize: '15px', color: '#8fa8c0',
}));
y += 24;
const emp = state.empires[colony.empireIdx];
let cx = panelX;
for (const hull of rules.hullList) {
const d = empireDesign(rules, state, colony.empireIdx, hull.id);
const t = scene.add.text(cx, y, `${d.name} (${d.cost})`, {
fontFamily: FONT, fontSize: '13px', color: '#9fd8ff',
}).setInteractive({ useHandCursor: true });
t.on('pointerup', () => { enqueue(rules, state, colony, 'ship', hull.id); onChanged?.(); rebuild(); });
console_.add(t);
y += 19;
}
y += 8;
for (const b of rules.buildingList) {
if (colony.buildings.includes(b.id)) continue;
if (b.prereq && !emp.known[b.prereq]) continue;
const t = scene.add.text(cx, y, `${b.name} (${b.cost})`, {
fontFamily: FONT, fontSize: '13px', color: '#ffd88a',
}).setInteractive({ useHandCursor: true });
t.on('pointerup', () => { enqueue(rules, state, colony, 'building', b.id); onChanged?.(); rebuild(); });
console_.add(t);
y += 19;
if (y > shell.y + shell.height - 60) break;
}
// The one action: hand the whole colony over to its own screen. Opened
// directly 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 is keeping the star map behind it inert.
y = Math.max(y + 20, shell.y + shell.height - 96);
console_.add(new Button(scene, panelX + panelW / 2, y + 24, 'View Colony', () => {
shell.layer.setVisible(false);
tick.paused = true;
openColonyView(scene, rules, state, colony, art, {
onChanged,
onClose: () => {
shell.layer.setVisible(true);
tick.paused = false;
rebuild();
},
});
}, { width: panelW, height: 50, fontSize: 21 }));
}
function select(i) {
@ -251,20 +289,33 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
if (star.planets.length) select(0);
// The orrery turns in real time; planets are where the drop-in art lands.
let pulseT = 0;
const tick = scene.time.addEvent({
delay: 33,
loop: true,
callback: () => {
orbitGfx.clear();
orbitGfx.lineStyle(1, 0x2c4468, 0.55);
for (const b of bodies) {
pulseT += 33;
// Subtle vertical bob, independent of orbit speed so every marker
// pulses at the same gentle rate regardless of which world it's over.
const bob = Math.sin(pulseT / 260) * 3;
const colonyByOrbit = new Map(coloniesAt(state, starIdx).map((c) => [c.orbit, c]));
bodies.forEach((b, i) => {
b.angle += b.planet.orbitSpeed * 0.012;
orbitGfx.strokeEllipse(orreryCX, orreryCY, b.radius * 2, b.radius * 1.1);
b.img.setPosition(
b.body.setPosition(
orreryCX + Math.cos(b.angle) * b.radius,
orreryCY + Math.sin(b.angle) * b.radius * 0.55,
);
}
const colony = colonyByOrbit.get(i);
b.marker.setVisible(!!colony);
if (colony) {
const owner = state.empires[colony.empireIdx];
b.marker.setFillStyle(Phaser.Display.Color.HexStringToColor(owner.color).color, 1);
b.marker.setPosition(0, b.markerBaseY + bob);
}
});
// Re-sort every tick: whichever body (or the star) sits lowest on
// screen this frame draws last, i.e. on top.
orrery.sort('y');

View File

@ -12,6 +12,10 @@ mismaps every piece of artwork already drawn.
Status: ⬜ = not painted yet (procedural fallback in use).
Two different pictures of a planet are used and they are **not** interchangeable:
the `planets` sheet (§2) is a transparent 192 px disc for the orrery, and the
world backdrops (§2b) are opaque 1920 × 1080 scenes for the colony screen.
---
## 1. `ships` — ⬜ `assets/images/vega/ships.png`
@ -78,6 +82,50 @@ both place these on a black background and scale them.
---
### 2b. World backdrops — ✅ mostly painted, `assets/images/vega/world-<type>-01.png`
**Not part of the `planets` sheet.** These are full-screen **1920 × 1080 opaque**
backdrops for the colony screen (`VegaColonyView.js`) — the same worlds seen from
the ground rather than from orbit. The sheet above stays what it is: transparent
192 px discs for the orrery.
Declared per planet type in `worldBackgrounds` in `data/mastervega-artwork.json`,
exactly like the species stills. A `null` path is a legitimate "not painted yet"
and falls back to a gradient built from that type's `color` in the rules file, so
the set can be finished one world at a time with no code change.
The `-01` suffix is a **variant slot** — nothing reads a variant index yet, but
the naming leaves room for `world-terran-02.png` and a roll.
| Type | File | |
|---|---|---|
| Terran | `world-terran-01.png` | ✅ |
| Ocean | `world-ocean-01.png` | ✅ |
| **Jungle** | — | ⬜ **outstanding** |
| Steppe | `world-steppe-01.png` | ✅ |
| Arid | `world-arid-01.png` | ✅ |
| Desert | `world-desert-01.png` | ✅ |
| Tundra | `world-tundra-01.png` | ✅ |
| Minimal | `world-minimal-01.png` | ✅ |
| Barren | `world-barren-01.png` | ✅ |
| Dead | `world-dead-01.png` | ✅ |
| Inferno | `world-inferno-01.png` | ✅ |
| Toxic | `world-toxic-01.png` | ✅ |
| Radiated | `world-radiated-01.png` | ✅ |
| Gas Giant | ⚠️ `world-gas-**giant**-01.png` | ✅ |
| Asteroid Belt | — | ⬜ not needed — neither type is colonisable, so the colony screen never opens on one |
> ⚠️ The gas giant's filename is `world-gas-giant-01.png` **with a hyphen** while
> its rules id is `gasgiant`. Same class of mismatch as `char-kestralli.png`: the
> manifest points at the real filename, and the verifier fails immediately if a
> declared path stops resolving.
Text is drawn over the top-left of these (name, owner, world details) with a drop
shadow, and the right ~500 px sits under a semi-transparent panel — so keep the
top-left readable and do not put the subject of the picture on the far right.
---
## 3. `stars` — ⬜ `assets/images/vega/stars.png`
| | |
@ -224,6 +272,7 @@ column 2 → pixel origin **(88, 264)**. **Painted.**
|---|---|---|
| `assets/images/vega/background-menu.png` | 1920 × 1080 | Backdrop for both menus — dimmed 34% on the landing screen, 62% on the setup screen so the species cards stay legible |
| `assets/images/vega/menu-title.png` | 1368 × 768 | Title logo. Large on the **left** of the landing screen (contained in 900 × 560 → 900 × 505), and small as the setup screen's header (contained in 760 × 132). Always contained, never stretched. |
| `assets/images/vega/world-*-01.png` | 1920 × 1080 | Colony-screen backdrops, one per planet type — see §2b. |
## Speech — ✅ `assets/speech/vega/`

View File

@ -37,7 +37,7 @@ import { buildZoomLadder, minZoomFor, MAX_ZOOM, DEFAULT_ZOOM_INDEX } from '../sr
// them is checkable headlessly and worth checking.
import {
ensureSheets, shipFrame, planetFrame, techFrame, buildingFrame,
speciesVideoKey, speciesStillKey, speciesSpeechClip, UI_SPEECH,
speciesVideoKey, speciesStillKey, speciesSpeechClip, UI_SPEECH, worldBgKey,
} from '../src/games/mastervega/VegaArt.js';
const QUICK = process.argv.includes('--quick');
@ -254,6 +254,33 @@ section('2. Procedural art');
check(`species ${s.id} has a portraitStills entry`, stillIds.includes(s.id));
}
// World backdrops for the colony screen. These are 1920x1080 opaque images,
// NOT frames on the `planets` sheet — that one holds transparent 192px discs
// for the orrery, which is a different picture of the same world. Same
// drop-in contract as the portraits: every type must be declared so a new one
// cannot be silently forgotten, a null path is a legitimate "not painted yet"
// that falls back to a gradient, and any path that IS set must resolve.
const worlds = artJson.worldBackgrounds ?? {};
const worldIds = Object.keys(worlds).filter((k) => !k.startsWith('_'));
for (const id of worldIds) {
check(`worldBackgrounds entry ${id} is a known planet type`, !!RULES.planetTypes[id]);
check(`worldBackgrounds ${id} key matches the loader's key`,
worlds[id].key === worldBgKey(id), `${worlds[id].key} vs ${worldBgKey(id)}`);
if (worlds[id].path) {
check(`worldBackgrounds ${id} file exists`, existsSync(join(root, worlds[id].path)),
worlds[id].path);
}
}
for (const p of RULES.planetTypeList) {
check(`planet type ${p.id} has a worldBackgrounds entry`, worldIds.includes(p.id));
}
const worldsPainted = worldIds.filter((id) => worlds[id].path).length;
const colonisablePainted = RULES.planetTypeList
.filter((p) => p.colonizable && worlds[p.id]?.path).length;
const colonisableTotal = RULES.planetTypeList.filter((p) => p.colonizable).length;
console.log(` (${worldsPainted}/${worldIds.length} world backdrops painted; `
+ `${colonisablePainted}/${colonisableTotal} of the colonisable types)`);
const recorded = vidIds.filter((id) => vids[id].path).length;
const stillCount = stillIds.filter((id) => stills[id].path).length;
console.log(` (${recorded}/${RULES.speciesList.length} portraits are video, `
@ -764,6 +791,127 @@ section('6. Colony economy');
Logic.setSlider(RULES, st, colony, 'industry', 5);
check('slider values are clamped', colony.sliders.industry <= 1);
// The two blocks below poke at one colony's allocation and queue. The
// 200-turn soak further down measures every colony against its own ceiling,
// so put this one back exactly as it was found rather than handing the soak a
// colony configured by a unit test.
const restore = {
sliders: { ...colony.sliders }, locked: { ...colony.locked }, queue: [...colony.queue],
};
// --- padlocks. `colony.locked` has been honoured by setSlider since the
// engine was written but had no UI until the colony screen, so this is the
// first thing that proves the behaviour is what that screen assumes.
{
const c = st.colonies[0];
Logic.CHANNELS.forEach((ch) => { delete c.locked[ch]; });
Logic.setSlider(RULES, st, c, 'research', 0.2);
c.locked.research = true;
const pinned = c.sliders.research;
Logic.setSlider(RULES, st, c, 'industry', 0.7);
check('a locked channel holds its share', Math.abs(c.sliders.research - pinned) < 1e-9,
`${c.sliders.research} vs ${pinned}`);
const lockedSum = Logic.CHANNELS.reduce((t, ch) => t + c.sliders[ch], 0);
check('locking still normalises the whole allocation to 1',
Math.abs(lockedSum - 1) < 1e-6, `${lockedSum}`);
// A channel cannot claim room a lock has already spoken for. The colony
// screen relies on this to keep the last channel unlockable.
c.locked.ecology = true;
c.locked.defense = true;
Logic.setSlider(RULES, st, c, 'industry', 1);
const room = 1 - (c.sliders.research + c.sliders.ecology + c.sliders.defense);
check('a slider cannot take room the locks reserved',
c.sliders.industry <= room + 1e-9, `${c.sliders.industry} vs ${room}`);
Logic.CHANNELS.forEach((ch) => { delete c.locked[ch]; });
Logic.setSlider(RULES, st, c, 'industry', 0.4);
}
// --- build queue: repeats, reordering, and the ETA the colony screen quotes.
{
const c = st.colonies[0];
c.queue.length = 0;
check('enqueueMany queues every copy it was asked for',
Logic.enqueueMany(RULES, st, c, 'ship', 'scout', 3) === 3, `${c.queue.length}`);
check('repeats are stored as separate entries', c.queue.length === 3);
// Buildings are unique per colony, so a repeat request stops after the first.
const bId = RULES.buildingList.find((b) => !b.prereq).id;
check('enqueueMany refuses duplicate buildings',
Logic.enqueueMany(RULES, st, c, 'building', bId, 4) === 1);
c.queue.length = 0;
Logic.enqueue(RULES, st, c, 'ship', 'scout');
Logic.enqueue(RULES, st, c, 'ship', 'frigate');
Logic.enqueue(RULES, st, c, 'ship', 'destroyer');
c.queue[0].progress = 7;
check('moveQueueItem rejects an out-of-range source',
!Logic.moveQueueItem(RULES, st, c, 9, 0) && !Logic.moveQueueItem(RULES, st, c, -1, 0));
check('moving an item onto itself is a no-op that still succeeds',
Logic.moveQueueItem(RULES, st, c, 1, 1) && c.queue[1].id === 'frigate');
Logic.moveQueueItem(RULES, st, c, 2, 0);
check('moveQueueItem reorders the queue',
c.queue.map((q) => q.id).join(',') === 'destroyer,scout,frigate',
c.queue.map((q) => q.id).join(','));
// Progress lives on the item, so demoting the half-built head banks its BC
// rather than handing them to whatever was promoted over it.
check('progress travels with the item it belongs to',
c.queue[1].id === 'scout' && c.queue[1].progress === 7 && c.queue[0].progress === 0,
`${c.queue[0].id}:${c.queue[0].progress} ${c.queue[1].id}:${c.queue[1].progress}`);
check('a destination past the end clamps to the last slot',
Logic.moveQueueItem(RULES, st, c, 0, 99) && c.queue[2].id === 'destroyer');
// --- collapsed runs. A "x N" row is N queue entries, and moving one is N
// splices whose indices differ by direction — get it wrong and the run
// silently interleaves with its neighbour instead of throwing.
c.queue.length = 0;
Logic.enqueueMany(RULES, st, c, 'ship', 'scout', 3);
Logic.enqueueMany(RULES, st, c, 'ship', 'frigate', 2);
let runs = Logic.collapseQueue(c);
check('identical consecutive ships collapse into one run',
runs.length === 2 && runs[0].count === 3 && runs[1].count === 2,
runs.map((r) => `${r.item.id}x${r.count}`).join(','));
check('a run records the span it covers',
runs[0].index === 0 && runs[0].lastIndex === 2 && runs[1].index === 3);
Logic.moveQueueRun(RULES, st, c, runs, 0, 1);
check('moving a run down keeps it contiguous',
c.queue.map((q) => q.id).join(',') === 'frigate,frigate,scout,scout,scout',
c.queue.map((q) => q.id).join(','));
runs = Logic.collapseQueue(c);
Logic.moveQueueRun(RULES, st, c, runs, 1, -1);
check('moving a run back up restores the original order',
c.queue.map((q) => q.id).join(',') === 'scout,scout,scout,frigate,frigate',
c.queue.map((q) => q.id).join(','));
check('moving a run off either end is refused',
!Logic.moveQueueRun(RULES, st, c, runs, 0, -1)
&& !Logic.moveQueueRun(RULES, st, c, runs, runs.length - 1, 1));
// A part-built entry must stand alone: its progress bar has to stay
// legible, and it is the one slot the engine actually spends on.
c.queue[0].progress = 5;
runs = Logic.collapseQueue(c);
check('a part-built entry does not join the run behind it',
runs[0].count === 1 && runs[0].item.progress === 5 && runs[1].count === 2,
runs.map((r) => `${r.item.id}x${r.count}`).join(','));
c.queue[0].progress = 0;
const rate = Logic.colonyBuildRate(RULES, st, c);
check('colonyBuildRate is finite and non-negative', Number.isFinite(rate) && rate >= 0, `${rate}`);
const eta = Logic.queueItemEta(RULES, st, c, c.queue[0]);
check('a funded queue quotes a whole number of turns',
rate <= 0 || (Number.isInteger(eta) && eta >= 1), `${eta}`);
// Nothing reaching construction must read as "never", not as 0 turns.
const idle = { ...c, sliders: { ...c.sliders, ships: 0, industry: 0, defense: 0 } };
check('an unfunded queue reports no ETA rather than an instant one',
Logic.queueItemEta(RULES, st, idle, c.queue[0]) === Infinity);
}
colony.sliders = restore.sliders;
colony.locked = restore.locked;
colony.queue = restore.queue;
// Run a long stretch and assert the colony stays healthy without any AI.
for (let i = 0; i < 200 * st.empires.length; i += 1) {
Logic.beginEmpireTurn(RULES, st, st.current);