Compare commits

...

3 Commits

Author SHA1 Message Date
Brian Fertig 167fdd6e3f refactor: replace scanline with diagonal shimmer and add orbiting border lights to VegaButton
- Replace horizontal scanline sweep with an occasional diagonal shimmer effect
  that crosses the button face at a 22° angle with a 1.5s pause between passes
- Add three white border lights that slowly orbit the button's stroke outline
  (~3.6s per full revolution), drawn above all other layers
- Reduce corner brackets from all four corners to upper-right and lower-left
  only, matching the chamfered panel's 45° cuts
- Extract reusable perimeter utilities (_computePerimeter, _perimeterPoint)
  cached once at construction to avoid per-frame arc-length recomputation
- Wire shimmer and border lights into hover enter/exit and destroy lifecycle
2026-08-10 21:43:34 -06:00
Brian Fertig 4c3b17610a refactor(mastervega): consolidate HUD buttons into Empire dropdown and fix ghost button styling
- Replace four separate HUD buttons (Research, Diplomacy, Council, Leaders)
  with a single "Empire" dropdown button, freeing HUD space for future
  buttons like "Colonies"
- Add toggleEmpireMenu/openEmpireMenu/closeEmpireMenu with a popover panel
  containing the four options, click-outside-to-dismiss, and ▸/▾ indicator
  on the button label
- Fix ghost button glow washout by introducing GHOST_ALPHA (0.85) constant
  and using it instead of hardcoded 0.3 alpha for ghost button backgrounds
- Change ghost button hover text color from glow color to textDark for
  consistent readability
- Remove unused glowHex from SCHEMES config
2026-08-10 21:30:15 -06:00
Brian Fertig e1b5ae7277 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.
2026-08-10 19:37:57 -06:00
18 changed files with 808 additions and 61 deletions

View File

