refactor(mastervega): cyberpunk button skin, fleet mix, orbit-aware targeting, and diplomacy guards

- Add VegaButton.js: a Master of Vega-local cyberpunk button with chamfered
  panels, neon glow, scanline sweep, and corner-bracket hover animations.
  Supports cyan/magenta/red/green schemes.

- Swap Button imports across 12 Vega modules to use VegaButton.js and add
  scheme options (green for accept, red for reject/war, magenta for actions).

- Fix multi-colony targeting in VegaLogic.js: introduce targetColonyAt() to
  resolve the correct colony by orbit when multiple exist at a star. Update
  bombard(), invasionForecast(), and invade() to accept an optional orbit
  parameter. Add immediate scene.log() feedback in VegaSystemView.js.

- Implement weighted 4:3:2:1 fleet mix (frigate:destroyer:cruiser:battleship)
  in pickFleet(), replacing the old equal-count-per-hull diversity logic.

- Add BASELINE_WEAPON fallback in VegaShips.js so warships with zero weapon
  tech still deal damage instead of silently doing nothing.

- Guard VegaDiplomacy.js proposeOrOffer calls with canNegotiate to prevent
  diplomacy-incapable species (e.g., Lithox) from queuing peace offers the
  human can never answer.

- Update verifyMasterOfVega.js with comprehensive tests for all new behavior.
This commit is contained in:
Brian Fertig 2026-08-10 19:37:57 -06:00
parent c105ebd23c
commit e1b5ae7277
18 changed files with 607 additions and 46 deletions

View File

