diff --git a/assets/images/vega/planets.png b/assets/images/vega/planets.png new file mode 100644 index 0000000..1b63626 Binary files /dev/null and b/assets/images/vega/planets.png differ diff --git a/assets/images/vega/planets.psd b/assets/images/vega/planets.psd new file mode 100644 index 0000000..dc1d402 Binary files /dev/null and b/assets/images/vega/planets.psd differ diff --git a/assets/images/vega/world-arid-01.png b/assets/images/vega/world-arid-01.png new file mode 100644 index 0000000..36ae7ea Binary files /dev/null and b/assets/images/vega/world-arid-01.png differ diff --git a/assets/images/vega/world-barren-01.png b/assets/images/vega/world-barren-01.png new file mode 100644 index 0000000..a77c76f Binary files /dev/null and b/assets/images/vega/world-barren-01.png differ diff --git a/assets/images/vega/world-dead-01.png b/assets/images/vega/world-dead-01.png new file mode 100644 index 0000000..6e7c99a Binary files /dev/null and b/assets/images/vega/world-dead-01.png differ diff --git a/assets/images/vega/world-desert-01.png b/assets/images/vega/world-desert-01.png new file mode 100644 index 0000000..20b58f6 Binary files /dev/null and b/assets/images/vega/world-desert-01.png differ diff --git a/assets/images/vega/world-gas-giant-01.png b/assets/images/vega/world-gas-giant-01.png new file mode 100644 index 0000000..f1fbbd4 Binary files /dev/null and b/assets/images/vega/world-gas-giant-01.png differ diff --git a/assets/images/vega/world-inferno-01.png b/assets/images/vega/world-inferno-01.png new file mode 100644 index 0000000..fc333ee Binary files /dev/null and b/assets/images/vega/world-inferno-01.png differ diff --git a/assets/images/vega/world-minimal-01.png b/assets/images/vega/world-minimal-01.png new file mode 100644 index 0000000..cd1fa57 Binary files /dev/null and b/assets/images/vega/world-minimal-01.png differ diff --git a/assets/images/vega/world-ocean-01.png b/assets/images/vega/world-ocean-01.png new file mode 100644 index 0000000..e5c0dbc Binary files /dev/null and b/assets/images/vega/world-ocean-01.png differ diff --git a/assets/images/vega/world-radiated-01.png b/assets/images/vega/world-radiated-01.png new file mode 100644 index 0000000..d82321f Binary files /dev/null and b/assets/images/vega/world-radiated-01.png differ diff --git a/assets/images/vega/world-steppe-01.png b/assets/images/vega/world-steppe-01.png new file mode 100644 index 0000000..c1c99a4 Binary files /dev/null and b/assets/images/vega/world-steppe-01.png differ diff --git a/assets/images/vega/world-terran-01.png b/assets/images/vega/world-terran-01.png new file mode 100644 index 0000000..8ddf9b7 Binary files /dev/null and b/assets/images/vega/world-terran-01.png differ diff --git a/assets/images/vega/world-toxic-01.png b/assets/images/vega/world-toxic-01.png new file mode 100644 index 0000000..8fdb7b2 Binary files /dev/null and b/assets/images/vega/world-toxic-01.png differ diff --git a/assets/images/vega/world-tundra-01.png b/assets/images/vega/world-tundra-01.png new file mode 100644 index 0000000..e616518 Binary files /dev/null and b/assets/images/vega/world-tundra-01.png differ diff --git a/data/mastervega-artwork.json b/data/mastervega-artwork.json index 18e0a61..3e8d328 100644 --- a/data/mastervega-artwork.json +++ b/data/mastervega-artwork.json @@ -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, diff --git a/docs/mastervega-build-plan.md b/docs/mastervega-build-plan.md index ee30eb1..3c6a5c2 100644 --- a/docs/mastervega-build-plan.md +++ b/docs/mastervega-build-plan.md @@ -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 diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 212cdf0..5525afc 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -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' }, diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index dbc9017..daa8189 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -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 diff --git a/src/games/mastervega/VegaArt.js b/src/games/mastervega/VegaArt.js index b2dd0ae..a37e9cb 100644 --- a/src/games/mastervega/VegaArt.js +++ b/src/games/mastervega/VegaArt.js @@ -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]; diff --git a/src/games/mastervega/VegaColonyView.js b/src/games/mastervega/VegaColonyView.js new file mode 100644 index 0000000..77ee30d --- /dev/null +++ b/src/games/mastervega/VegaColonyView.js @@ -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; +} diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index a12288b..8cf2d3c 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -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]; diff --git a/src/games/mastervega/VegaScreens.js b/src/games/mastervega/VegaScreens.js index 6211d6e..4bf27b9 100644 --- a/src/games/mastervega/VegaScreens.js +++ b/src/games/mastervega/VegaScreens.js @@ -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; diff --git a/src/games/mastervega/VegaSidePanel.js b/src/games/mastervega/VegaSidePanel.js index 0203146..ed10c4b 100644 --- a/src/games/mastervega/VegaSidePanel.js +++ b/src/games/mastervega/VegaSidePanel.js @@ -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; diff --git a/src/games/mastervega/VegaSystemView.js b/src/games/mastervega/VegaSystemView.js index 15bb3ba..6ab51eb 100644 --- a/src/games/mastervega/VegaSystemView.js +++ b/src/games/mastervega/VegaSystemView.js @@ -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'); diff --git a/src/games/mastervega/sprites.md b/src/games/mastervega/sprites.md index 8e21cbc..80c8ed8 100644 --- a/src/games/mastervega/sprites.md +++ b/src/games/mastervega/sprites.md @@ -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--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/` diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index 0b54b79..c6161ca 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -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);