feat(mastervega): add star map command panel and fleet detachment system

Introduce VegaSidePanel, a right-hand command panel with three modes:
star (system summary), fleet (ship count selectors), and order
(distance, ETA, Accept/Cancel). Replaces the inline fleet-order flow
with a callback-driven architecture where the scene owns selection
state and the panel only reads the engine and reports clicks.

Add fleet detachment logic to VegaLogic:
- splitFleet: carve a new idle fleet from an existing one
- sendDetachment: validate against actual stacks, normalize duplicates,
  probe reachability on a copy, and rollback on refusal
- etaTo: compute ETA scoped to the selected detachment (slowest hull
  in the selection, not the whole fleet)

VegaStarMap gains blockPointer (prevent pan/zoom over the panel),
onEmptyClick (dismiss panel by clicking void), and a rotating selection
reticle.

Section 4b adds ~32 verification checks covering ETA accuracy, ship
conservation, out-of-range refusals, duplicate normalization, star-base
immobility, and the idle-fleet merge rule.

Updates mastervega-build-plan.md to document the new panel and
verification section.
This commit is contained in:
Brian Fertig 2026-08-02 08:38:22 -06:00
parent a16e59067b
commit b0efbe3a44
6 changed files with 1095 additions and 32 deletions

View File

@ -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.

View File

@ -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

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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);
});
}

View File

@ -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');
// ---------------------------------------------------------------------------