@ -6,7 +6,7 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js'; import { Button } from './VegaButton.js';
import { TextInput } from '../../ui/TextInput.js'; import { TextInput } from '../../ui/TextInput.js';
import { Tooltip } from '../../ui/Tooltip.js'; import { Tooltip } from '../../ui/Tooltip.js';
import { VegaMusic } from './VegaMusic.js'; import { VegaMusic } from './VegaMusic.js';
@ -668,7 +668,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
// combat's Next round button) rather than the generic click, and a no-op // combat's Next round button) rather than the generic click, and a no-op
// press while busy/modal should stay silent. // press while busy/modal should stay silent.
this.endTurnBtn = new Button(this, GAME_WIDTH - 130, 31, 'End turn', () => this.onEndTurn(), this.endTurnBtn = new Button(this, GAME_WIDTH - 130, 31, 'End turn', () => this.onEndTurn(),
{ width: 190, height: 46, fontSize: 20 }); { width: 190, height: 46, fontSize: 20, scheme: 'magenta' });
hud.add(this.endTurnBtn); hud.add(this.endTurnBtn);
// --- status log, bottom left // --- status log, bottom left
@ -820,9 +820,15 @@ export default class MasterOfVegaGame extends Phaser.Scene {
const GAP = 10; const GAP = 10;
const X = 24; const X = 24;
const PORTRAIT = 48; const PORTRAIT = 48;
// canNegotiate here is a belt-and-suspenders guard, not the primary fix:
// runDiplomacyTurn (VegaDiplomacy.js) no longer lets a diplomacy-
// incapable species (Lithox) queue a pendingOffer in the first place, but
// this keeps a save written before that fix from resurrecting a stray
// "seeks an audience" card with a permanently-disabled button.
const otherIdxs = Object.keys(human.pendingOffers) const otherIdxs = Object.keys(human.pendingOffers)
.map(Number) .map(Number)
.filter((idx) => human.pendingOffers[idx] && this.state.empires[idx]?.alive); .filter((idx) => human.pendingOffers[idx] && this.state.empires[idx]?.alive
&& canNegotiate(this.rules, this.state, this.state.humanIndex, idx));
otherIdxs.forEach((otherIdx, row) => { otherIdxs.forEach((otherIdx, row) => {
const other = this.state.empires[otherIdx]; const other = this.state.empires[otherIdx];
const cy = GAME_HEIGHT - 168 - 16 - row * (H + GAP) - H / 2; const cy = GAME_HEIGHT - 168 - 16 - row * (H + GAP) - H / 2;

View File

@ -35,7 +35,7 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js'; import { Button } from './VegaButton.js';
import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js'; import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js';
import { FONT, D, uiClick } from './VegaScreens.js'; import { FONT, D, uiClick } from './VegaScreens.js';
import { playSound, SFX } from '../../ui/Sounds.js'; import { playSound, SFX } from '../../ui/Sounds.js';
@ -729,12 +729,12 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
/** Lays out one row (or wrapped rows) of [label, fn] pairs at `y`, 3 per row. Returns the row count. */ /** Lays out one row (or wrapped rows) of [label, fn] pairs at `y`, 3 per row. Returns the row count. */
function layoutRow(list, y, extraOpts = {}) { function layoutRow(list, y, extraOpts = {}) {
const perRow = 3; const perRow = 3;
list.forEach(([label, fn], i) => { list.forEach(([label, fn, itemOpts], i) => {
const col = i % perRow; const col = i % perRow;
const row = Math.floor(i / perRow); const row = Math.floor(i / perRow);
const x = CHAT_X + col * (BTN_W + BTN_GAP) + BTN_W / 2; const x = CHAT_X + col * (BTN_W + BTN_GAP) + BTN_W / 2;
const yy = y + row * (BTN_H + BTN_GAP) + BTN_H / 2; const yy = y + row * (BTN_H + BTN_GAP) + BTN_H / 2;
buttonRow.add(new Button(scene, x, yy, label, uiClick(scene, fn), { width: BTN_W, height: BTN_H, fontSize: 16, ...extraOpts })); buttonRow.add(new Button(scene, x, yy, label, uiClick(scene, fn), { width: BTN_W, height: BTN_H, fontSize: 16, ...extraOpts, ...itemOpts }));
}); });
return Math.ceil(list.length / perRow) || 0; return Math.ceil(list.length / perRow) || 0;
} }
@ -749,15 +749,15 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
items.push(['We\'re Staying', doComplaintDefy]); items.push(['We\'re Staying', doComplaintDefy]);
} }
if (offer) { if (offer) {
items.push(['Accept', () => doPending(true)]); items.push(['Accept', () => doPending(true), { scheme: 'green' }]);
items.push(['Reject', () => doPending(false)]); items.push(['Reject', () => doPending(false), { scheme: 'red' }]);
} else if (!canNegotiate(rules, state, me, otherIdx)) { } else if (!canNegotiate(rules, state, me, otherIdx)) {
if (!atWar(state, me, otherIdx)) items.push(['Declare War', doDeclareWar]); if (!atWar(state, me, otherIdx)) items.push(['Declare War', doDeclareWar, { scheme: 'red' }]);
} else if (atWar(state, me, otherIdx)) { } else if (atWar(state, me, otherIdx)) {
items.push(['Sue for Peace', () => doPropose('peace')]); items.push(['Sue for Peace', () => doPropose('peace')]);
if (giftAffordable) items.push(['Send Gift', openGiftPicker]); if (giftAffordable) items.push(['Send Gift', openGiftPicker]);
} else { } else {
items.push(['Declare War', doDeclareWar]); items.push(['Declare War', doDeclareWar, { scheme: 'red' }]);
if (state.empires[me].treaties[otherIdx] !== 'alliance') { if (state.empires[me].treaties[otherIdx] !== 'alliance') {
items.push(['Propose Alliance', () => doPropose('alliance')]); items.push(['Propose Alliance', () => doPropose('alliance')]);
} }

View File

@ -0,0 +1,231 @@
// Master of Vega — cyberpunk button skin.
//
// Same public API as src/ui/Button.js (constructor signature, options,
// setActive/setLabel/setEnabled) so every call site in this game can swap
// its import for this file with zero other changes. Deliberately NOT a
// replacement for src/ui/Button.js itself — that file is shared with other
// games and stays untouched; this is a mastervega-local reskin only.
//
// Look: angular chamfered panel (not rounded), a static neon glow on the
// border (baseline look, not a hover animation), and two hover-only
// animations — a scanline sweep and four corner brackets snapping outward,
// like a targeting reticle acquiring a lock.
import * as Phaser from 'phaser';
const CHAMFER = 14;
const SCHEMES = {
cyan: { glow: 0x2fe3ff, glowHex: '#2fe3ff', textDark: '#04121e' },
magenta: { glow: 0xff2fd0, glowHex: '#ff2fd0', textDark: '#1a0414' },
red: { glow: 0xff3355, glowHex: '#ff3355', textDark: '#1a0406' },
green: { glow: 0x2bff9e, glowHex: '#2bff9e', textDark: '#04160e' },
};
const PANEL = 0x0b1220;
const TEXT = '#e8f4ff';
export class Button extends Phaser.GameObjects.Container {
constructor(scene, x, y, label, onClick, options = {}) {
super(scene, x, y);
const {
width = 280,
height = 64,
bg = PANEL,
textColor = TEXT,
fontSize = 28,
variant = 'solid',
scheme = 'cyan',
} = options;
const scm = SCHEMES[scheme] || SCHEMES.cyan;
this.options = { width, height, bg, textColor, fontSize, variant, scheme: scm };
const isGhost = variant === 'ghost';
this.bgRect = scene.add.graphics();
this.bgRect.postFX.addGlow(scm.glow, 3, 0, false, 0.1, 10);
// Scanline + corner-bracket overlays, chamfer-clipped so they never
// spill past the panel outline. Created before the first _drawBg() call
// since that call populates fxMaskShape's geometry.
this.fxMaskShape = scene.add.graphics().setVisible(false);
this.scanline = scene.add.graphics().setAlpha(0);
this.scanline.setMask(new Phaser.Display.Masks.GeometryMask(scene, this.fxMaskShape));
this.brackets = scene.add.graphics().setAlpha(0);
this._drawBg(bg, isGhost ? 0.3 : 1);
this.text = scene.add.text(0, 0, label, {
fontFamily: '"Julius Sans One"',
fontSize: `${fontSize}px`,
color: textColor,
}).setOrigin(0.5);
// Long labels on a fixed-width button otherwise render past the edge of
// the chamfered panel. Purely corrective: only ever shrinks text that
// wouldn't have fit at the requested size, so every button whose label
// already fits renders exactly as before.
const maxTextWidth = width - 16;
if (this.text.width > maxTextWidth) {
const fitSize = Math.max(11, Math.floor(fontSize * (maxTextWidth / this.text.width)));
this.text.setFontSize(fitSize);
}
this._drawBrackets(false);
this.add([this.bgRect, this.fxMaskShape, this.scanline, this.brackets, this.text]);
this.setSize(width, height);
this.setInteractive({
useHandCursor: true,
hitArea: new Phaser.Geom.Rectangle(0, 0, width, height),
hitAreaCallback: Phaser.Geom.Rectangle.Contains,
});
const onOver = () => {
if (this._active) return;
const { scheme: s, variant: v } = this.options;
if (v === 'ghost') {
this._drawBg(s.glow, 0.22);
this.text.setColor(s.glowHex);
} else {
this._drawBg(s.glow, 1);
this.text.setColor(s.textDark);
}
this._startScanline();
this._drawBrackets(true);
};
const onOut = () => {
if (this._active) return;
const { bg: b, textColor: tc, variant: v } = this.options;
this._drawBg(b, v === 'ghost' ? 0.3 : 1);
this.text.setColor(tc);
this._stopScanline();
this._drawBrackets(false);
};
const onDown = () => this.bgRect.setScale(0.97);
const onUp = () => this.bgRect.setScale(1);
this.on('pointerover', onOver);
this.on('pointerout', onOut);
this.on('pointerdown', onDown);
this.on('pointerup', onUp);
this.on('pointerupoutside', onUp);
if (onClick) this.on('pointerup', onClick);
this.once(Phaser.GameObjects.Events.DESTROY, () => this._stopScanline());
scene.add.existing(this);
}
/** Chamfered (angular-cut) panel outline, top-right and bottom-left corners cut at 45°. */
_chamferPoints() {
const { width, height } = this.options;
const hw = width / 2;
const hh = height / 2;
const c = Math.min(CHAMFER, hw, hh);
return [
{ x: -hw, y: -hh },
{ x: hw - c, y: -hh },
{ x: hw, y: -hh + c },
{ x: hw, y: hh },
{ x: -hw + c, y: hh },
{ x: -hw, y: hh - c },
];
}
_drawBg(fillColor, fillAlpha) {
const { scheme } = this.options;
const pts = this._chamferPoints();
this.bgRect.clear();
if (fillAlpha > 0) {
this.bgRect.fillStyle(fillColor, fillAlpha);
this.bgRect.fillPoints(pts, true);
}
this.bgRect.lineStyle(2, scheme.glow, 1);
this.bgRect.strokePoints(pts, true);
this.fxMaskShape.clear();
this.fxMaskShape.fillStyle(0xffffff, 1);
this.fxMaskShape.fillPoints(pts, true);
}
_drawBrackets(extended) {
const { width, height, scheme } = this.options;
const hw = width / 2;
const hh = height / 2;
const arm = 10;
const pad = extended ? 4 : 0;
const corners = [
{ cx: -hw - pad, cy: -hh - pad, dx: 1, dy: 1 },
{ cx: hw + pad, cy: -hh - pad, dx: -1, dy: 1 },
{ cx: hw + pad, cy: hh + pad, dx: -1, dy: -1 },
{ cx: -hw - pad, cy: hh + pad, dx: 1, dy: -1 },
];
this.brackets.clear();
this.brackets.lineStyle(2, scheme.glow, 1);
for (const { cx, cy, dx, dy } of corners) {
this.brackets.beginPath();
this.brackets.moveTo(cx + dx * arm, cy);
this.brackets.lineTo(cx, cy);
this.brackets.lineTo(cx, cy + dy * arm);
this.brackets.strokePath();
}
}
_startScanline() {
this._stopScanline();
const { width, height, scheme } = this.options;
const hw = width / 2;
const hh = height / 2;
this.scanline.clear();
this.scanline.fillStyle(scheme.glow, 0.85);
this.scanline.fillRect(-hw, -3, width, 3);
this.scanline.y = -hh;
this.scanline.setAlpha(1);
this.brackets.setAlpha(1);
this._scanTween = this.scene.tweens.add({
targets: this.scanline,
y: hh,
duration: 650,
repeat: -1,
ease: 'Sine.easeInOut',
yoyo: true,
});
}
_stopScanline() {
if (this._scanTween) {
this._scanTween.stop();
this._scanTween = null;
}
this.scanline.setAlpha(0);
this.brackets.setAlpha(0);
}
setActive(active) {
this._active = active;
const { bg, textColor, scheme, variant } = this.options;
if (active) {
this._drawBg(scheme.glow, 1);
this.text.setColor(scheme.textDark);
} else {
this._drawBg(bg, variant === 'ghost' ? 0.3 : 1);
this.text.setColor(textColor);
}
return this;
}
setLabel(label) {
this.text.setText(label);
return this;
}
setEnabled(enabled) {
this.setAlpha(enabled ? 1 : 0.5);
if (enabled) this.setInteractive({ useHandCursor: true });
else this.disableInteractive();
return this;
}
}

View File

@ -43,7 +43,7 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js'; import { Button } from './VegaButton.js';
import { TextInput } from '../../ui/TextInput.js'; import { TextInput } from '../../ui/TextInput.js';
import { FONT, D, ORBIT, uiClick } from './VegaScreens.js'; import { FONT, D, ORBIT, uiClick } from './VegaScreens.js';
import { turnToYear } from './VegaRules.js'; import { turnToYear } from './VegaRules.js';

View File

@ -33,7 +33,7 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js'; import { Button } from './VegaButton.js';
import { Tooltip } from '../../ui/Tooltip.js'; import { Tooltip } from '../../ui/Tooltip.js';
import { FONT, D, ORBIT, slider, uiClick } from './VegaScreens.js'; import { FONT, D, ORBIT, slider, uiClick } from './VegaScreens.js';
import { playSound, SFX } from '../../ui/Sounds.js'; import { playSound, SFX } from '../../ui/Sounds.js';
@ -82,7 +82,7 @@ const COLONY_FOCUS_OPTIONS = [
{ value: 'manual', label: 'Manual', desc: 'No automation — you queue everything yourself.' }, { value: 'manual', label: 'Manual', desc: 'No automation — you queue everything yourself.' },
{ value: 'improvement', label: 'Colony Improvement', desc: 'Industry buildings first, then any other building not yet built.' }, { value: 'improvement', label: 'Colony Improvement', desc: 'Industry buildings first, then any other building not yet built.' },
{ value: 'research', label: 'Research Focus', desc: 'Only queues research buildings. Once none are left, the queue stays empty and construction spills into research.' }, { value: 'research', label: 'Research Focus', desc: 'Only queues research buildings. Once none are left, the queue stays empty and construction spills into research.' },
{ value: 'fleet', label: 'Fleet Production', desc: 'Builds a diversified warship fleet, favouring whichever hull you have the fewest of at this system.' }, { value: 'fleet', label: 'Fleet Production', desc: 'Builds a diversified warship fleet at this system, targeting a 4:3:2:1 mix of frigates : destroyers : cruisers : battleships.' },
{ value: 'growth', label: 'Population Growth', desc: 'Queues buildings that raise your population ceiling or growth rate.' }, { value: 'growth', label: 'Population Growth', desc: 'Queues buildings that raise your population ceiling or growth rate.' },
{ value: 'trade', label: 'Trade & Commerce', desc: 'Queues buildings that raise trade income.' }, { value: 'trade', label: 'Trade & Commerce', desc: 'Queues buildings that raise trade income.' },
{ value: 'defense', label: 'Homeworld Defense', desc: 'Queues planetary defense buildings.' }, { value: 'defense', label: 'Homeworld Defense', desc: 'Queues planetary defense buildings.' },

View File

@ -21,7 +21,7 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js'; import { Button } from './VegaButton.js';
import { queueGameAssets } from '../../services/assetLoader.js'; import { queueGameAssets } from '../../services/assetLoader.js';
import { compileRules, markNumeral } from './VegaRules.js'; import { compileRules, markNumeral } from './VegaRules.js';
import { ensureSheets } from './VegaArt.js'; import { ensureSheets } from './VegaArt.js';

View File

@ -7,7 +7,7 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js'; import { Button } from './VegaButton.js';
import { playSound, SFX } from '../../ui/Sounds.js'; import { playSound, SFX } from '../../ui/Sounds.js';
import { FONT, D, uiClick } from './VegaScreens.js'; import { FONT, D, uiClick } from './VegaScreens.js';
import VegaFx from './VegaFx.js'; import VegaFx from './VegaFx.js';
@ -205,7 +205,7 @@ export function openCombatView(scene, rules, battle, art, opts = {}) {
playSound(scene, SFX.VEGA_ENDTURN); playSound(scene, SFX.VEGA_ENDTURN);
animate(stepRound(battle, {})); animate(stepRound(battle, {}));
if (battle.done) scene.time.delayedCall(700, finish); if (battle.done) scene.time.delayedCall(700, finish);
}, { width: 220, height: 52 }); }, { width: 220, height: 52, scheme: 'magenta' });
layer.add(next); layer.add(next);
const auto = new Button(scene, GAME_WIDTH / 2, GAME_HEIGHT - 70, 'Auto-resolve', uiClick(scene, () => { const auto = new Button(scene, GAME_WIDTH / 2, GAME_HEIGHT - 70, 'Auto-resolve', uiClick(scene, () => {

View File

@ -20,7 +20,7 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js'; import { Button } from './VegaButton.js';
import { playSound, SFX } from '../../ui/Sounds.js'; import { playSound, SFX } from '../../ui/Sounds.js';
import { FONT, D, uiClick } from './VegaScreens.js'; import { FONT, D, uiClick } from './VegaScreens.js';
import VegaFx from './VegaFx.js'; import VegaFx from './VegaFx.js';

View File

@ -275,8 +275,17 @@ export function runDiplomacyTurn(rules, state, e) {
if (atWar(state, e, other.idx)) { if (atWar(state, e, other.idx)) {
// Sue for peace when clearly losing, or when the war has gone cold. // Sue for peace when clearly losing, or when the war has gone cold.
// Only sue for peace when genuinely losing, and not immediately — a war // Only sue for peace when genuinely losing, and not immediately — a war
// that ends on the turn it starts accomplishes nothing. // that ends on the turn it starts accomplishes nothing. Gated on
if (ratio < 0.55 && rand(state) < 0.12) proposeOrOffer(rules, state, e, other.idx, 'peace'); // canNegotiate same as every other proposal below: a diplomacy-
// incapable species (Lithox) never comes to the table, war included —
// the only way out of a war with one is the human's own unilateral
// Cease Hostilities toggle (VegaScreens.js), not a held offer. Without
// this a losing Lithox would still queue a peace offer the human could
// never answer (its Seek Audience button is permanently disabled),
// leaving a stray "seeks an audience" card up for nothing.
if (canNegotiate(rules, state, e, other.idx) && ratio < 0.55 && rand(state) < 0.12) {
proposeOrOffer(rules, state, e, other.idx, 'peace');
}
continue; continue;
} }

View File

@ -1513,6 +1513,24 @@ function deliverPopulation(rules, state, f) {
stack.popPayload = 0; stack.popPayload = 0;
} }
// Resolves which colony at this star a bombard/invade action actually
// targets. A star can host more than one colony (one per orbit), owned by
// different empires — colonyAt() only ever returns the first one found in
// state.colonies, regardless of orbit. That silently broke Bombard/Invade
// whenever an allied or neutral colony shared a system with the actual
// (hostile) target: colonyAt picked the ally, the atWar check correctly
// refused, and the caller had no way to tell the refusal apart from a real
// "no orbital superiority" failure. `orbit`, when supplied, pins the exact
// colony the caller means — VegaSystemView.js always knows which one is on
// screen. Without it (VegaAI.js's callers, which only know a starIdx), the
// fallback targets whichever colony here the attacker is actually at war
// with, rather than just whichever happens to be first in the array.
function targetColonyAt(state, e, starIdx, orbit) {
const here = coloniesAt(state, starIdx);
if (orbit != null) return here.find((c) => c.orbit === orbit) ?? null;
return here.find((c) => atWar(state, e, c.empireIdx)) ?? here[0] ?? null;
}
// Orbital superiority at a system: our combat power there exceeds theirs. // Orbital superiority at a system: our combat power there exceeds theirs.
// Bombardment and invasion both require it. // Bombardment and invasion both require it.
export function holdsOrbit(rules, state, e, starIdx, defenderIdx) { export function holdsOrbit(rules, state, e, starIdx, defenderIdx) {
@ -1534,8 +1552,8 @@ export function holdsOrbit(rules, state, e, starIdx, defenderIdx) {
// denies the attacker the clean orbit an invasion needs. Soaked to 2500 turns, // denies the attacker the clean orbit an invasion needs. Soaked to 2500 turns,
// not one empire in eight games was ever eliminated. Bombing also thins the // not one empire in eight games was ever eliminated. Bombing also thins the
// defenders for a subsequent landing, so the two mechanics work together. // defenders for a subsequent landing, so the two mechanics work together.
export function bombard(rules, state, e, starIdx) { export function bombard(rules, state, e, starIdx, orbit = null) {
const colony = colonyAt(state, starIdx); const colony = targetColonyAt(state, e, starIdx, orbit);
if (!colony || colony.empireIdx === e) return null; if (!colony || colony.empireIdx === e) return null;
if (!atWar(state, e, colony.empireIdx)) return null; if (!atWar(state, e, colony.empireIdx)) return null;
if (!holdsOrbit(rules, state, e, starIdx, colony.empireIdx)) return null; if (!holdsOrbit(rules, state, e, starIdx, colony.empireIdx)) return null;
@ -1585,8 +1603,8 @@ export function bombard(rules, state, e, starIdx) {
// both the AI and the human's invade button need the same forecast — the AI // both the AI and the human's invade button need the same forecast — the AI
// was committing every transport it had to hopeless landings (1971 failures // was committing every transport it had to hopeless landings (1971 failures
// against 130 successes) purely because nothing told it the odds. // against 130 successes) purely because nothing told it the odds.
export function invasionForecast(rules, state, e, starIdx) { export function invasionForecast(rules, state, e, starIdx, orbit = null) {
const colony = colonyAt(state, starIdx); const colony = targetColonyAt(state, e, starIdx, orbit);
if (!colony || colony.empireIdx === e) return null; if (!colony || colony.empireIdx === e) return null;
const troops = state.fleets const troops = state.fleets
.filter((f) => f.starIdx === starIdx && f.empireIdx === e) .filter((f) => f.starIdx === starIdx && f.empireIdx === e)
@ -1608,8 +1626,8 @@ export function invasionForecast(rules, state, e, starIdx) {
return { troops, defenders, odds, favourable }; return { troops, defenders, odds, favourable };
} }
export function invade(rules, state, e, starIdx) { export function invade(rules, state, e, starIdx, orbit = null) {
const colony = colonyAt(state, starIdx); const colony = targetColonyAt(state, e, starIdx, orbit);
if (!colony || colony.empireIdx === e) return null; if (!colony || colony.empireIdx === e) return null;
if (!atWar(state, e, colony.empireIdx)) return null; if (!atWar(state, e, colony.empireIdx)) return null;
// Holding orbit means orbital SUPERIORITY, not an empty sky. // Holding orbit means orbital SUPERIORITY, not an empty sky.
@ -1987,12 +2005,23 @@ function pickDefense(rules, state, colony) {
enqueueFirstAffordableBuilding(rules, state, colony, defenseBuildingIds(rules)); enqueueFirstAffordableBuilding(rules, state, colony, defenseBuildingIds(rules));
} }
// Brian's target fleet mix for Colony Focus: Fleet Production — 4 frigates :
// 3 destroyers : 2 cruisers : 1 battleship. Any warship hull missing from
// this table (there shouldn't be one — frigate/destroyer/cruiser/battleship
// are the only role:'warship' hulls in mastervega-rules.json) falls back to
// weight 1.
const FLEET_MIX_WEIGHT = { frigate: 4, destroyer: 3, cruiser: 2, battleship: 1 };
// Independently-implemented twin of VegaAI.js's preferredWarship (same // Independently-implemented twin of VegaAI.js's preferredWarship (same
// (hp + damage*4) / cost scoring, same ~15-turns-of-budget affordability // (hp + damage*4) / cost scoring, same ~15-turns-of-budget affordability
// cutoff — see the header note above for why this is duplicated rather than // cutoff — see the header note above for why this is duplicated rather than
// shared), plus a diversity pass: rather than always maxing out the single // shared), plus a weighted diversity pass: rather than always maxing out the
// best-value hull, it counts what's already docked at this colony's star and // single best-value hull, it counts what's already docked at this colony's
// favours whichever warship role is least represented, ties broken by score. // star and favours whichever warship role is furthest below its target share
// of FLEET_MIX_WEIGHT (count/weight, lowest wins — the standard weighted
// round-robin comparison), ties broken by score. Starting from an empty
// fleet this converges to the 4:3:2:1 ratio after one full 10-ship cycle and
// holds it indefinitely, rather than the old equal-count-per-hull split.
function pickFleet(rules, state, colony) { function pickFleet(rules, state, colony) {
const e = colony.empireIdx; const e = colony.empireIdx;
const budget = colonyBuildRate(rules, state, colony); const budget = colonyBuildRate(rules, state, colony);
@ -2005,16 +2034,18 @@ function pickFleet(rules, state, colony) {
} }
} }
let best = null; let best = null;
let bestCount = Infinity; let bestRatio = Infinity;
let bestScore = -Infinity; let bestScore = -Infinity;
for (const hullId of hullIds) { for (const hullId of hullIds) {
const d = empireDesign(rules, state, e, hullId); const d = empireDesign(rules, state, e, hullId);
if (d.damage <= 0) continue; if (d.damage <= 0) continue;
if (d.cost > budget * 15) continue; if (d.cost > budget * 15) continue;
const count = present[hullId] ?? 0; const count = present[hullId] ?? 0;
const weight = FLEET_MIX_WEIGHT[hullId] ?? 1;
const ratio = count / weight;
const score = (d.hp + d.damage * 4) / d.cost; const score = (d.hp + d.damage * 4) / d.cost;
if (count < bestCount || (count === bestCount && score > bestScore)) { if (ratio < bestRatio || (ratio === bestRatio && score > bestScore)) {
best = hullId; bestCount = count; bestScore = score; best = hullId; bestRatio = ratio; bestScore = score;
} }
} }
// Before any weapon tech is known every hull scores damage=0 and `best` // Before any weapon tech is known every hull scores damage=0 and `best`
@ -2106,7 +2137,7 @@ export function recommendColonyFocus(rules, state, colony) {
return { return {
value: 'fleet', label: 'Fleet Production', value: 'fleet', label: 'Fleet Production',
reason: "Empire-wide fleet strength is below what's typical this far into the game — " reason: "Empire-wide fleet strength is below what's typical this far into the game — "
+ 'additional warships would help, favouring whichever type you have fewest of.', + 'additional warships would help, built toward a 4:3:2:1 frigate/destroyer/cruiser/battleship mix.',
}; };
} }

View File

@ -6,7 +6,7 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js'; import { Button } from './VegaButton.js';
import { playSound, SFX } from '../../ui/Sounds.js'; import { playSound, SFX } from '../../ui/Sounds.js';
import { import {
empireColonies, empireFleets, hireLeader, assignLeader, unassignLeader, leaderHireCost, empireColonies, empireFleets, hireLeader, assignLeader, unassignLeader, leaderHireCost,

View File

@ -18,7 +18,7 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js'; import { Button } from './VegaButton.js';
import { markNumeral } from './VegaRules.js'; import { markNumeral } from './VegaRules.js';
import { empireDesign } from './VegaLogic.js'; import { empireDesign } from './VegaLogic.js';
import { refitCost } from './VegaShips.js'; import { refitCost } from './VegaShips.js';

View File

@ -16,6 +16,20 @@ export const COMPONENT_FIELDS = ['weapons', 'construction', 'forcefields', 'prop
export const MAX_MARK = 7; export const MAX_MARK = 7;
// Mark is the AVERAGE tier across all five component fields (see markFor
// below), with an unresearched field contributing 0 rather than dragging the
// average negative — so a real Mark II/III hull with zero weapons research
// is a perfectly reachable state if a player (or the AI) simply prioritises
// the other four fields. Without this fallback that ship deals literally 0
// damage forever, silently: Bombard refuses (bestComponents.allWeapons is
// empty), and space combat is a no-op. Strictly worse than the tier-0 Laser
// Cannon on every axis, so actually researching lasercannon (or anything
// else) is still a real upgrade, not a formality made moot by this floor.
const BASELINE_WEAPON = {
id: 'baselinemassdriver', name: 'Mass Driver', kind: 'beam',
min: 1, max: 2, shots: 1, space: 3, cost: 10,
};
// Highest tier the empire has reached in each field. The chains are linear, so // Highest tier the empire has reached in each field. The chains are linear, so
// "best tier" is all we ever need to know. // "best tier" is all we ever need to know.
export function fieldTiers(rules, known) { export function fieldTiers(rules, known) {
@ -104,6 +118,11 @@ export function bestComponents(rules, known) {
out.missiles = weapons.filter((w) => w.kind === 'missile').sort((a, b) => (avg(b) * b.shots) / b.space - (avg(a) * a.shots) / a.space); out.missiles = weapons.filter((w) => w.kind === 'missile').sort((a, b) => (avg(b) * b.shots) / b.space - (avg(a) * a.shots) / a.space);
out.weapon = out.beams[0] ?? out.missiles[0] ?? null; out.weapon = out.beams[0] ?? out.missiles[0] ?? null;
out.allWeapons = weapons; out.allWeapons = weapons;
if (!out.allWeapons.length) {
out.allWeapons = [BASELINE_WEAPON];
out.beams = [BASELINE_WEAPON];
out.weapon = BASELINE_WEAPON;
}
return out; return out;
} }

View File

@ -22,7 +22,7 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js'; import { Button } from './VegaButton.js';
import { markNumeral } from './VegaRules.js'; import { markNumeral } from './VegaRules.js';
import { parsecs } from './VegaGalaxyGen.js'; import { parsecs } from './VegaGalaxyGen.js';
import { import {
@ -759,11 +759,11 @@ export default class VegaSidePanel {
// click, and a fuel-range refusal should stay silent either way. // click, and a fuel-range refusal should stay silent either way.
const accept = new Button(this.scene, PAD + half / 2, btnY + 24, 'Accept', const accept = new Button(this.scene, PAD + half / 2, btnY + 24, 'Accept',
refusal ? null : () => this.cb.onAcceptOrder?.(this.fleet, this.orderStar, this.selectedShips()), refusal ? null : () => this.cb.onAcceptOrder?.(this.fleet, this.orderStar, this.selectedShips()),
{ width: half, height: 48, fontSize: 20 }); { width: half, height: 48, fontSize: 20, scheme: 'green' });
if (refusal) accept.setEnabled?.(false); if (refusal) accept.setEnabled?.(false);
this.body.add(accept); this.body.add(accept);
this.body.add(new Button(this.scene, PAD + half * 1.5 + 12, btnY + 24, 'Cancel', this.body.add(new Button(this.scene, PAD + half * 1.5 + 12, btnY + 24, 'Cancel',
uiClick(this.scene, () => this.cb.onCancelOrder?.()), { width: half, height: 48, fontSize: 20, variant: 'ghost' })); uiClick(this.scene, () => this.cb.onCancelOrder?.()), { width: half, height: 48, fontSize: 20, variant: 'ghost', scheme: 'red' }));
this.y = btnY + 60; this.y = btnY + 60;
} }
} }

View File

@ -14,7 +14,7 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { Button } from '../../ui/Button.js'; import { Button } from './VegaButton.js';
import { playSound, SFX } from '../../ui/Sounds.js'; import { playSound, SFX } from '../../ui/Sounds.js';
import { modalShell, FONT, ORBIT, uiClick } from './VegaScreens.js'; import { modalShell, FONT, ORBIT, uiClick } from './VegaScreens.js';
import { planetFrame, starFrame } from './VegaArt.js'; import { planetFrame, starFrame } from './VegaArt.js';
@ -216,7 +216,7 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
if (!mine) { if (!mine) {
// Enemy colony: offer the two things a fleet in orbit can actually do. // Enemy colony: offer the two things a fleet in orbit can actually do.
if (viewerIdx >= 0 && atWar(state, viewerIdx, colony.empireIdx)) { if (viewerIdx >= 0 && atWar(state, viewerIdx, colony.empireIdx)) {
const forecast = invasionForecast(rules, state, viewerIdx, starIdx); const forecast = invasionForecast(rules, state, viewerIdx, starIdx, colony.orbit);
console_.add(scene.add.text(panelX, y, forecast && forecast.troops > 0 console_.add(scene.add.text(panelX, y, forecast && forecast.troops > 0
? `${forecast.troops} marines vs ~${forecast.defenders} defenders — ${Math.round(forecast.odds * 100)}% per exchange` ? `${forecast.troops} marines vs ~${forecast.defenders} defenders — ${Math.round(forecast.odds * 100)}% per exchange`
: 'No troop transports in orbit.', { : 'No troop transports in orbit.', {
@ -224,12 +224,35 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
wordWrap: { width: panelW }, wordWrap: { width: panelW },
})); }));
y += 44; y += 44;
// Both actions used to fail completely silently — refused by a
// precondition (no orbital superiority, no weapons) and an attempted-
// but-lost invasion (every troop consumed either way — see invade()
// in VegaLogic.js) were visually identical to nothing happening at
// all, since the only record was a turn-report event the player
// wouldn't see until ending their turn. scene.log() surfaces the
// outcome immediately in the same ticker the rest of the HUD uses.
console_.add(new Button(scene, panelX + 120, y + 20, 'Bombard', uiClick(scene, () => { console_.add(new Button(scene, panelX + 120, y + 20, 'Bombard', uiClick(scene, () => {
bombard(rules, state, viewerIdx, starIdx); onChanged?.(); rebuild(); const result = bombard(rules, state, viewerIdx, starIdx, colony.orbit);
if (!result) {
scene.log?.('Bombard failed — you need orbital superiority and warships with a working weapon.');
} else if (result.destroyed) {
scene.log?.(`Bombarded the ${owner.name} colony — ${result.killed} killed, the colony was wiped out.`);
} else {
scene.log?.(`Bombarded the ${owner.name} colony — ${result.killed} killed.`);
}
onChanged?.(); rebuild();
}), { width: 220, height: 42, bg: 0x6b2230 })); }), { width: 220, height: 42, bg: 0x6b2230 }));
if (forecast && forecast.troops > 0) { if (forecast && forecast.troops > 0) {
console_.add(new Button(scene, panelX + 370, y + 20, 'Invade', uiClick(scene, () => { console_.add(new Button(scene, panelX + 370, y + 20, 'Invade', uiClick(scene, () => {
invade(rules, state, viewerIdx, starIdx); onChanged?.(); rebuild(); const result = invade(rules, state, viewerIdx, starIdx, colony.orbit);
if (!result) {
scene.log?.('Invasion could not be launched — you need orbital superiority and a troop transport in orbit.');
} else if (result.captured) {
scene.log?.(`Invasion of the ${owner.name} colony succeeded — ${result.attackersLeft} marines secured it.`);
} else {
scene.log?.(`Invasion of the ${owner.name} colony failed — all marines were lost.`);
}
onChanged?.(); rebuild();
}), { width: 220, height: 42 })); }), { width: 220, height: 42 }));
} }
} }

View File

@ -132,7 +132,36 @@ function describeBuildingDone(rules, state, ev) {
function describeShipDone(rules, state, ev) { function describeShipDone(rules, state, ev) {
const h = rules.hulls[ev.hullId]; const h = rules.hulls[ev.hullId];
const star = state.galaxy.stars[ev.starIdx]; const star = state.galaxy.stars[ev.starIdx];
return { headline: `${h.name} completed at ${star.name}.`, lines: [line(h.desc, '#9fb6cc')] }; const count = ev.count ?? 1;
const headline = count > 1
? `${count}× ${h.name} completed at ${star.name}.`
: `${h.name} completed at ${star.name}.`;
return { headline, lines: [line(h.desc, '#9fb6cc')] };
}
// A well-funded queue can pop several copies of the same hull at the same
// colony in a single turn (VegaLogic.js's build loop completes as many
// queue entries as the turn's production covers) — each one pushes its own
// shipDone event, which used to mean N identical "Frigate completed at
// Sol." rows in the New Turn report. Collapsed here into one row per
// (empire, colony, hull) with a count, keeping the first event as the
// representative so its starIdx/colonyId/hullId stay valid for the report
// screen's icon/"View Star System" wiring — only the headline needs the
// count. Scoped to shipDone specifically: buildings are unique per colony
// (enqueue refuses a duplicate), so buildingDone can never repeat this way.
export function groupShipDoneEvents(events) {
const out = [];
const byKey = new Map();
for (const ev of events) {
if (ev.type !== 'shipDone') { out.push(ev); continue; }
const key = `${ev.empire}|${ev.colonyId}|${ev.hullId}`;
const existing = byKey.get(key);
if (existing) { existing.count = (existing.count ?? 1) + 1; continue; }
const grouped = { ...ev, count: 1 };
byKey.set(key, grouped);
out.push(grouped);
}
return out;
} }
// The recommendations are re-derived here rather than frozen into the event // The recommendations are re-derived here rather than frozen into the event

View File

@ -17,8 +17,8 @@ import { makeShipIcon, makeCommanderPortrait } from './VegaShipMedia.js';
import { openShipDetail } from './VegaShipDetail.js'; import { openShipDetail } from './VegaShipDetail.js';
import { buildingFrame } from './VegaArt.js'; import { buildingFrame } from './VegaArt.js';
import { turnToYear } from './VegaRules.js'; import { turnToYear } from './VegaRules.js';
import { Button } from '../../ui/Button.js'; import { Button } from './VegaButton.js';
import { describeEvent, categoryWeight } from './VegaTurnReport.js'; import { describeEvent, categoryWeight, groupShipDoneEvents } from './VegaTurnReport.js';
import { enqueue, empireColonies, colonyTrade } from './VegaLogic.js'; import { enqueue, empireColonies, colonyTrade } from './VegaLogic.js';
// A colony's own trade minus what its existing buildings already cost to run // A colony's own trade minus what its existing buildings already cost to run
@ -50,7 +50,7 @@ export function openTurnReportScreen(scene, rules, state, events, onClose) {
playSound(scene, SFX.VEGA_NEWTURN); playSound(scene, SFX.VEGA_NEWTURN);
const shell = modalShell(scene, `New Turn: ${turnToYear(state.turn)}`, onClose, { width: 1180, height: 760 }); const shell = modalShell(scene, `New Turn: ${turnToYear(state.turn)}`, onClose, { width: 1180, height: 760 });
const ordered = events const ordered = groupShipDoneEvents(events)
.map((ev, i) => ({ ev, i, desc: describeEvent(rules, state, ev) })) .map((ev, i) => ({ ev, i, desc: describeEvent(rules, state, ev) }))
.sort((a, b) => categoryWeight(a.ev) - categoryWeight(b.ev) || a.i - b.i); .sort((a, b) => categoryWeight(a.ev) - categoryWeight(b.ev) || a.i - b.i);

View File

@ -47,7 +47,7 @@ import {
import { CHAT } from '../src/games/mastervega/VegaChat.js'; import { CHAT } from '../src/games/mastervega/VegaChat.js';
// Turn-report classification is Phaser-free, so what the "New Turn" popup will // Turn-report classification is Phaser-free, so what the "New Turn" popup will
// and will not interrupt the player for is checkable here. // and will not interrupt the player for is checkable here.
import { NOTABLE_TYPES, describeEvent, TYPE_LABEL } from '../src/games/mastervega/VegaTurnReport.js'; import { NOTABLE_TYPES, describeEvent, TYPE_LABEL, groupShipDoneEvents } from '../src/games/mastervega/VegaTurnReport.js';
// Ship media is addressed here and nowhere else, so the key convention is // Ship media is addressed here and nowhere else, so the key convention is
// checkable without a canvas. // checkable without a canvas.
import { shipVideoKey, hasShipVideo } from '../src/games/mastervega/VegaShipMedia.js'; import { shipVideoKey, hasShipVideo } from '../src/games/mastervega/VegaShipMedia.js';
@ -981,6 +981,32 @@ section('4. Ship Marks');
const base = Ships.designFor(RULES, known, 'starbase', RULES.species.human.traits); const base = Ships.designFor(RULES, known, 'starbase', RULES.species.human.traits);
check('star base is immobile', base.immobile && base.speed === 0); check('star base is immobile', base.immobile && base.speed === 0);
// A warship hull with literally zero weapon techs known is a real reachable
// state (Mark is an average across all five fields, so the other four can
// carry it well past Mark I) — without a fallback it deals 0 damage
// forever, silently. bestComponents must synthesize a minimal weapon.
{
const noTech = Ships.bestComponents(RULES, {});
check('an empire with zero known techs still has exactly one fallback weapon',
noTech.allWeapons.length === 1, `${noTech.allWeapons.length}`);
check('the fallback weapon is the documented baseline, not a real tech',
noTech.weapon?.id === 'baselinemassdriver', noTech.weapon?.id);
const bareFrigate = Ships.designFor(RULES, {}, 'frigate', RULES.species.human.traits);
check('a warship with zero known techs still deals damage',
bareFrigate.damage > 0, `${bareFrigate.damage}`);
check('non-warship hulls stay unarmed even with zero known techs (hull.space gates it)',
Ships.designFor(RULES, {}, 'scout', RULES.species.human.traits).damage === 0);
// The fallback must be strictly worse than the real tier-0 weapon, so
// researching it (or anything else) is still a genuine upgrade.
const lasercannonOnly = Ships.bestComponents(RULES, { lasercannon: true });
check('the fallback is replaced (not stacked) once a real weapon is known',
lasercannonOnly.allWeapons.length === 1 && lasercannonOnly.weapon?.id !== 'baselinemassdriver',
JSON.stringify(lasercannonOnly.allWeapons.map((w) => w.id)));
const realFrigate = Ships.designFor(RULES, { lasercannon: true }, 'frigate', RULES.species.human.traits);
check('the real tier-0 weapon out-damages the fallback',
realFrigate.damage > bareFrigate.damage, `${realFrigate.damage} vs ${bareFrigate.damage}`);
}
check('refit costs something and is finite', (() => { check('refit costs something and is finite', (() => {
const c = Ships.refitCost(RULES, known, 'cruiser', 1, RULES.species.human.traits); const c = Ships.refitCost(RULES, known, 'cruiser', 1, RULES.species.human.traits);
return Number.isFinite(c) && c > 0; return Number.isFinite(c) && c > 0;
@ -1292,6 +1318,61 @@ section('4c. Population transport');
Logic.fleetPower(RULES, st, fleet) === 0); Logic.fleetPower(RULES, st, fleet) === 0);
} }
} }
// A star hosting two colonies (different empires, different orbits) used
// to break Bombard/Invade/invasionForecast whenever the non-hostile one
// came first in state.colonies: colonyAt() is orbit-blind, so an ally
// sharing a system with the real target silently ate every click. Brian
// hit this directly — allied Rrashaa on one planet, hostile Lithox on
// another, orbital superiority confirmed, both actions refused anyway.
{
const stM = Logic.createGame(RULES, {
sizeId: 'medium', shapeId: 'elliptical', seed: 606, difficultyId: 'normal',
speciesIds: ['human', 'rrashaa', 'kkrix'], humanIndex: 0,
});
stM.rules = RULES;
let starIdx = -1;
for (let i = 0; i < stM.galaxy.stars.length; i += 1) {
if ((stM.galaxy.stars[i].planets?.length ?? 0) >= 2 && !stM.galaxy.homeIdx.includes(i)) { starIdx = i; break; }
}
check('the test galaxy has a star with 2+ planets to place two colonies on', starIdx >= 0);
if (starIdx >= 0) {
// Ally (rrashaa, empire 1) founded FIRST so it lands first in
// state.colonies — colonyAt()'s old first-match behaviour would have
// picked this one regardless of which orbit was actually being acted
// on. Different populations so a forecast computed against the wrong
// colony is numerically distinguishable from one computed correctly.
const allyColony = Logic.foundColony(RULES, stM, 1, starIdx, 0, 20);
const enemyColony = Logic.foundColony(RULES, stM, 2, starIdx, 1, 10);
Diplo.declareWar(RULES, stM, 0, 2); // human at war with kkrix only; rrashaa stays unallied-but-not-at-war
Logic.addFleet(RULES, stM, 0, starIdx, [
{ hullId: 'frigate', count: 3, mark: 1 },
{ hullId: 'transport', count: 1, mark: 1 },
]);
check('colonyAt (orbit-blind) resolves to the ally here, confirming the bug precondition is real',
Logic.colonyAt(stM, starIdx)?.empireIdx === 1);
const noOrbit = Logic.invasionForecast(RULES, stM, 0, starIdx);
const byEnemyOrbit = Logic.invasionForecast(RULES, stM, 0, starIdx, enemyColony.orbit);
const byAllyOrbit = Logic.invasionForecast(RULES, stM, 0, starIdx, allyColony.orbit);
check('invasionForecast without an orbit falls back to the at-war colony, not the ally',
noOrbit?.troops > 0 && noOrbit.defenders === byEnemyOrbit?.defenders,
`${JSON.stringify(noOrbit)} vs ${JSON.stringify(byEnemyOrbit)}`);
check('an explicit orbit actually changes which colony is targeted',
byAllyOrbit?.defenders !== byEnemyOrbit?.defenders,
`ally ${byAllyOrbit?.defenders} vs enemy ${byEnemyOrbit?.defenders}`);
check('invade pinned to the ally\'s own orbit correctly refuses (not at war), even though it is a real colony here',
Logic.invade(RULES, stM, 0, starIdx, allyColony.orbit) === null);
// The actual reported bug: no orbit passed at all (VegaAI.js's calling
// convention), with the ally still ahead of the enemy in state.colonies.
const bombardResult = Logic.bombard(RULES, stM, 0, starIdx);
check('bombard without an orbit correctly reaches the hostile colony sharing the star',
!!bombardResult, JSON.stringify(bombardResult));
}
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -1592,6 +1673,81 @@ section('6. Colony economy');
c.focus = 'manual'; c.focus = 'manual';
} }
// --- Fleet Production's weighted 4:3:2:1 mix (frigate:destroyer:cruiser:
// battleship). Grants full weapon tech and maxes the ships slider so every
// warship hull clears pickFleet's damage>0 and affordability gates —
// otherwise this would be testing which hulls got filtered out, not the
// mix logic itself.
{
const c = st.colonies[0];
const emp = st.empires[c.empireIdx];
const savedKnown = { ...emp.known };
const savedSliders = { ...c.sliders };
const savedFleets = st.fleets;
const savedPop = c.pop;
for (const t of RULES.techsByField.weapons) emp.known[t.id] = true;
// empireDesign memoizes per-empire designs keyed on emp.techsKnown, which
// a direct emp.known mutation (unlike Logic.grantTech) never bumps — null
// the cache out by hand or empireDesign silently keeps returning the
// pre-grant (no-weapon) design below.
emp._designs = null; emp._designsAt = -1;
emp._comps = null; emp._compsAt = -1;
Logic.setSlider(RULES, st, c, 'ships', 1);
// A fresh homeworld's production can't clear battleship's cost*15
// affordability gate on its own (that's a real, correct game-balance
// fact, not a bug) — overridden here purely so this check can exercise
// all four warship hulls at once rather than skipping the top of the mix.
c.pop = Logic.colonyMaxPop(RULES, st, c) * 3;
const warshipIds = RULES.hullList.filter((h) => h.role === 'warship').map((h) => h.id);
const allAffordable = warshipIds.every((id) => {
const d = Logic.empireDesign(RULES, st, c.empireIdx, id);
return d.damage > 0 && d.cost <= Logic.colonyBuildRate(RULES, st, c) * 15;
});
if (allAffordable) {
// Unambiguous minimum: battleship is the only hull under its target
// share (ratio 0 vs 1.0 for the other three), so it must win regardless
// of score tie-breaking.
st.fleets = [];
Logic.addFleet(RULES, st, c.empireIdx, c.starIdx,
[{ hullId: 'frigate', count: 4, mark: 1 }, { hullId: 'destroyer', count: 3, mark: 1 },
{ hullId: 'cruiser', count: 2, mark: 1 }]);
c.queue.length = 0;
c.focus = 'fleet';
Logic.autoQueueColonies(RULES, st, c.empireIdx);
check('fleet mix picks the sole hull strictly below its target share',
c.queue[0]?.id === 'battleship', c.queue[0]?.id);
// A full 10-ship cycle from empty must land exactly on 4:3:2:1 — the
// weighted round-robin's defining property — no matter how the score
// tie-break orders picks along the way.
st.fleets = [];
const counts = { frigate: 0, destroyer: 0, cruiser: 0, battleship: 0 };
for (let i = 0; i < 10; i += 1) {
c.queue.length = 0;
Logic.autoQueueColonies(RULES, st, c.empireIdx);
const picked = c.queue[0]?.id;
counts[picked] = (counts[picked] ?? 0) + 1;
Logic.addFleet(RULES, st, c.empireIdx, c.starIdx, [{ hullId: picked, count: 1, mark: 1 }]);
}
check('one full mix cycle (10 ships) lands exactly on 4:3:2:1',
counts.frigate === 4 && counts.destroyer === 3 && counts.cruiser === 2 && counts.battleship === 1,
JSON.stringify(counts));
} else {
check('fleet mix hull-set is affordable/tech-eligible for the 4:3:2:1 checks above (skipped)',
true, 'skipped: not all warship hulls cleared damage/affordability at this budget');
}
st.fleets = savedFleets;
emp.known = savedKnown;
emp._designs = null; emp._designsAt = -1;
emp._comps = null; emp._compsAt = -1;
c.sliders = savedSliders;
c.pop = savedPop;
c.queue.length = 0;
c.focus = 'manual';
}
// --- Advisor recommendations: recommendColonyFocus / recommendAllocationFocus // --- Advisor recommendations: recommendColonyFocus / recommendAllocationFocus
// / checkAdvisorRecommendations. Uses a throwaway state so nothing here // / checkAdvisorRecommendations. Uses a throwaway state so nothing here
// needs restoring afterward. // needs restoring afterward.
@ -1775,6 +1931,40 @@ section('6b. Founding vignette and the turn report');
} }
} }
// Brian's ask: multiple copies of the same hull finishing at the same
// colony in one turn used to render as N identical "Frigate completed at
// Sol." rows in the New Turn report — groupShipDoneEvents collapses them
// into one row with a count.
{
const stG = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'spiral', difficultyId: 'normal', seed: 7171,
speciesIds: ['human', 'kkrix'], humanIndex: 0,
});
const homeStar = stG.empires[0].homeStar;
const mk = (colonyId, hullId, empire = 0) => ({ type: 'shipDone', empire, colonyId, hullId, starIdx: homeStar, turn: 1 });
const events = [
mk(1, 'frigate'), mk(1, 'frigate'), mk(1, 'frigate'), // 3 frigates, same colony
mk(1, 'destroyer'), // a different hull, same colony — stays separate
mk(2, 'frigate'), // same hull, DIFFERENT colony — stays separate
{ type: 'techDone', empire: 0, techId: 'lasercannon', turn: 1 }, // non-shipDone — passes through untouched
];
const grouped = groupShipDoneEvents(events);
check('groupShipDoneEvents collapses same-colony same-hull duplicates into one row',
grouped.length === 4, `${grouped.length}`);
const frigateAt1 = grouped.find((ev) => ev.type === 'shipDone' && ev.colonyId === 1 && ev.hullId === 'frigate');
check('the collapsed row counts every duplicate', frigateAt1?.count === 3, `${frigateAt1?.count}`);
check('a different hull at the same colony is not folded in',
grouped.find((ev) => ev.hullId === 'destroyer')?.count === 1);
check('the same hull at a different colony is not folded in',
grouped.find((ev) => ev.colonyId === 2 && ev.hullId === 'frigate')?.count === 1);
check('non-shipDone events pass through untouched',
grouped.some((ev) => ev.type === 'techDone'));
check('ungrouped shipDone rows (count 1) still read as singular',
!describeEvent(RULES, stG, grouped.find((ev) => ev.hullId === 'destroyer')).headline.includes('×'));
check('a grouped shipDone row headlines the count',
describeEvent(RULES, stG, frigateAt1).headline.startsWith('3× '), describeEvent(RULES, stG, frigateAt1).headline);
}
// Every number the vignette reads out, on a colony one tick old, for every // Every number the vignette reads out, on a colony one tick old, for every
// colonisable world type any species could land on. These are called before // colonisable world type any species could land on. These are called before
// the colony has ever been processed, which is a state no other screen sees. // the colony has ever been processed, which is a state no other screen sees.
@ -1875,6 +2065,29 @@ section('7. Diplomacy and the Galactic Council');
return !Diplo.canNegotiate(RULES, st, 0, 3); return !Diplo.canNegotiate(RULES, st, 0, 3);
})()); })());
// A diplomacy-incapable species at war and clearly losing must never queue
// a held peace offer for the human — its Seek Audience button is
// permanently disabled (VegaScreens.js/MasterOfVegaGame.js), so a queued
// offer would leave an un-clearable "seeks an audience" card up with
// nothing the human could ever do about it. 300 iterations at the
// underlying 12%-per-turn propose chance would almost certainly have
// caught at least one leak under the old (unguarded) behaviour.
{
const stL = Logic.createGame(RULES, {
sizeId: 'medium', shapeId: 'elliptical', seed: 55, difficultyId: 'normal',
speciesIds: ['human', 'lithox'], humanIndex: 0,
});
stL.rules = RULES;
stL.empires[0].contacted[1] = true;
stL.empires[1].contacted[0] = true;
stL.empires[0].totalPop = 900;
stL.empires[1].totalPop = 50; // lithox is losing badly: power ratio well under 0.55
Diplo.declareWar(RULES, stL, 1, 0);
for (let i = 0; i < 300; i += 1) Diplo.runDiplomacyTurn(RULES, stL, 1);
check('a losing diplomacy-incapable empire never queues a held peace offer',
!stL.empires[0].pendingOffers[1]);
}
Diplo.declareWar(RULES, st, 0, 1); Diplo.declareWar(RULES, st, 0, 1);
check('war is mutual', Logic.atWar(st, 0, 1) && Logic.atWar(st, 1, 0)); check('war is mutual', Logic.atWar(st, 0, 1) && Logic.atWar(st, 1, 0));
check('being attacked is resented', st.empires[1].attitude[0] < 0); check('being attacked is resented', st.empires[1].attitude[0] < 0);