diff --git a/docs/mastervega-build-plan.md b/docs/mastervega-build-plan.md index b83b6f4..ee30eb1 100644 --- a/docs/mastervega-build-plan.md +++ b/docs/mastervega-build-plan.md @@ -31,8 +31,47 @@ 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`, -`VegaNebula.js`, `VegaSystemView.js`, `VegaCombatView.js`, `VegaScreens.js`, -`VegaArt.js`, `VegaFx.js`. +`VegaSidePanel.js`, `VegaNebula.js`, `VegaSystemView.js`, `VegaCombatView.js`, +`VegaScreens.js`, `VegaArt.js`, `VegaFx.js`. + +### The star map's command panel + +`VegaSidePanel.js` is MOO1's right-hand column: docked, hidden until something +is selected, three modes — **star** (class, worlds, colonies with their +production read-out, forces in orbit, and a *View System* button), **fleet** +(one row per ship stack with a −/+ count), **order** (distance, ETA, who is +waiting there, Accept / Cancel). + +The panel owns no state. `MasterOfVegaGame` decides what is selected and calls +`showStar` / `showFleet` / `showOrder`; the panel only reads the engine and +reports clicks back through callbacks. Selecting a fleet arms it: the next star +click is a destination, not an inspection — except the fleet's *own* star, which +drops the selection and shows the system, because otherwise a fleet could never +be deselected by clicking where it already is. + +**Splitting is the count selector, not a separate command.** `sendDetachment()` +sends the selection and leaves the rest behind as its own fleet. There is +deliberately no "split and stay" button: `consolidateFleets` merges idle fleets +in the same system at the start of the owner's next turn (trap 17), so a +detachment that does not leave immediately simply un-splits itself. A detachment +gets a fresh fleet id and therefore does **not** inherit the parent's fleet +leader. + +Three things in `sendDetachment` are load-bearing and each is verified in +section 4b: + +* The request is validated **against the actual stacks before totals are + compared** — otherwise asking for nine of a stack of three reads as "all of + them" and silently sends the whole fleet. +* Duplicate entries for one stack are **summed** before the availability check, + or 2 + 2 of a stack of 3 passes twice. +* A refusal is **total**: the reachability probe runs on a copy, so a rejected + order never leaves the fleet carved in two with the pieces going nowhere. + +The star map gained `blockPointer` (a drag or a wheel starting over the panel +must not pan or zoom the galaxy underneath it) and `onEmptyClick`, which uses +the `currentlyOver` list Phaser passes to the input plugin's `pointerup` — an +empty list is a genuine click on the void, which is how the panel is dismissed. State is plain JSON with the RNG cursor inside it (`state.rngState`, explicit-step mulberry32), so replaying a seed reproduces the galaxy, the @@ -176,13 +215,18 @@ wins spread across 8 of 10 species ## Verification ```bash -node tools/verifyMasterOfVega.js # 972 checks, ~60s -node tools/verifyMasterOfVega.js --quick # 971 checks, ~15s +node tools/verifyMasterOfVega.js # ~1004 checks, ~60s +node tools/verifyMasterOfVega.js --quick # 1003 checks, ~15s node tools/verifyMasterOfVega.js --games=50 # a deeper soak ``` -Eleven sections; section 2 runs the real procedural painters against a Proxy fake -canvas, section 10 is the self-play soak with invariants and a turn-time budget. +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. + +Note for 4b and anything like it: `addFleet` **merges into an existing fleet at +the same star**, so a test that adds ships to a homeworld is really testing "the +starting fleet plus mine" and its counts mean nothing. Clear `st.fleets` first. **Never browser-tested.** Everything above is engine- and Node-verified only. diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index d15bede..0888d06 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -20,6 +20,7 @@ import * as Logic from './VegaLogic.js'; import { runAITurn } from './VegaAI.js'; import VegaStarMap from './VegaStarMap.js'; +import VegaSidePanel from './VegaSidePanel.js'; import VegaFx from './VegaFx.js'; import { openSystemView } from './VegaSystemView.js'; import { openCombatView } from './VegaCombatView.js'; @@ -74,6 +75,7 @@ export default class MasterOfVegaGame extends Phaser.Scene { teardown() { resetSpeechQueue(); + this.panel?.destroy(); this.map?.destroy(); this.fx?.destroy(); this.music?.destroy?.(); @@ -465,7 +467,21 @@ export default class MasterOfVegaGame extends Phaser.Scene { this.map = new VegaStarMap(this, this.rules, this.state, this.art, { onStarClick: (idx) => this.onStarClick(idx), onFleetClick: (fleet) => this.onFleetClick(fleet), + onEmptyClick: () => this.clearSelection(), blockWheel: () => this.modalOpen, + 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 + // just decides what is selected and hands it over. + this.panel = new VegaSidePanel(this, this.rules, this.state, this.art, { + viewerIdx: this.state.humanIndex, + onViewSystem: (idx) => this.openSystemViewFor(idx), + onSelectFleet: (fleet) => this.onFleetClick(fleet), + onAcceptOrder: (fleet, toStar, ships) => this.confirmOrder(fleet, toStar, ships), + onCancelOrder: () => this.panel.showFleet(this.selectedFleet), + onSelectionLost: () => { this.selectedFleet = null; this.map?.setSelectedStar(-1); }, + onClose: () => this.clearSelection(), }); this.buildHud(); @@ -540,6 +556,7 @@ export default class MasterOfVegaGame extends Phaser.Scene { refreshAll() { this.map?.refresh(); + this.panel?.refresh(); this.refreshHud(); } @@ -556,31 +573,54 @@ export default class MasterOfVegaGame extends Phaser.Scene { }); } + // Clicking a star means one of two things, and which one depends entirely on + // whether a fleet of ours is selected: with a fleet in hand the star is a + // destination and the panel asks for confirmation, otherwise it is just + // something to look at. onStarClick(idx) { if (this.modalOpen || this.busy) return; + this.map.setSelectedStar(idx); - // A pending fleet order consumes the click instead of opening the system. - if (this.pendingOrder && this.selectedFleet) { - const fleet = this.selectedFleet; - this.pendingOrder = false; - this.selectedFleet = null; - if (Logic.canSendFleet(this.rules, this.state, fleet, idx)) { - Logic.sendFleet(this.rules, this.state, fleet, idx); - const eta = Logic.fleetEta(this.rules, this.state, fleet); - this.log(`Fleet away — ${this.state.galaxy.stars[idx].name} in ${eta} turns.`); - playSound(this, 'ta-rocket-1'); - this.refreshAll(); - } else { - this.log('Out of fuel range. Research propulsion, or plant a colony closer.'); - } + // Clicking the system the fleet is already sitting in is not a move order — + // it is a request to look at the place, so the selection is dropped and the + // star's own panel comes up (which lists the fleet, one click from getting + // it back). + if (this.selectedFleet && this.state.fleets.includes(this.selectedFleet) + && this.selectedFleet.starIdx >= 0 && this.selectedFleet.starIdx !== idx) { + this.panel.showOrder(idx); return; } - // Unexplored systems only get the map's hover tooltip (VegaStarMap owns - // it) — the system view stays closed until a scout has actually arrived. - const emp = this.state.empires[this.state.humanIndex]; - if (emp && !emp.explored[idx]) return; + this.selectedFleet = null; + this.panel.showStar(idx); + } + onFleetClick(fleet) { + if (this.modalOpen || this.busy) return; + // Someone else's fleet is not something we can give orders to, but the + // system it is sitting in is — clicking it reads as clicking that system, + // which with a fleet in hand is how an attack gets ordered. + if (fleet.empireIdx !== this.state.humanIndex) { + if (fleet.starIdx >= 0) this.onStarClick(fleet.starIdx); + return; + } + this.selectedFleet = fleet; + this.map.setSelectedStar(fleet.starIdx); + this.panel.showFleet(fleet); + } + + clearSelection() { + if (this.modalOpen) return; + this.selectedFleet = null; + this.map?.setSelectedStar(-1); + this.panel?.hide(); + } + + openSystemViewFor(idx) { + const emp = this.state.empires[this.state.humanIndex]; + // The system view stays closed until a scout has actually arrived; the + // panel is what an unexplored star has to say for itself. + if (emp && !emp.explored[idx]) return; this.openModal((done) => openSystemView(this, this.rules, this.state, idx, this.art, { viewerIdx: this.state.humanIndex, onChanged: () => this.refreshAll(), @@ -588,12 +628,22 @@ export default class MasterOfVegaGame extends Phaser.Scene { })); } - onFleetClick(fleet) { - if (this.modalOpen || this.busy) return; - if (fleet.empireIdx !== this.state.humanIndex) return; - this.selectedFleet = fleet; - this.log(`Fleet selected — click a star within range to send it.`); - this.pendingOrder = true; + // The panel's Accept. `ships` is the task force the player dialled in — the + // ships left out stay behind as a fleet of their own. + confirmOrder(fleet, toStar, ships) { + const dest = this.state.galaxy.stars[toStar]; + const sent = Logic.sendDetachment(this.rules, this.state, fleet, toStar, ships); + if (!sent) { + this.log('Out of fuel range. Research propulsion, or plant a colony closer.'); + return; + } + const eta = Logic.fleetEta(this.rules, this.state, sent); + this.log(`Fleet away — ${dest.name} in ${eta} turn${eta === 1 ? '' : 's'}.`); + playSound(this, 'ta-rocket-1'); + this.selectedFleet = null; + this.panel.hide(); + this.map.setSelectedStar(toStar); + this.refreshAll(); } // ----------------------------------------------------------- turn driver diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index b3e0081..a12288b 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -845,6 +845,124 @@ export function fleetEta(rules, state, fleet) { return Math.max(1, Math.ceil((fleet.total - fleet.progress) / speed)); } +// How long an order WOULD take, asked before it is given — the number the star +// map's order panel shows next to Accept. `ships` scopes it to a detachment the +// player has selected, whose speed is the slowest hull in the selection rather +// than the slowest in the whole fleet: leaving the colony ships at home is how +// you make a raid arrive this decade, so the ETA has to react to the selection. +export function etaTo(rules, state, fleet, toStar, ships = null) { + const from = fleet.starIdx >= 0 ? fleet.starIdx : fleet.toStar; + if (from < 0 || from === toStar) return 0; + const speed = fleetSpeed(rules, state, ships ? { ...fleet, ships } : fleet); + if (speed <= 0) return Infinity; + return Math.max(1, Math.ceil(parsecs(state.galaxy, from, toStar) / speed)); +} + +// Fold a [{hullId, mark, count}] request into one entry per stack, so asking +// for the same stack twice cannot slip past the "do you have this many?" check. +function normaliseTake(take) { + const out = new Map(); + for (const t of take ?? []) { + const n = Math.floor(t.count ?? 0); + if (n <= 0) continue; + const key = `${t.hullId}|${t.mark}`; + const prev = out.get(key); + if (prev) prev.count += n; + else out.set(key, { hullId: t.hullId, mark: t.mark, count: n }); + } + return [...out.values()]; +} + +const takeTotal = (take) => take.reduce((t, s) => t + s.count, 0); +const fleetTotal = (fleet) => fleet.ships.reduce((t, s) => t + Math.max(0, s.count), 0); + +/** + * Detach part of an idle fleet into a fleet of its own, parked at the same star. + * Returns the new fleet, or null if the request was not satisfiable. + * + * Two idle fleets in one system are merged again by consolidateFleets at the + * start of the owner's next turn, which is MOO1's rule and deliberate — so the + * only splitting the UI offers is "send some of these ships somewhere" + * (sendDetachment below), where the detachment leaves the same instant. + * + * The detachment gets a fresh fleet id and therefore does NOT inherit the + * parent's fleet leader; the leader stays with the fleet they were posted to. + */ +export function splitFleet(rules, state, fleet, take) { + if (!fleet || fleet.starIdx < 0 || fleet.toStar >= 0) return null; + const wanted = normaliseTake(take); + const taken = takeTotal(wanted); + if (taken <= 0 || taken >= fleetTotal(fleet)) return null; + + // Verify the whole request before mutating anything, or a request that is + // half-satisfiable leaves the fleet carved up and the order refused. + const pairs = []; + for (const t of wanted) { + const src = fleet.ships.find((s) => s.hullId === t.hullId && s.mark === t.mark); + if (!src || src.count < t.count) return null; + pairs.push([src, t.count]); + } + + const ships = []; + for (const [src, n] of pairs) { + src.count -= n; + ships.push({ ...src, count: n }); + } + fleet.ships = fleet.ships.filter((s) => s.count > 0); + + const detachment = { + id: state.nextFleetId += 1, + empireIdx: fleet.empireIdx, + starIdx: fleet.starIdx, + fromStar: -1, toStar: -1, progress: 0, total: 0, + ships, + }; + state.fleets.push(detachment); + return detachment; +} + +/** + * Send some of a fleet's ships to a star: the selection departs, the rest stay + * behind. Selecting everything is just sendFleet. Returns the fleet that is now + * under way, or null if the order was refused. + */ +export function sendDetachment(rules, state, fleet, toStar, take) { + if (!fleet) return null; + const wanted = normaliseTake(take); + const taken = takeTotal(wanted); + if (taken <= 0) return null; + // Check the request against the actual stacks BEFORE comparing totals, or + // asking for nine of a stack of three reads as "all of them" and quietly + // sends the whole fleet instead of refusing. + for (const t of wanted) { + const src = fleet.ships.find((s) => s.hullId === t.hullId && s.mark === t.mark); + if (!src || src.count < t.count) return null; + } + if (taken === fleetTotal(fleet)) { + return sendFleet(rules, state, fleet, toStar) ? fleet : null; + } + + // Ask whether the DETACHMENT could fly before splitting it out, so a refused + // order never leaves the fleet in pieces. + const probe = { ...fleet, ships: wanted.map((t) => ({ ...t })) }; + if (!canSendFleet(rules, state, probe, toStar)) return null; + + const detachment = splitFleet(rules, state, fleet, wanted); + if (!detachment) return null; + if (!sendFleet(rules, state, detachment, toStar)) { + // Unreachable given the probe, but a half-split fleet would be a silent + // loss of ships, so put them back rather than trust the reasoning. + for (const s of detachment.ships) { + const m = fleet.ships.find((x) => x.hullId === s.hullId && x.mark === s.mark); + if (m) m.count += s.count; + else fleet.ships.push({ ...s }); + } + state.fleets = state.fleets.filter((f) => f !== detachment); + return null; + } + return detachment; +} + function moveFleets(rules, state, e) { for (const f of empireFleets(state, e)) { if (f.toStar < 0) continue; diff --git a/src/games/mastervega/VegaSidePanel.js b/src/games/mastervega/VegaSidePanel.js new file mode 100644 index 0000000..0203146 --- /dev/null +++ b/src/games/mastervega/VegaSidePanel.js @@ -0,0 +1,635 @@ +// Master of Vega — the star map's right-hand command panel. +// +// MOO1 puts every decision you can make about the thing you just clicked into +// one docked column, and shows nothing at all when nothing is selected. This is +// that panel. It has three modes: +// +// star — read-only summary of a system: class, worlds, colonies, garrisons, +// plus the one action a system offers ("View System"). +// fleet — the ships in a selected fleet, each stack with a count you can dial +// down. The counts ARE the split: what you leave at zero stays home. +// order — a destination has been clicked. Distance, ETA and who is waiting +// there, behind an explicit Accept / Cancel. +// +// It owns no game state. The scene decides what is selected and calls showStar / +// showFleet / showOrder; the panel only ever reads the engine and reports clicks +// back through `cb`. That is what keeps the turn driver in MasterOfVegaGame. +// +// Layout note: this is one container positioned at the panel's top-left with +// every child in LOCAL coordinates. Interactive children are Rectangles and +// Buttons, never bare Containers — a Container's hit area is locked to its +// centre and is the classic way to get an unclickable widget here. + +import * as Phaser from 'phaser'; +import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { markNumeral } from './VegaRules.js'; +import { parsecs } from './VegaGalaxyGen.js'; +import { + coloniesAt, fleetsAt, empireDesign, fleetPower, fleetSpeed, fleetEta, etaTo, + colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories, + colonyDefenseCap, habitableForEmpire, reachableStars, atWar, +} from './VegaLogic.js'; +import { FONT, D } from './VegaScreens.js'; + +const W = 400; +const PAD = 18; +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; + this.rules = rules; + this.state = state; + this.art = art; + this.cb = cb; + this.viewerIdx = cb.viewerIdx ?? state.humanIndex; + + this.x0 = GAME_WIDTH - W - 16; + this.y0 = 78; + this.h = GAME_HEIGHT - this.y0 - 24; + + this.mode = null; + this.starIdx = -1; + this.fleet = null; + this.orderStar = -1; + this.sel = []; + + // Parked off-screen right; open() slides it in. + this.root = scene.add.container(this.x0 + W + 60, this.y0) + .setDepth(D.hud + 1).setVisible(false); + + const panel = scene.add.rectangle(0, 0, W, this.h, PANEL, 0.96).setOrigin(0, 0); + panel.setStrokeStyle(1.5, ACCENT, 0.55); + this.root.add(panel); + + // Same corner ticks as modalShell, so the panel and the modals read as one + // instrument rather than two different dialog systems. + const ticks = scene.add.graphics(); + ticks.lineStyle(2.5, ACCENT, 0.9); + const t = 22; + for (const [cx, cy, dx, dy] of [ + [0, 0, 1, 1], [W, 0, -1, 1], [0, this.h, 1, -1], [W, this.h, -1, -1], + ]) { + ticks.lineBetween(cx, cy, cx + dx * t, cy); + ticks.lineBetween(cx, cy, cx, cy + dy * t); + } + this.root.add(ticks); + + this.titleText = scene.add.text(PAD, 16, '', { + fontFamily: FONT, fontSize: '25px', color: '#cfe8ff', + }); + this.root.add(this.titleText); + this.subText = scene.add.text(PAD, 48, '', { + fontFamily: FONT, fontSize: '14px', color: '#7f97b3', + }); + this.root.add(this.subText); + this.root.add(scene.add.rectangle(PAD, 72, COL, 1, ACCENT, 0.4).setOrigin(0, 0)); + + this.root.add(new Button(scene, W - 32, 30, '✕', () => { + this.cb.onClose?.(); + }, { width: 38, height: 34, fontSize: 18, variant: 'ghost' })); + + this.body = scene.add.container(0, 0); + this.root.add(this.body); + } + + // Used by the star map to keep a drag or a wheel over the panel from panning + // and zooming the galaxy underneath it. + containsPoint(x, y) { + if (!this.root.visible) return false; + return x >= this.root.x && x <= this.root.x + W + && y >= this.y0 && y <= this.y0 + this.h; + } + + // ------------------------------------------------------------------ modes + + showStar(starIdx) { + this.mode = 'star'; + this.starIdx = starIdx; + this.rebuild(); + this.open(); + } + + showFleet(fleet) { + this.mode = 'fleet'; + this.fleet = fleet; + this.syncSelection(); + this.rebuild(); + this.open(); + } + + showOrder(toStarIdx) { + if (!this.fleet) return; + this.mode = 'order'; + this.orderStar = toStarIdx; + this.rebuild(); + this.open(); + } + + hide() { + if (!this.root.visible) return; + this.mode = null; + this.fleet = null; + this.starIdx = -1; + // Kill the opening tween first: two tweens on the same x fight, and the + // loser here is a hide that completes after a reopen and leaves the panel + // invisible but still selected. + this.scene.tweens.killTweensOf(this.root); + this.scene.tweens.add({ + targets: this.root, + x: this.x0 + W + 60, + duration: 160, + ease: 'Sine.easeIn', + onComplete: () => this.root.setVisible(false), + }); + } + + open() { + if (this.root.visible && Math.abs(this.root.x - this.x0) < 0.5) return; + if (!this.root.visible) { + this.root.setVisible(true); + this.root.x = this.x0 + W + 60; + } + this.scene.tweens.killTweensOf(this.root); + this.scene.tweens.add({ + targets: this.root, x: this.x0, duration: 200, ease: 'Cubic.easeOut', + }); + } + + /** + * Re-read the engine. Called after every turn and every action, so a fleet + * that was destroyed, merged or that arrived somewhere does not leave a stale + * panel behind: an unusable selection reports back and closes. + */ + refresh() { + if (!this.mode) return; + if ((this.mode === 'fleet' || this.mode === 'order') + && (!this.fleet || !this.state.fleets.includes(this.fleet))) { + this.cb.onSelectionLost?.(); + this.hide(); + return; + } + // An order is quoted from where the fleet stands; if it is no longer + // standing anywhere, there is nothing left to confirm. + if (this.mode === 'order' && this.fleet.starIdx < 0) this.mode = 'fleet'; + if (this.mode !== 'star') this.syncSelection(); + this.rebuild(); + } + + destroy() { this.root.destroy(); } + + // -------------------------------------------------------- text primitives + + setHead(title, sub) { + this.titleText.setText(String(title).toUpperCase()); + this.subText.setText(sub ?? ''); + } + + line(text, opts = {}) { + const { + size = 15, color = '#c8dcf0', x = PAD, gap = 4, wrap = COL - (x - PAD), + } = opts; + const t = this.scene.add.text(x, this.y, text, { + fontFamily: FONT, fontSize: `${size}px`, color, lineSpacing: 3, + wordWrap: { width: wrap }, + }); + this.body.add(t); + this.y += t.height + gap; + return t; + } + + /** Is there room for another `px` of content above the action buttons? */ + room(px) { return this.y + px < this.h - 90; } + + heading(text) { + this.y += 10; + this.line(text.toUpperCase(), { size: 13, color: '#6f8aa3', gap: 2 }); + this.body.add(this.scene.add.rectangle(PAD, this.y, COL, 1, ACCENT, 0.18).setOrigin(0, 0)); + this.y += 8; + } + + /** A framed one-line callout — used for hints and refusals. */ + callout(text, colour) { + const t = this.scene.add.text(PAD + 10, this.y + 8, text, { + fontFamily: FONT, fontSize: '14px', color: colour, wordWrap: { width: COL - 20 }, + }); + const box = this.scene.add.rectangle(PAD, this.y, COL, t.height + 16, + Phaser.Display.Color.HexStringToColor(colour).color, 0.1).setOrigin(0, 0); + box.setStrokeStyle(1, Phaser.Display.Color.HexStringToColor(colour).color, 0.4); + this.body.add(box); + this.body.add(t); + this.y += t.height + 22; + } + + /** A small square push-button; Button is too heavy for a −/+ pair. */ + tinyButton(x, y, size, label, fn, enabled = true) { + const r = this.scene.add.rectangle(x, y, size, size, enabled ? 0x16253c : 0x101a29) + .setStrokeStyle(1, ACCENT, enabled ? 0.55 : 0.18); + const t = this.scene.add.text(x, y - 1, label, { + fontFamily: FONT, fontSize: '18px', color: enabled ? '#cfe8ff' : '#3c4c60', + }).setOrigin(0.5); + this.body.add(r); + this.body.add(t); + if (!enabled) return; + r.setInteractive({ useHandCursor: true }); + r.on('pointerover', () => r.setFillStyle(0x22405f)); + r.on('pointerout', () => r.setFillStyle(0x16253c)); + r.on('pointerup', fn); + } + + /** Full-width action button parked at the bottom of the column. */ + action(label, fn, opts = {}) { + const { enabled = true, width = COL, x = PAD + COL / 2, variant = 'solid' } = opts; + const b = new Button(this.scene, x, this.y + 24, label, enabled ? fn : null, + { width, height: 48, fontSize: 20, variant }); + if (!enabled) b.setEnabled?.(false); + this.body.add(b); + this.y += 60; + return b; + } + + // ------------------------------------------------------------ the rebuild + + rebuild() { + this.body.removeAll(true); + this.y = 90; + if (this.mode === 'star') this.buildStar(); + else if (this.mode === 'fleet') this.buildFleet(); + else if (this.mode === 'order') this.buildOrder(); + } + + get viewer() { + return this.viewerIdx >= 0 ? this.state.empires[this.viewerIdx] : null; + } + + habitableCount(star) { + const viewer = this.viewer; + return viewer + ? star.planets.filter((p, orbit) => + habitableForEmpire(this.rules, this.state, viewer.idx, star.idx, orbit)).length + : star.planets.filter((p) => this.rules.planetTypes[p.typeId].colonizable).length; + } + + // ---------------------------------------------------------------- STAR + + buildStar() { + const { rules, state } = this; + const star = state.galaxy.stars[this.starIdx]; + const cls = rules.starClasses[star.classId]; + const viewer = this.viewer; + const explored = !viewer || viewer.explored[this.starIdx]; + + if (!explored) { + this.setHead('Unexplored', `${cls.name} star`); + this.line(cls.desc, { size: 14, color: '#8fa8c0' }); + this.y += 8; + this.callout('Send a scout ship to chart this system.', '#e0b08a'); + return; + } + + this.setHead(star.name, `${cls.name} star`); + this.line(cls.desc, { size: 13, color: '#6f8aa3' }); + this.y += 6; + this.line(star.planets.length + ? `${star.planets.length} world${star.planets.length === 1 ? '' : 's'} · ${this.habitableCount(star)} habitable` + : 'No planets in this system.', { size: 15, color: '#9fb6cc' }); + + const colonies = coloniesAt(state, this.starIdx); + if (colonies.length) { + this.heading('Colonies'); + for (const colony of colonies) this.colonyBlock(colony); + } + + // A crowded system (several colonies, six worlds, a stack of fleets) can + // out-run the column. The panel does not scroll, so the lower sections give + // way rather than spilling out past the frame. + if (star.planets.length && this.room(40 + star.planets.length * 20)) { + this.heading('Worlds'); + star.planets.forEach((planet, orbit) => { + const type = rules.planetTypes[planet.typeId]; + const settled = colonies.some((c) => c.orbit === orbit); + const open = !settled && viewer + && habitableForEmpire(rules, state, viewer.idx, this.starIdx, orbit); + this.line( + `${ORBIT[orbit] ?? orbit + 1} · ${type.name} · ${rules.planetSizes[planet.sizeId]?.name ?? ''}` + + ` · ${rules.richness[planet.richId]?.name ?? ''}`, + { size: 13, color: open ? '#7fd8a0' : (settled ? '#c8dcf0' : '#6f8aa3'), gap: 2 }, + ); + }); + } + + // The system is explored, so its orbits are visible — the same rule the map + // uses to decide which fleet markers to draw. + const fleets = fleetsAt(state, this.starIdx); + if (fleets.length && this.room(40 + fleets.length * 22)) { + this.heading('Forces in orbit'); + for (const fleet of fleets) this.fleetRow(fleet); + } + + // The system view is the only action a star itself offers; everything else + // there (sliders, build queue, invasion) lives inside it. + this.y = Math.max(this.y + 10, this.h - 76); + this.action('View System', () => this.cb.onViewSystem?.(this.starIdx)); + } + + colonyBlock(colony) { + const { rules, state } = this; + const owner = state.empires[colony.empireIdx]; + const mine = colony.empireIdx === this.viewerIdx; + const planet = state.galaxy.stars[colony.starIdx].planets[colony.orbit]; + + this.line( + `${owner.name}${colony.capital ? ' — capital' : ''}` + + ` · ${ORBIT[colony.orbit] ?? colony.orbit + 1} ${rules.planetTypes[planet.typeId].name}`, + { size: 16, color: owner.color, gap: 2 }, + ); + + if (!mine) { + // What a rival tells you about their colony is what you can see from + // orbit: how many people live there, and whether they are shooting. + this.line(`Population ${colony.pop.toFixed(1)}`, { size: 14, color: '#9fb6cc', gap: 2 }); + if (this.viewerIdx >= 0 && atWar(state, this.viewerIdx, colony.empireIdx)) { + this.line('At war — bombard and invade from the system view.', + { size: 13, color: '#e08a8a' }); + } else { + this.y += 6; + } + return; + } + + const prod = colonyProduction(rules, state, colony); + this.line( + `Population ${colony.pop.toFixed(1)} / ${colonyMaxPop(rules, state, colony)}\n` + + `Factories ${Math.floor(effectiveFactories(rules, state, colony))} / ${colonyFactoryCap(rules, state, colony)}\n` + + `Output ${prod.toFixed(1)} BC per turn\n` + + `Defences ${Math.round(colony.defenseHp)} / ${colonyDefenseCap(rules, state, colony)}` + + (colony.waste > 0.5 ? `\nUncleaned waste ${colony.waste.toFixed(1)}` : ''), + { size: 14, color: '#c8dcf0', gap: 4 }, + ); + + if (colony.queue.length) { + const item = colony.queue[0]; + const name = item.kind === 'building' + ? rules.buildings[item.id].name + : empireDesign(rules, state, colony.empireIdx, item.id).name; + this.line(`Building ${name}`, { size: 13, color: '#ffd88a' }); + } else { + this.line('Idle — output spills into research.', { size: 13, color: '#6f8aa3' }); + } + } + + /** One clickable line per fleet in a system. */ + fleetRow(fleet) { + const { rules, state } = this; + const emp = state.empires[fleet.empireIdx]; + const mine = fleet.empireIdx === this.viewerIdx; + const ships = fleet.ships.reduce((t, s) => t + s.count, 0); + const label = `${mine ? 'Your fleet' : emp.name} — ${ships} ship${ships === 1 ? '' : 's'}` + + ` · power ${fleetPower(rules, state, fleet)}`; + const t = this.line(label, { size: 14, color: mine ? '#9fd8ff' : emp.color, gap: 3 }); + if (!mine) return; + t.setInteractive({ useHandCursor: true }); + t.on('pointerover', () => t.setColor('#ffffff')); + t.on('pointerout', () => t.setColor('#9fd8ff')); + t.on('pointerup', () => this.cb.onSelectFleet?.(fleet)); + } + + // --------------------------------------------------------------- FLEET + + /** + * Rebuild the per-stack selection, keeping whatever the player had already + * dialled in where the stack still exists. Immobile hulls (star bases) are + * excluded outright rather than shown at zero: they defend the system they + * were built in and can never be part of a move order. + */ + syncSelection() { + const fleet = this.fleet; + if (!fleet) { this.sel = []; return; } + const prev = new Map(this.sel.map((s) => [`${s.hullId}|${s.mark}`, s.count])); + this.sel = []; + for (const s of fleet.ships) { + if (s.count <= 0) continue; + const d = empireDesign(this.rules, this.state, fleet.empireIdx, s.hullId); + if (d.immobile) continue; + const key = `${s.hullId}|${s.mark}`; + const want = prev.has(key) ? Math.min(prev.get(key), s.count) : s.count; + this.sel.push({ hullId: s.hullId, mark: s.mark, count: want, max: s.count }); + } + } + + selectedShips() { + return this.sel.filter((s) => s.count > 0).map((s) => ({ ...s })); + } + + designName(hullId, mark) { + const d = empireDesign(this.rules, this.state, this.fleet.empireIdx, hullId); + if (d.mark === mark || d.hull.space <= 0) return d.name; + // A stack that has not been refitted yet keeps its own Mark in its name. + return `${d.hull.name} Mark ${markNumeral(mark)}`; + } + + buildFleet() { + const { rules, state } = this; + const fleet = this.fleet; + const total = fleet.ships.reduce((t, s) => t + s.count, 0); + + if (fleet.starIdx < 0) { + const to = state.galaxy.stars[fleet.toStar]; + const from = state.galaxy.stars[fleet.fromStar]; + this.setHead('Fleet', 'Under way'); + this.line(`${from?.name ?? '?'} → ${to?.name ?? '?'}`, { size: 17, color: '#cfe8ff' }); + this.etaLine(fleetEta(rules, state, fleet)); + this.shipList(); + this.y += 8; + this.callout('A fleet in transit cannot be redirected until it arrives.', '#e0b08a'); + return; + } + + const star = state.galaxy.stars[fleet.starIdx]; + this.setHead('Fleet', `In orbit at ${star.name}`); + this.line(`${total} ship${total === 1 ? '' : 's'} · power ${fleetPower(rules, state, fleet)}`, + { size: 15, color: '#9fb6cc' }); + + this.heading('Task force'); + if (!this.sel.length) { + this.line('No mobile ships — this force holds the system.', + { size: 14, color: '#e08a8a' }); + } else { + this.line('Dial a stack down to leave those ships behind.', + { size: 13, color: '#6f8aa3' }); + this.y += 4; + this.sel.forEach((s, i) => this.stackRow(s, i)); + this.y += 6; + this.selectionSummary(); + } + + // Garrison hulls, listed so their absence from the task force is explained + // rather than looking like ships that went missing. + const garrison = fleet.ships.filter((s) => s.count > 0 + && empireDesign(rules, state, fleet.empireIdx, s.hullId).immobile); + if (garrison.length) { + this.heading('Garrison'); + for (const s of garrison) { + this.line(`${s.count} × ${this.designName(s.hullId, s.mark)} — holds this system`, + { size: 13, color: '#8fa8c0', gap: 2 }); + } + } + + this.y = Math.max(this.y + 12, this.h - 138); + if (this.sel.length) { + this.callout('Click a destination star to plot a course.', '#6fc4ff'); + } + this.action('Done', () => this.cb.onClose?.(), { variant: 'ghost' }); + } + + /** A ship stack with a −/+ count selector. */ + stackRow(s, index) { + const rowY = this.y; + const name = this.scene.add.text(PAD, rowY, `${this.designName(s.hullId, s.mark)}`, { + fontFamily: FONT, fontSize: '15px', color: s.count > 0 ? '#c8dcf0' : '#5d7085', + wordWrap: { width: COL - 130 }, + }); + this.body.add(name); + + const boxSize = 26; + const right = PAD + COL; + const set = (v) => { + this.sel[index].count = Phaser.Math.Clamp(v, 0, s.max); + this.rebuild(); + }; + this.tinyButton(right - boxSize / 2, rowY + 12, boxSize, '+', () => set(s.count + 1), + s.count < s.max); + this.tinyButton(right - boxSize * 2.9, rowY + 12, boxSize, '–', () => set(s.count - 1), + s.count > 0); + const count = this.scene.add.text(right - boxSize * 1.7, rowY + 11, `${s.count} / ${s.max}`, { + fontFamily: FONT, fontSize: '15px', color: s.count > 0 ? '#e8f4ff' : '#5d7085', + }).setOrigin(0.5); + this.body.add(count); + + this.y = rowY + Math.max(name.height, boxSize) + 8; + } + + selectionSummary() { + const { rules, state } = this; + const ships = this.selectedShips(); + const n = ships.reduce((t, s) => t + s.count, 0); + if (!n) { + this.line('Nothing selected.', { size: 14, color: '#e08a8a' }); + return; + } + const probe = { ...this.fleet, ships }; + const speed = fleetSpeed(rules, state, probe); + this.line( + `Selected ${n} ship${n === 1 ? '' : 's'} · power ${fleetPower(rules, state, probe)}` + + ` · speed ${speed}`, + { size: 14, color: '#9fd8ff' }, + ); + const left = this.fleet.ships.reduce((t, s) => t + s.count, 0) - n; + if (left > 0) { + this.line(`${left} ship${left === 1 ? '' : 's'} would stay behind as a separate fleet.`, + { size: 13, color: '#6f8aa3' }); + } + } + + etaLine(eta) { + this.line(Number.isFinite(eta) ? `ETA ${eta} turn${eta === 1 ? '' : 's'}` : 'ETA — unreachable', { + size: 26, color: Number.isFinite(eta) ? '#7fd8a0' : '#e08a8a', + }); + } + + shipList() { + this.heading('Ships'); + for (const s of this.fleet.ships) { + if (s.count <= 0) continue; + this.line(`${s.count} × ${this.designName(s.hullId, s.mark)}`, + { size: 14, color: '#c8dcf0', gap: 2 }); + } + } + + // --------------------------------------------------------------- ORDER + + buildOrder() { + const { rules, state } = this; + const fleet = this.fleet; + const dest = state.galaxy.stars[this.orderStar]; + const from = state.galaxy.stars[fleet.starIdx]; + const viewer = this.viewer; + const explored = !viewer || viewer.explored[this.orderStar]; + + this.setHead(explored ? dest.name : 'Unexplored', 'Fleet order'); + this.line(`${from?.name ?? '?'} → ${explored ? dest.name : 'unknown system'}`, + { size: 17, color: '#cfe8ff' }); + + const ships = this.selectedShips(); + const n = ships.reduce((t, s) => t + s.count, 0); + const distance = parsecs(state.galaxy, fleet.starIdx, this.orderStar); + const inRange = !!reachableStars(rules, state, fleet.empireIdx)[this.orderStar]; + const sameStar = fleet.starIdx === this.orderStar; + const eta = n ? etaTo(rules, state, fleet, this.orderStar, ships) : Infinity; + + this.line(`${distance.toFixed(1)} parsecs`, { size: 14, color: '#8fa8c0' }); + this.y += 4; + this.etaLine(eta); + + this.heading('Task force'); + if (!n) { + this.line('No ships selected.', { size: 15, color: '#e08a8a' }); + } else { + for (const s of ships) { + this.line(`${s.count} × ${this.designName(s.hullId, s.mark)}`, + { size: 14, color: '#c8dcf0', gap: 2 }); + } + this.y += 4; + this.line(`Power ${fleetPower(rules, state, { ...fleet, ships })}`, + { size: 14, color: '#9fd8ff' }); + } + + // What is waiting there, as far as the player is allowed to know. + if (explored) { + const colonies = coloniesAt(state, this.orderStar); + const hostiles = colonies.filter((c) => c.empireIdx !== this.viewerIdx); + const enemyFleets = fleetsAt(state, this.orderStar) + .filter((f) => f.empireIdx !== this.viewerIdx); + if (hostiles.length || enemyFleets.length) { + this.heading('Opposition'); + for (const c of hostiles) { + const owner = state.empires[c.empireIdx]; + const war = this.viewerIdx >= 0 && atWar(state, this.viewerIdx, c.empireIdx); + this.line(`${owner.name} colony — pop ${c.pop.toFixed(1)}${war ? ' — at war' : ''}`, + { size: 14, color: war ? '#e08a8a' : owner.color, gap: 2 }); + } + for (const f of enemyFleets) { + const owner = state.empires[f.empireIdx]; + const count = f.ships.reduce((t, s) => t + s.count, 0); + this.line(`${owner.name} fleet — ${count} ship${count === 1 ? '' : 's'}`, + { size: 14, color: owner.color, gap: 2 }); + } + } + } + + let refusal = null; + if (sameStar) refusal = 'The fleet is already in this system.'; + else if (!n) refusal = 'Select at least one ship to send.'; + else if (!inRange) refusal = 'Beyond fuel range. Research propulsion, or plant a colony closer.'; + else if (!Number.isFinite(eta)) refusal = 'Nothing in this task force can move.'; + + this.y = Math.max(this.y + 12, this.h - 150); + if (refusal) this.callout(refusal, '#e08a8a'); + + const half = (COL - 12) / 2; + const btnY = this.y; + const accept = new Button(this.scene, PAD + half / 2, btnY + 24, 'Accept', + refusal ? null : () => this.cb.onAcceptOrder?.(this.fleet, this.orderStar, this.selectedShips()), + { width: half, height: 48, fontSize: 20 }); + if (refusal) accept.setEnabled?.(false); + this.body.add(accept); + this.body.add(new Button(this.scene, PAD + half * 1.5 + 12, btnY + 24, 'Cancel', + () => this.cb.onCancelOrder?.(), { width: half, height: 48, fontSize: 20, variant: 'ghost' })); + this.y = btnY + 60; + } +} diff --git a/src/games/mastervega/VegaStarMap.js b/src/games/mastervega/VegaStarMap.js index 0d9997e..292a1e9 100644 --- a/src/games/mastervega/VegaStarMap.js +++ b/src/games/mastervega/VegaStarMap.js @@ -101,6 +101,10 @@ export default class VegaStarMap { this.starLayer = scene.add.container(0, 0); this.root.add(this.starLayer); + // The selection reticle sits above the stars and below the fleet markers, + // so a fleet parked over its own star is never hidden by its own highlight. + this.selGfx = scene.add.graphics(); + this.root.add(this.selGfx); this.fleetLayer = scene.add.container(0, 0); this.root.add(this.fleetLayer); this.labelLayer = scene.add.container(0, 0); @@ -426,12 +430,40 @@ export default class VegaStarMap { } } + // ---------------------------------------------------------- selection + + /** Ring the system the command panel is currently talking about. -1 clears. */ + setSelectedStar(idx) { + this.selectedStar = idx ?? -1; + this.drawSelection(); + } + + drawSelection() { + const g = this.selGfx; + g.clear(); + const s = this.starSprites?.[this.selectedStar]; + if (!s) return; + // Four arcs rather than a circle: a broken reticle stays legible on top of + // an ownership ring, which a second full circle would not. + const r = s.cls.radius * 2.2 + 20; + const spin = (this.time / 1000) * 0.5; + g.lineStyle(2, 0x9fd8ff, 0.95); + for (let i = 0; i < 4; i += 1) { + const a = spin + (i * Math.PI) / 2; + g.beginPath(); + g.arc(s.star.x, s.star.y, r, a, a + 0.7); + g.strokePath(); + } + } + // ------------------------------------------------------------- animation update(_time, delta) { this.time += delta; const t = this.time / 1000; + if (this.selectedStar >= 0) this.drawSelection(); + for (const s of this.starSprites) { if (s.cls.special === 'pulsar') { s.body.setRotation(t * 1.4); @@ -526,6 +558,9 @@ export default class VegaStarMap { let originY = 0; scene.input.on('pointerdown', (p) => { + // A drag that starts on the command panel scrolls nothing — otherwise + // dialling a ship count would pan the galaxy out from under the panel. + if (this.cb.blockPointer?.(p)) return; dragging = true; this.dragged = false; startX = p.x; startY = p.y; @@ -542,9 +577,17 @@ export default class VegaStarMap { this.root.y = originY + dy; this.clampPan(); }); - scene.input.on('pointerup', () => { dragging = false; }); + // Phaser emits the game objects' own pointerup handlers first and then this + // one, with everything under the cursor in `objs` — so an empty list is a + // genuine click on the void, which is how the panel gets dismissed. + scene.input.on('pointerup', (p, objs) => { + dragging = false; + if (!this.dragged && (!objs || objs.length === 0) && !this.cb.blockPointer?.(p)) { + this.cb.onEmptyClick?.(p); + } + }); scene.input.on('wheel', (p, _objs, _dx, dy) => { - if (this.cb.blockWheel?.(p)) return; + if (this.cb.blockWheel?.(p) || this.cb.blockPointer?.(p)) return; this.applyZoom(this.zoomIndex + (dy > 0 ? -1 : 1), p.x, p.y); }); } diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index 9df80b5..0b54b79 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -480,6 +480,179 @@ section('4. Ship Marks'); Ships.refitCost(RULES, known, 'cruiser', Ships.MAX_MARK, RULES.species.human.traits) === 0); } +// --------------------------------------------------------------------------- +section('4b. Fleet orders and detachments'); +// --------------------------------------------------------------------------- +{ + // The star map's command panel gives orders through sendDetachment: the ships + // the player dialled in depart, the rest stay behind. Ships are the one thing + // in the game that cannot be conjured or lost silently, so every path here is + // checked for conservation as well as for doing the right thing. + const mk = () => { + const st = Logic.createGame(RULES, { + sizeId: 'medium', shapeId: 'spiral', seed: 77, difficultyId: 'normal', + speciesIds: ['human', 'kkrix', 'lithox'], humanIndex: 0, + }); + st.rules = RULES; + // addFleet MERGES into an existing fleet at the same star, so the starting + // fleet has to go or every case below is really testing "starting fleet + // plus mine" and the counts stop meaning anything. + st.fleets = []; + return st; + }; + const countShips = (st, e) => Logic.empireFleets(st, e) + .reduce((t, f) => t + f.ships.reduce((n, s) => n + s.count, 0), 0); + // A star this empire can actually reach, other than the one it is sitting on. + const targetFor = (st, fleet) => Object.keys(Logic.reachableStars(RULES, st, fleet.empireIdx)) + .map(Number).find((i) => i !== fleet.starIdx); + + { + const st = mk(); + const home = st.galaxy.homeIdx[0]; + const fleet = Logic.addFleet(RULES, st, 0, home, + [{ hullId: 'frigate', mark: 1, count: 4 }, { hullId: 'scout', mark: 1, count: 2 }]); + const before = countShips(st, 0); + const to = targetFor(st, fleet); + + // ETA is asked BEFORE the order is given, and must react to the selection: + // a scout is faster than a frigate, so sending scouts alone is never slower. + const etaAll = Logic.etaTo(RULES, st, fleet, to); + const etaScouts = Logic.etaTo(RULES, st, fleet, to, [{ hullId: 'scout', mark: 1, count: 2 }]); + check('pre-order ETA is a positive whole number of turns', + Number.isInteger(etaAll) && etaAll >= 1, `${etaAll}`); + check('a faster detachment never arrives later than the whole fleet', + etaScouts <= etaAll, `${etaScouts} vs ${etaAll}`); + check('ETA to the star you are already at is zero', + Logic.etaTo(RULES, st, fleet, fleet.starIdx) === 0); + + const sent = Logic.sendDetachment(RULES, st, fleet, to, [{ hullId: 'scout', mark: 1, count: 2 }]); + check('a detachment departs', !!sent && sent.toStar === to); + check('the detachment is a NEW fleet', sent !== fleet); + check('the detachment carries exactly what was asked for', + sent.ships.length === 1 && sent.ships[0].hullId === 'scout' && sent.ships[0].count === 2); + check('the rest of the fleet stays put', fleet.starIdx === home && fleet.toStar < 0); + check('the parent fleet keeps the ships that were left behind', + fleet.ships.reduce((t, s) => t + s.count, 0) === 4); + check('splitting conserves ships', countShips(st, 0) === before, `${countShips(st, 0)} vs ${before}`); + check('the committed ETA matches the one the panel quoted', + Logic.fleetEta(RULES, st, sent) === etaScouts, + `${Logic.fleetEta(RULES, st, sent)} vs ${etaScouts}`); + } + + { + // Selecting everything is a plain move — no stray empty fleet left behind. + const st = mk(); + const home = st.galaxy.homeIdx[0]; + const fleet = Logic.addFleet(RULES, st, 0, home, [{ hullId: 'frigate', mark: 1, count: 3 }]); + const fleetsBefore = st.fleets.length; + const to = targetFor(st, fleet); + const sent = Logic.sendDetachment(RULES, st, fleet, to, [{ hullId: 'frigate', mark: 1, count: 3 }]); + check('selecting the whole fleet moves that fleet itself', sent === fleet); + check('a whole-fleet move creates no extra fleet', st.fleets.length === fleetsBefore); + } + + { + // Refusals must be total: a rejected order leaves the fleet untouched + // rather than carved in two with the pieces going nowhere. + const st = mk(); + const home = st.galaxy.homeIdx[0]; + const fleet = Logic.addFleet(RULES, st, 0, home, [{ hullId: 'frigate', mark: 1, count: 3 }]); + const before = countShips(st, 0); + const fleetsBefore = st.fleets.length; + const reach = Logic.reachableStars(RULES, st, 0); + const far = st.galaxy.stars.findIndex((s) => !reach[s.idx]); + if (far >= 0) { + check('an out-of-range order is refused', + Logic.sendDetachment(RULES, st, fleet, far, [{ hullId: 'frigate', mark: 1, count: 1 }]) === null); + check('a refused order leaves the fleet whole', + st.fleets.length === fleetsBefore && countShips(st, 0) === before); + } + const to = targetFor(st, fleet); + check('an empty selection is refused', + Logic.sendDetachment(RULES, st, fleet, to, []) === null); + check('asking for more ships than exist is refused', + Logic.sendDetachment(RULES, st, fleet, to, [{ hullId: 'frigate', mark: 1, count: 9 }]) === null); + check('asking for a stack that is not there is refused', + Logic.sendDetachment(RULES, st, fleet, to, [{ hullId: 'battleship', mark: 1, count: 1 }]) === null); + check('every refusal conserved the fleet', + st.fleets.length === fleetsBefore && countShips(st, 0) === before); + } + + { + // The same stack named twice must be summed, not checked twice against the + // same stock — otherwise 2 + 2 of a stack of 3 would both pass. + const st = mk(); + const home = st.galaxy.homeIdx[0]; + const fleet = Logic.addFleet(RULES, st, 0, home, [{ hullId: 'frigate', mark: 1, count: 3 }]); + const to = targetFor(st, fleet); + check('a duplicated stack request is summed before it is checked', + Logic.sendDetachment(RULES, st, fleet, to, [ + { hullId: 'frigate', mark: 1, count: 2 }, { hullId: 'frigate', mark: 1, count: 2 }, + ]) === null); + check('the fleet survived the duplicated request', + fleet.ships.reduce((t, s) => t + s.count, 0) === 3); + } + + { + // A star base cannot sail. Sending the mobile half of a mixed fleet must + // leave the base behind, and a fleet of nothing but bases cannot be sent. + const st = mk(); + const home = st.galaxy.homeIdx[0]; + const fleet = Logic.addFleet(RULES, st, 0, home, + [{ hullId: 'starbase', mark: 1, count: 1 }, { hullId: 'frigate', mark: 1, count: 2 }]); + const before = countShips(st, 0); + const to = targetFor(st, fleet); + const sent = Logic.sendDetachment(RULES, st, fleet, to, [{ hullId: 'frigate', mark: 1, count: 2 }]); + check('the mobile half of a mixed fleet can be sent', !!sent && sent.toStar === to); + check('the star base is not carried along', + !!sent && sent.ships.every((s) => s.hullId !== 'starbase')); + check('the star base is still at home', + Logic.fleetsAt(st, home).some((f) => f.ships.some((s) => s.hullId === 'starbase'))); + check('a mixed-fleet split conserves ships', countShips(st, 0) === before); + + const bases = Logic.addFleet(RULES, st, 0, st.galaxy.homeIdx[0], + [{ hullId: 'starbase', mark: 1, count: 1 }]); + check('a fleet of nothing but star bases cannot be sent', + Logic.sendDetachment(RULES, st, bases, to, [{ hullId: 'starbase', mark: 1, count: 1 }]) === null); + } + + { + // splitFleet on its own: the two halves must both be real fleets, and a + // fleet already under way cannot be split at all. + const st = mk(); + const home = st.galaxy.homeIdx[0]; + const fleet = Logic.addFleet(RULES, st, 0, home, [{ hullId: 'frigate', mark: 1, count: 5 }]); + const before = countShips(st, 0); + const half = Logic.splitFleet(RULES, st, fleet, [{ hullId: 'frigate', mark: 1, count: 2 }]); + check('splitFleet returns a new fleet at the same star', + !!half && half.starIdx === home && half.id !== fleet.id); + check('splitFleet conserves ships', countShips(st, 0) === before); + check('splitting off everything is refused', + Logic.splitFleet(RULES, st, fleet, [{ hullId: 'frigate', mark: 1, count: 3 }]) === null); + const to = targetFor(st, fleet); + Logic.sendFleet(RULES, st, fleet, to); + check('a fleet under way cannot be split', + Logic.splitFleet(RULES, st, fleet, [{ hullId: 'frigate', mark: 1, count: 1 }]) === null); + check('a fleet under way cannot be given a new order', + Logic.sendDetachment(RULES, st, fleet, home, [{ hullId: 'frigate', mark: 1, count: 1 }]) === null); + } + + { + // Two idle fleets in one system are merged again at the start of the next + // turn (trap 17). That is the rule the panel is designed around — the only + // split it offers is one that departs immediately — so it has to hold. + const st = mk(); + const home = st.galaxy.homeIdx[0]; + const fleet = Logic.addFleet(RULES, st, 0, home, [{ hullId: 'frigate', mark: 1, count: 4 }]); + Logic.splitFleet(RULES, st, fleet, [{ hullId: 'frigate', mark: 1, count: 1 }]); + check('a split leaves two fleets in the system', + Logic.fleetsAt(st, home).filter((f) => f.empireIdx === 0).length === 2); + Logic.beginEmpireTurn(RULES, st, 0); + check('idle detachments merge back at the start of the turn', + Logic.fleetsAt(st, home).filter((f) => f.empireIdx === 0).length === 1); + } +} + // --------------------------------------------------------------------------- section('5. Combat'); // ---------------------------------------------------------------------------