From e1b5ae7277edcd303c8adeaa732c0d4b913e27e3 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Mon, 10 Aug 2026 19:37:57 -0600 Subject: [PATCH] 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. --- src/games/mastervega/MasterOfVegaGame.js | 12 +- src/games/mastervega/VegaAudience.js | 14 +- src/games/mastervega/VegaButton.js | 231 +++++++++++++++++++ src/games/mastervega/VegaColonyIntro.js | 2 +- src/games/mastervega/VegaColonyView.js | 4 +- src/games/mastervega/VegaCombatSim.js | 2 +- src/games/mastervega/VegaCombatView.js | 4 +- src/games/mastervega/VegaCombatViewV2.js | 2 +- src/games/mastervega/VegaDiplomacy.js | 13 +- src/games/mastervega/VegaLogic.js | 57 +++-- src/games/mastervega/VegaScreens.js | 2 +- src/games/mastervega/VegaShipDetail.js | 2 +- src/games/mastervega/VegaShips.js | 19 ++ src/games/mastervega/VegaSidePanel.js | 6 +- src/games/mastervega/VegaSystemView.js | 31 ++- src/games/mastervega/VegaTurnReport.js | 31 ++- src/games/mastervega/VegaTurnReportScreen.js | 6 +- tools/verifyMasterOfVega.js | 215 ++++++++++++++++- 18 files changed, 607 insertions(+), 46 deletions(-) create mode 100644 src/games/mastervega/VegaButton.js diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index 9fb1c09..37257d6 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -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'; @@ -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 // 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 @@ -820,9 +820,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; diff --git a/src/games/mastervega/VegaAudience.js b/src/games/mastervega/VegaAudience.js index 7491288..f731026 100644 --- a/src/games/mastervega/VegaAudience.js +++ b/src/games/mastervega/VegaAudience.js @@ -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')]); } diff --git a/src/games/mastervega/VegaButton.js b/src/games/mastervega/VegaButton.js new file mode 100644 index 0000000..8f288c8 --- /dev/null +++ b/src/games/mastervega/VegaButton.js @@ -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; + } +} diff --git a/src/games/mastervega/VegaColonyIntro.js b/src/games/mastervega/VegaColonyIntro.js index 73f88a0..3c189e8 100644 --- a/src/games/mastervega/VegaColonyIntro.js +++ b/src/games/mastervega/VegaColonyIntro.js @@ -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'; diff --git a/src/games/mastervega/VegaColonyView.js b/src/games/mastervega/VegaColonyView.js index b916b28..65fe7f2 100644 --- a/src/games/mastervega/VegaColonyView.js +++ b/src/games/mastervega/VegaColonyView.js @@ -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.' }, diff --git a/src/games/mastervega/VegaCombatSim.js b/src/games/mastervega/VegaCombatSim.js index d0db2d5..8851de0 100644 --- a/src/games/mastervega/VegaCombatSim.js +++ b/src/games/mastervega/VegaCombatSim.js @@ -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'; diff --git a/src/games/mastervega/VegaCombatView.js b/src/games/mastervega/VegaCombatView.js index f1c5b0a..e054ff1 100644 --- a/src/games/mastervega/VegaCombatView.js +++ b/src/games/mastervega/VegaCombatView.js @@ -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, () => { diff --git a/src/games/mastervega/VegaCombatViewV2.js b/src/games/mastervega/VegaCombatViewV2.js index cde2275..ab734e4 100644 --- a/src/games/mastervega/VegaCombatViewV2.js +++ b/src/games/mastervega/VegaCombatViewV2.js @@ -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'; diff --git a/src/games/mastervega/VegaDiplomacy.js b/src/games/mastervega/VegaDiplomacy.js index 27e2bc4..e28b489 100644 --- a/src/games/mastervega/VegaDiplomacy.js +++ b/src/games/mastervega/VegaDiplomacy.js @@ -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; } diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index a32b6d6..99c1fa3 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -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.', }; } diff --git a/src/games/mastervega/VegaScreens.js b/src/games/mastervega/VegaScreens.js index 9cb4661..26ab064 100644 --- a/src/games/mastervega/VegaScreens.js +++ b/src/games/mastervega/VegaScreens.js @@ -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, diff --git a/src/games/mastervega/VegaShipDetail.js b/src/games/mastervega/VegaShipDetail.js index 54d77d0..e1d4710 100644 --- a/src/games/mastervega/VegaShipDetail.js +++ b/src/games/mastervega/VegaShipDetail.js @@ -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'; diff --git a/src/games/mastervega/VegaShips.js b/src/games/mastervega/VegaShips.js index 3a815c3..ce704ba 100644 --- a/src/games/mastervega/VegaShips.js +++ b/src/games/mastervega/VegaShips.js @@ -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; } diff --git a/src/games/mastervega/VegaSidePanel.js b/src/games/mastervega/VegaSidePanel.js index 7209561..c8e855d 100644 --- a/src/games/mastervega/VegaSidePanel.js +++ b/src/games/mastervega/VegaSidePanel.js @@ -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; } } diff --git a/src/games/mastervega/VegaSystemView.js b/src/games/mastervega/VegaSystemView.js index b918016..fd0435e 100644 --- a/src/games/mastervega/VegaSystemView.js +++ b/src/games/mastervega/VegaSystemView.js @@ -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 })); } } diff --git a/src/games/mastervega/VegaTurnReport.js b/src/games/mastervega/VegaTurnReport.js index 2ea65fd..3b388c2 100644 --- a/src/games/mastervega/VegaTurnReport.js +++ b/src/games/mastervega/VegaTurnReport.js @@ -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 diff --git a/src/games/mastervega/VegaTurnReportScreen.js b/src/games/mastervega/VegaTurnReportScreen.js index b4e1fbf..892ceda 100644 --- a/src/games/mastervega/VegaTurnReportScreen.js +++ b/src/games/mastervega/VegaTurnReportScreen.js @@ -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); diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index 3555ef7..c05732d 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -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);