@ -6,7 +6,7 @@
import * as Phaser from 'phaser';
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 { Tooltip } from '../../ui/Tooltip.js';
import { VegaMusic } from './VegaMusic.js';
@ -47,6 +47,17 @@ const SAVE_KEY = 'mastervega-save';
const SAVE_SLOT_COUNT = 10;
const saveSlotKey = (i) => `mastervega-save-slot-${i}`;
// The Empire button's own leading open/closed indicator — swapped by
// open/closeEmpireMenu, same ▸/▾ convention VegaTurnReportScreen.js uses
// per-row, applied here to a button label instead. The button's label is
// one centered Phaser Text (VegaButton.js has no separate icon slot), so
// the only way to nudge the triangle further from "Empire" without a new
// component is padding the string itself — the extra spaces push the
// triangle left and "Empire" right by equal amounts as the whole centered
// block widens.
const EMPIRE_CLOSED_LABEL = '▸ Empire';
const EMPIRE_OPEN_LABEL = '▾ Empire';
export default class MasterOfVegaGame extends Phaser.Scene {
constructor() { super('MasterOfVegaGame'); }
@ -648,27 +659,24 @@ export default class MasterOfVegaGame extends Phaser.Scene {
this.hudText = this.add.text(24, 18, '', { fontFamily: FONT, fontSize: '20px', color: '#cfe8ff' });
hud.add(this.hudText);
const mk = (label, x, fn) => {
const b = new Button(this, x, 31, label, this.uiClick(fn), { width: 150, height: 42, fontSize: 18 });
hud.add(b);
return b;
};
mk('Research', GAME_WIDTH - 830, () => this.openModal((done) =>
openResearchScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done)));
mk('Diplomacy', GAME_WIDTH - 670, () => this.openModal((done) =>
openDiplomacyScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done,
() => this.refreshAll())));
mk('Council', GAME_WIDTH - 510, () => this.openModal((done) =>
openCouncilScreen(this, this.rules, this.state, done)));
mk('Leaders', GAME_WIDTH - 350, () => this.openModal((done) =>
openLeaderScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done,
() => this.refreshHud())));
// Research/Diplomacy/Leaders/Council used to be four separate HUD
// buttons; consolidated into one "Empire" dropdown (openEmpireMenu,
// near the game menu below) to leave room in the bar for more empire-
// management buttons later (Brian's plan: "Colonies" next) without the
// row running out of width. Parked immediately left of End Turn, same
// width, so the two read as a pair — the leading ▸/▾ (EMPIRE_CLOSED_LABEL/
// EMPIRE_OPEN_LABEL, swapped by open/closeEmpireMenu) is this button's
// own open/closed indicator, not the same glyph VegaTurnReportScreen.js
// uses per-row, just the same visual convention.
this.empireBtn = new Button(this, GAME_WIDTH - 340, 31, EMPIRE_CLOSED_LABEL,
this.uiClick(() => this.toggleEmpireMenu()), { width: 190, height: 42, fontSize: 18 });
hud.add(this.empireBtn);
// Not this.uiClick — onEndTurn plays its own vega-endturn cue (shared with
// combat's Next round button) rather than the generic click, and a no-op
// press while busy/modal should stay silent.
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);
// --- status log, bottom left
@ -749,6 +757,72 @@ export default class MasterOfVegaGame extends Phaser.Scene {
this.gameMenuLayer = null;
}
// -------------------------------------------------------------- empire menu
/** Empire toggles a small popover dropping down from directly below it:
* Research, Diplomacy, Leaders, Council, in that order (Brian's ask).
* Same shape as the game menu above click-anywhere-to-dismiss veil,
* ghost-variant item buttons just anchored under the HUD bar instead of
* above the button. Gated by modalOpen like every other HUD button. */
toggleEmpireMenu() {
if (this.modalOpen) return;
if (this.empireMenuLayer) { this.closeEmpireMenu(); return; }
this.openEmpireMenu();
}
openEmpireMenu() {
this.empireBtn.setLabel(EMPIRE_OPEN_LABEL);
const layer = this.add.container(0, 0).setDepth(D.modal);
this.empireMenuLayer = layer;
const catcher = this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.001)
.setOrigin(0, 0).setInteractive();
catcher.on('pointerup', () => this.closeEmpireMenu());
layer.add(catcher);
const items = [
['Research', () => this.openModal((done) =>
openResearchScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done))],
['Diplomacy', () => this.openModal((done) =>
openDiplomacyScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done,
() => this.refreshAll()))],
['Leaders', () => this.openModal((done) =>
openLeaderScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done,
() => this.refreshHud()))],
['Council', () => this.openModal((done) =>
openCouncilScreen(this, this.rules, this.state, done))],
];
const BTN_W = 200;
const BTN_H = 42;
const GAP = 8;
const PAD = 14;
const panelH = PAD * 2 + items.length * BTN_H + (items.length - 1) * GAP;
// Left-aligned under the (190-wide) Empire button, regardless of the
// dropdown panel's own width — reads empireBtn's actual width rather
// than a second hardcoded constant that could drift if either changes.
const panelCx = this.empireBtn.x - this.empireBtn.options.width / 2 + BTN_W / 2;
const panelTopY = 31 + 21 + 12; // just below the Empire button (centred at y=31, height 42)
const panelCy = panelTopY + panelH / 2;
layer.add(this.add.rectangle(panelCx, panelCy, BTN_W + PAD * 2, panelH, 0x0b1220, 0.97)
.setStrokeStyle(1.5, 0x6fc4ff, 0.55));
let by = panelCy - panelH / 2 + PAD + BTN_H / 2;
for (const [label, fn] of items) {
const btn = new Button(this, panelCx, by, label, this.uiClick(() => { this.closeEmpireMenu(); fn(); }),
{ width: BTN_W, height: BTN_H, fontSize: 16, variant: 'ghost' });
layer.add(btn);
by += BTN_H + GAP;
}
}
closeEmpireMenu() {
this.empireBtn.setLabel(EMPIRE_CLOSED_LABEL);
this.empireMenuLayer?.destroy();
this.empireMenuLayer = null;
}
// Both destinations auto-save to the single "Resume Game" slot on the way
// out, same safety net the old ← Menu button always had — independent of,
// and in addition to, the 10 manual slots below.
@ -820,9 +894,15 @@ export default class MasterOfVegaGame extends Phaser.Scene {
const GAP = 10;
const X = 24;
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)
.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) => {
const other = this.state.empires[otherIdx];
const cy = GAME_HEIGHT - 168 - 16 - row * (H + GAP) - H / 2;

View File

@ -35,7 +35,7 @@
import * as Phaser from 'phaser';
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 { FONT, D, uiClick } from './VegaScreens.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. */
function layoutRow(list, y, extraOpts = {}) {
const perRow = 3;
list.forEach(([label, fn], i) => {
list.forEach(([label, fn, itemOpts], i) => {
const col = i % perRow;
const row = Math.floor(i / perRow);
const x = CHAT_X + col * (BTN_W + BTN_GAP) + BTN_W / 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;
}
@ -749,15 +749,15 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
items.push(['We\'re Staying', doComplaintDefy]);
}
if (offer) {
items.push(['Accept', () => doPending(true)]);
items.push(['Reject', () => doPending(false)]);
items.push(['Accept', () => doPending(true), { scheme: 'green' }]);
items.push(['Reject', () => doPending(false), { scheme: 'red' }]);
} 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)) {
items.push(['Sue for Peace', () => doPropose('peace')]);
if (giftAffordable) items.push(['Send Gift', openGiftPicker]);
} else {
items.push(['Declare War', doDeclareWar]);
items.push(['Declare War', doDeclareWar, { scheme: 'red' }]);
if (state.empires[me].treaties[otherIdx] !== 'alliance') {
items.push(['Propose Alliance', () => doPropose('alliance')]);
}

View File

@ -0,0 +1,343 @@
// 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 hover-only animations —
// two upper-right/lower-left corner brackets snapping outward like a
// targeting reticle acquiring a lock, an occasional diagonal shimmer
// crossing the face, and a few small white lights slowly orbiting the
// button's own stroke outline, drawn above everything else in the button.
import * as Phaser from 'phaser';
const CHAMFER = 14;
const SCHEMES = {
cyan: { glow: 0x2fe3ff, textDark: '#04121e' },
magenta: { glow: 0xff2fd0, textDark: '#1a0414' },
red: { glow: 0xff3355, textDark: '#1a0406' },
green: { glow: 0x2bff9e, textDark: '#04160e' },
};
const PANEL = 0x0b1220;
const TEXT = '#e8f4ff';
// Every button carries an always-on postFX glow on its border (see
// bgRect.postFX.addGlow below), meant to read as "dark button, glowing
// edge." Solid buttons (fill alpha 1) stay dark under that glow because
// the opaque fill visually dominates it. Ghost buttons don't have that
// luxury — at a low fill alpha there isn't enough opaque dark color to
// resist the glow, so the whole button washes out to a flat bright cyan
// instead of reading as translucent-with-a-glowing-edge. 0.85 gives ghost
// buttons enough opacity to hold their own against the glow while staying
// visibly lighter/less final than a solid button.
const GHOST_ALPHA = 0.85;
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 };
// Cached once — _chamferPoints() only depends on width/height, which
// never change after construction — so the border-lights orbit
// (_perimeterPoint) doesn't recompute the polygon's arc-length table
// on every animation frame.
this._perimeter = this._computePerimeter();
const isGhost = variant === 'ghost';
this.bgRect = scene.add.graphics();
this.bgRect.postFX.addGlow(scm.glow, 3, 0, false, 0.1, 10);
// Shimmer + 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.shimmer = scene.add.graphics().setAlpha(0);
this.shimmer.setMask(new Phaser.Display.Masks.GeometryMask(scene, this.fxMaskShape));
this.brackets = scene.add.graphics().setAlpha(0);
// Unmasked (unlike shimmer/brackets, which stay inside or hug the
// panel) — its points are computed directly on the stroke's own path,
// so it never needs clipping, and it is added to the container LAST,
// below, so it renders above bgRect's stroke rather than under it.
this.borderLights = scene.add.graphics().setAlpha(0);
this._drawBg(bg, isGhost ? GHOST_ALPHA : 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);
// borderLights is last — Phaser Containers render children in insertion
// order regardless of individual depth, so this is what actually puts
// it above bgRect's stroke (and everything else) rather than under it.
this.add([this.bgRect, this.fxMaskShape, this.shimmer, this.brackets, this.text, this.borderLights]);
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.textDark);
} else {
this._drawBg(s.glow, 1);
this.text.setColor(s.textDark);
}
this._startShimmer();
this._startBorderLights();
this._drawBrackets(true);
};
const onOut = () => {
if (this._active) return;
const { bg: b, textColor: tc, variant: v } = this.options;
this._drawBg(b, v === 'ghost' ? GHOST_ALPHA : 1);
this.text.setColor(tc);
this._stopShimmer();
this._stopBorderLights();
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._stopShimmer();
this._stopBorderLights();
});
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;
// Only the upper-right and lower-left corners (Brian's ask) — these are
// also the two the chamfered panel itself cuts at 45° (_chamferPoints),
// so the brackets echo the panel's own shape instead of framing all
// four corners generically.
const corners = [
{ 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();
}
}
// A tall, narrow, tilted bright band tweened across in X — much taller
// than the button so the tilt never uncovers the top/bottom edge as it
// crosses — clipped to the chamfer shape by the shimmer/fxMaskShape mask
// set up in the constructor, then a repeatDelay pause before it crosses
// again: "occasional", not a continuous back-and-forth sweep.
_startShimmer() {
this._stopShimmer();
const { width, height } = this.options;
const hw = width / 2;
const margin = height; // clears the band fully off both sides at rest
this.shimmer.clear();
this.shimmer.fillStyle(0xffffff, 0.5);
this.shimmer.fillRect(-12, -height * 2, 24, height * 4);
this.shimmer.rotation = Phaser.Math.DegToRad(22);
this.shimmer.x = -hw - margin;
this.shimmer.y = 0;
this.shimmer.setAlpha(1);
this.brackets.setAlpha(1);
this._shimmerTween = this.scene.tweens.add({
targets: this.shimmer,
x: hw + margin,
duration: 550,
repeat: -1,
repeatDelay: 1500,
ease: 'Cubic.easeInOut',
});
}
_stopShimmer() {
if (this._shimmerTween) {
this._shimmerTween.stop();
this._shimmerTween = null;
}
this.shimmer.setAlpha(0);
this.brackets.setAlpha(0);
}
// Arc-length table for _perimeterPoint: cumulative distance walked around
// the chamfered polygon, point by point, closing back to the start.
_computePerimeter() {
const pts = this._chamferPoints();
const cum = [0];
for (let i = 0; i < pts.length; i += 1) {
const a = pts[i];
const b = pts[(i + 1) % pts.length];
cum.push(cum[i] + Phaser.Math.Distance.Between(a.x, a.y, b.x, b.y));
}
return { pts, cum, total: cum[cum.length - 1] };
}
/** Point at `frac` (0..1, wraps) of the way around the perimeter. */
_perimeterPoint(frac) {
const { pts, cum, total } = this._perimeter;
const target = (((frac % 1) + 1) % 1) * total;
for (let i = 0; i < pts.length; i += 1) {
if (target <= cum[i + 1] || i === pts.length - 1) {
const a = pts[i];
const b = pts[(i + 1) % pts.length];
const segLen = cum[i + 1] - cum[i];
const t = segLen > 0 ? (target - cum[i]) / segLen : 0;
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
}
}
return pts[0];
}
// A few short white segments, evenly spaced around the perimeter, redrawn
// each tick as `phase` advances — the "orbiting lights" effect. Each
// segment is a straight line between two perimeter points rather than a
// true corner-following polyline, so a segment straddling a corner cuts
// it very slightly; negligible at this segment length, not worth a
// multi-point path for.
_drawBorderLights(phase) {
const LIGHT_COUNT = 3;
const LIGHT_FRAC = 0.05;
this.borderLights.clear();
this.borderLights.lineStyle(3, 0xffffff, 0.9);
for (let i = 0; i < LIGHT_COUNT; i += 1) {
const start = phase + i / LIGHT_COUNT;
const a = this._perimeterPoint(start);
const b = this._perimeterPoint(start + LIGHT_FRAC);
this.borderLights.beginPath();
this.borderLights.moveTo(a.x, a.y);
this.borderLights.lineTo(b.x, b.y);
this.borderLights.strokePath();
}
}
_startBorderLights() {
this._stopBorderLights();
const counter = { phase: 0 };
this.borderLights.setAlpha(1);
this._lightsTween = this.scene.tweens.add({
targets: counter,
phase: 1,
duration: 3600, // slow, per Brian's ask
repeat: -1,
onUpdate: () => this._drawBorderLights(counter.phase),
});
}
_stopBorderLights() {
if (this._lightsTween) {
this._lightsTween.stop();
this._lightsTween = null;
}
this.borderLights.clear();
this.borderLights.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' ? GHOST_ALPHA : 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 { 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 { FONT, D, ORBIT, uiClick } from './VegaScreens.js';
import { turnToYear } from './VegaRules.js';

View File

@ -33,7 +33,7 @@
import * as Phaser from 'phaser';
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 { FONT, D, ORBIT, slider, uiClick } from './VegaScreens.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: '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: '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: 'trade', label: 'Trade & Commerce', desc: 'Queues buildings that raise trade income.' },
{ value: 'defense', label: 'Homeworld Defense', desc: 'Queues planetary defense buildings.' },

View File

@ -21,7 +21,7 @@
import * as Phaser from 'phaser';
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 { compileRules, markNumeral } from './VegaRules.js';
import { ensureSheets } from './VegaArt.js';

View File

@ -7,7 +7,7 @@
import * as Phaser from 'phaser';
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 { FONT, D, uiClick } from './VegaScreens.js';
import VegaFx from './VegaFx.js';
@ -205,7 +205,7 @@ export function openCombatView(scene, rules, battle, art, opts = {}) {
playSound(scene, SFX.VEGA_ENDTURN);
animate(stepRound(battle, {}));
if (battle.done) scene.time.delayedCall(700, finish);
}, { width: 220, height: 52 });
}, { width: 220, height: 52, scheme: 'magenta' });
layer.add(next);
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 { 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 { FONT, D, uiClick } from './VegaScreens.js';
import VegaFx from './VegaFx.js';

View File

@ -275,8 +275,17 @@ export function runDiplomacyTurn(rules, state, e) {
if (atWar(state, e, other.idx)) {
// 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
// that ends on the turn it starts accomplishes nothing.
if (ratio < 0.55 && rand(state) < 0.12) proposeOrOffer(rules, state, e, other.idx, 'peace');
// that ends on the turn it starts accomplishes nothing. Gated on
// 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;
}

View File

@ -1513,6 +1513,24 @@ function deliverPopulation(rules, state, f) {
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.
// Bombardment and invasion both require it.
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,
// not one empire in eight games was ever eliminated. Bombing also thins the
// defenders for a subsequent landing, so the two mechanics work together.
export function bombard(rules, state, e, starIdx) {
const colony = colonyAt(state, starIdx);
export function bombard(rules, state, e, starIdx, orbit = null) {
const colony = targetColonyAt(state, e, starIdx, orbit);
if (!colony || colony.empireIdx === e) return null;
if (!atWar(state, e, 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
// was committing every transport it had to hopeless landings (1971 failures
// against 130 successes) purely because nothing told it the odds.
export function invasionForecast(rules, state, e, starIdx) {
const colony = colonyAt(state, starIdx);
export function invasionForecast(rules, state, e, starIdx, orbit = null) {
const colony = targetColonyAt(state, e, starIdx, orbit);
if (!colony || colony.empireIdx === e) return null;
const troops = state.fleets
.filter((f) => f.starIdx === starIdx && f.empireIdx === e)
@ -1608,8 +1626,8 @@ export function invasionForecast(rules, state, e, starIdx) {
return { troops, defenders, odds, favourable };
}
export function invade(rules, state, e, starIdx) {
const colony = colonyAt(state, starIdx);
export function invade(rules, state, e, starIdx, orbit = null) {
const colony = targetColonyAt(state, e, starIdx, orbit);
if (!colony || colony.empireIdx === e) return null;
if (!atWar(state, e, colony.empireIdx)) return null;
// Holding orbit means orbital SUPERIORITY, not an empty sky.
@ -1987,12 +2005,23 @@ function pickDefense(rules, state, colony) {
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
// (hp + damage*4) / cost scoring, same ~15-turns-of-budget affordability
// 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
// best-value hull, it counts what's already docked at this colony's star and
// favours whichever warship role is least represented, ties broken by score.
// shared), plus a weighted diversity pass: rather than always maxing out the
// single best-value hull, it counts what's already docked at this colony's
// 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) {
const e = colony.empireIdx;
const budget = colonyBuildRate(rules, state, colony);
@ -2005,16 +2034,18 @@ function pickFleet(rules, state, colony) {
}
}
let best = null;
let bestCount = Infinity;
let bestRatio = Infinity;
let bestScore = -Infinity;
for (const hullId of hullIds) {
const d = empireDesign(rules, state, e, hullId);
if (d.damage <= 0) continue;
if (d.cost > budget * 15) continue;
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;
if (count < bestCount || (count === bestCount && score > bestScore)) {
best = hullId; bestCount = count; bestScore = score;
if (ratio < bestRatio || (ratio === bestRatio && score > bestScore)) {
best = hullId; bestRatio = ratio; bestScore = score;
}
}
// Before any weapon tech is known every hull scores damage=0 and `best`
@ -2106,7 +2137,7 @@ export function recommendColonyFocus(rules, state, colony) {
return {
value: 'fleet', label: 'Fleet Production',
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 { 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 {
empireColonies, empireFleets, hireLeader, assignLeader, unassignLeader, leaderHireCost,

View File

@ -18,7 +18,7 @@
import * as Phaser from 'phaser';
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 { empireDesign } from './VegaLogic.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;
// 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
// "best tier" is all we ever need to know.
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.weapon = out.beams[0] ?? out.missiles[0] ?? null;
out.allWeapons = weapons;
if (!out.allWeapons.length) {
out.allWeapons = [BASELINE_WEAPON];
out.beams = [BASELINE_WEAPON];
out.weapon = BASELINE_WEAPON;
}
return out;
}

View File

@ -22,7 +22,7 @@
import * as Phaser from 'phaser';
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 { parsecs } from './VegaGalaxyGen.js';
import {
@ -759,11 +759,11 @@ export default class VegaSidePanel {
// click, and a fuel-range refusal should stay silent either way.
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 });
{ width: half, height: 48, fontSize: 20, scheme: 'green' });
if (refusal) accept.setEnabled?.(false);
this.body.add(accept);
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;
}
}

View File

@ -14,7 +14,7 @@
import * as Phaser from 'phaser';
import { Button } from '../../ui/Button.js';
import { Button } from './VegaButton.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { modalShell, FONT, ORBIT, uiClick } from './VegaScreens.js';
import { planetFrame, starFrame } from './VegaArt.js';
@ -216,7 +216,7 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
if (!mine) {
// Enemy colony: offer the two things a fleet in orbit can actually do.
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
? `${forecast.troops} marines vs ~${forecast.defenders} defenders — ${Math.round(forecast.odds * 100)}% per exchange`
: 'No troop transports in orbit.', {
@ -224,12 +224,35 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
wordWrap: { width: panelW },
}));
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, () => {
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 }));
if (forecast && forecast.troops > 0) {
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 }));
}
}

View File

@ -132,7 +132,36 @@ function describeBuildingDone(rules, state, ev) {
function describeShipDone(rules, state, ev) {
const h = rules.hulls[ev.hullId];
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

View File

@ -17,8 +17,8 @@ import { makeShipIcon, makeCommanderPortrait } from './VegaShipMedia.js';
import { openShipDetail } from './VegaShipDetail.js';
import { buildingFrame } from './VegaArt.js';
import { turnToYear } from './VegaRules.js';
import { Button } from '../../ui/Button.js';
import { describeEvent, categoryWeight } from './VegaTurnReport.js';
import { Button } from './VegaButton.js';
import { describeEvent, categoryWeight, groupShipDoneEvents } from './VegaTurnReport.js';
import { enqueue, empireColonies, colonyTrade } from './VegaLogic.js';
// 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);
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) }))
.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';
// Turn-report classification is Phaser-free, so what the "New Turn" popup will
// 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
// checkable without a canvas.
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);
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', (() => {
const c = Ships.refitCost(RULES, known, 'cruiser', 1, RULES.species.human.traits);
return Number.isFinite(c) && c > 0;
@ -1292,6 +1318,61 @@ section('4c. Population transport');
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';
}
// --- 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
// / checkAdvisorRecommendations. Uses a throwaway state so nothing here
// 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
// 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.
@ -1875,6 +2065,29 @@ section('7. Diplomacy and the Galactic Council');
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);
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);