refactor(mastervega): cyberpunk button skin, fleet mix, orbit-aware targeting, and diplomacy guards
- Add VegaButton.js: a Master of Vega-local cyberpunk button with chamfered panels, neon glow, scanline sweep, and corner-bracket hover animations. Supports cyan/magenta/red/green schemes. - Swap Button imports across 12 Vega modules to use VegaButton.js and add scheme options (green for accept, red for reject/war, magenta for actions). - Fix multi-colony targeting in VegaLogic.js: introduce targetColonyAt() to resolve the correct colony by orbit when multiple exist at a star. Update bombard(), invasionForecast(), and invade() to accept an optional orbit parameter. Add immediate scene.log() feedback in VegaSystemView.js. - Implement weighted 4:3:2:1 fleet mix (frigate:destroyer:cruiser:battleship) in pickFleet(), replacing the old equal-count-per-hull diversity logic. - Add BASELINE_WEAPON fallback in VegaShips.js so warships with zero weapon tech still deal damage instead of silently doing nothing. - Guard VegaDiplomacy.js proposeOrOffer calls with canNegotiate to prevent diplomacy-incapable species (e.g., Lithox) from queuing peace offers the human can never answer. - Update verifyMasterOfVega.js with comprehensive tests for all new behavior.
This commit is contained in:
parent
c105ebd23c
commit
e1b5ae7277
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import * as Phaser from 'phaser';
|
import * as Phaser from 'phaser';
|
||||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from './VegaButton.js';
|
||||||
import { TextInput } from '../../ui/TextInput.js';
|
import { TextInput } from '../../ui/TextInput.js';
|
||||||
import { Tooltip } from '../../ui/Tooltip.js';
|
import { Tooltip } from '../../ui/Tooltip.js';
|
||||||
import { VegaMusic } from './VegaMusic.js';
|
import { VegaMusic } from './VegaMusic.js';
|
||||||
|
|
@ -668,7 +668,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
// combat's Next round button) rather than the generic click, and a no-op
|
// combat's Next round button) rather than the generic click, and a no-op
|
||||||
// press while busy/modal should stay silent.
|
// press while busy/modal should stay silent.
|
||||||
this.endTurnBtn = new Button(this, GAME_WIDTH - 130, 31, 'End turn', () => this.onEndTurn(),
|
this.endTurnBtn = new Button(this, GAME_WIDTH - 130, 31, 'End turn', () => this.onEndTurn(),
|
||||||
{ width: 190, height: 46, fontSize: 20 });
|
{ width: 190, height: 46, fontSize: 20, scheme: 'magenta' });
|
||||||
hud.add(this.endTurnBtn);
|
hud.add(this.endTurnBtn);
|
||||||
|
|
||||||
// --- status log, bottom left
|
// --- status log, bottom left
|
||||||
|
|
@ -820,9 +820,15 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
const GAP = 10;
|
const GAP = 10;
|
||||||
const X = 24;
|
const X = 24;
|
||||||
const PORTRAIT = 48;
|
const PORTRAIT = 48;
|
||||||
|
// canNegotiate here is a belt-and-suspenders guard, not the primary fix:
|
||||||
|
// runDiplomacyTurn (VegaDiplomacy.js) no longer lets a diplomacy-
|
||||||
|
// incapable species (Lithox) queue a pendingOffer in the first place, but
|
||||||
|
// this keeps a save written before that fix from resurrecting a stray
|
||||||
|
// "seeks an audience" card with a permanently-disabled button.
|
||||||
const otherIdxs = Object.keys(human.pendingOffers)
|
const otherIdxs = Object.keys(human.pendingOffers)
|
||||||
.map(Number)
|
.map(Number)
|
||||||
.filter((idx) => human.pendingOffers[idx] && this.state.empires[idx]?.alive);
|
.filter((idx) => human.pendingOffers[idx] && this.state.empires[idx]?.alive
|
||||||
|
&& canNegotiate(this.rules, this.state, this.state.humanIndex, idx));
|
||||||
otherIdxs.forEach((otherIdx, row) => {
|
otherIdxs.forEach((otherIdx, row) => {
|
||||||
const other = this.state.empires[otherIdx];
|
const other = this.state.empires[otherIdx];
|
||||||
const cy = GAME_HEIGHT - 168 - 16 - row * (H + GAP) - H / 2;
|
const cy = GAME_HEIGHT - 168 - 16 - row * (H + GAP) - H / 2;
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
|
|
||||||
import * as Phaser from 'phaser';
|
import * as Phaser from 'phaser';
|
||||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from './VegaButton.js';
|
||||||
import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js';
|
import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js';
|
||||||
import { FONT, D, uiClick } from './VegaScreens.js';
|
import { FONT, D, uiClick } from './VegaScreens.js';
|
||||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||||
|
|
@ -729,12 +729,12 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
|
||||||
/** Lays out one row (or wrapped rows) of [label, fn] pairs at `y`, 3 per row. Returns the row count. */
|
/** Lays out one row (or wrapped rows) of [label, fn] pairs at `y`, 3 per row. Returns the row count. */
|
||||||
function layoutRow(list, y, extraOpts = {}) {
|
function layoutRow(list, y, extraOpts = {}) {
|
||||||
const perRow = 3;
|
const perRow = 3;
|
||||||
list.forEach(([label, fn], i) => {
|
list.forEach(([label, fn, itemOpts], i) => {
|
||||||
const col = i % perRow;
|
const col = i % perRow;
|
||||||
const row = Math.floor(i / perRow);
|
const row = Math.floor(i / perRow);
|
||||||
const x = CHAT_X + col * (BTN_W + BTN_GAP) + BTN_W / 2;
|
const x = CHAT_X + col * (BTN_W + BTN_GAP) + BTN_W / 2;
|
||||||
const yy = y + row * (BTN_H + BTN_GAP) + BTN_H / 2;
|
const yy = y + row * (BTN_H + BTN_GAP) + BTN_H / 2;
|
||||||
buttonRow.add(new Button(scene, x, yy, label, uiClick(scene, fn), { width: BTN_W, height: BTN_H, fontSize: 16, ...extraOpts }));
|
buttonRow.add(new Button(scene, x, yy, label, uiClick(scene, fn), { width: BTN_W, height: BTN_H, fontSize: 16, ...extraOpts, ...itemOpts }));
|
||||||
});
|
});
|
||||||
return Math.ceil(list.length / perRow) || 0;
|
return Math.ceil(list.length / perRow) || 0;
|
||||||
}
|
}
|
||||||
|
|
@ -749,15 +749,15 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
|
||||||
items.push(['We\'re Staying', doComplaintDefy]);
|
items.push(['We\'re Staying', doComplaintDefy]);
|
||||||
}
|
}
|
||||||
if (offer) {
|
if (offer) {
|
||||||
items.push(['Accept', () => doPending(true)]);
|
items.push(['Accept', () => doPending(true), { scheme: 'green' }]);
|
||||||
items.push(['Reject', () => doPending(false)]);
|
items.push(['Reject', () => doPending(false), { scheme: 'red' }]);
|
||||||
} else if (!canNegotiate(rules, state, me, otherIdx)) {
|
} else if (!canNegotiate(rules, state, me, otherIdx)) {
|
||||||
if (!atWar(state, me, otherIdx)) items.push(['Declare War', doDeclareWar]);
|
if (!atWar(state, me, otherIdx)) items.push(['Declare War', doDeclareWar, { scheme: 'red' }]);
|
||||||
} else if (atWar(state, me, otherIdx)) {
|
} else if (atWar(state, me, otherIdx)) {
|
||||||
items.push(['Sue for Peace', () => doPropose('peace')]);
|
items.push(['Sue for Peace', () => doPropose('peace')]);
|
||||||
if (giftAffordable) items.push(['Send Gift', openGiftPicker]);
|
if (giftAffordable) items.push(['Send Gift', openGiftPicker]);
|
||||||
} else {
|
} else {
|
||||||
items.push(['Declare War', doDeclareWar]);
|
items.push(['Declare War', doDeclareWar, { scheme: 'red' }]);
|
||||||
if (state.empires[me].treaties[otherIdx] !== 'alliance') {
|
if (state.empires[me].treaties[otherIdx] !== 'alliance') {
|
||||||
items.push(['Propose Alliance', () => doPropose('alliance')]);
|
items.push(['Propose Alliance', () => doPropose('alliance')]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
import * as Phaser from 'phaser';
|
import * as Phaser from 'phaser';
|
||||||
|
|
||||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from './VegaButton.js';
|
||||||
import { TextInput } from '../../ui/TextInput.js';
|
import { TextInput } from '../../ui/TextInput.js';
|
||||||
import { FONT, D, ORBIT, uiClick } from './VegaScreens.js';
|
import { FONT, D, ORBIT, uiClick } from './VegaScreens.js';
|
||||||
import { turnToYear } from './VegaRules.js';
|
import { turnToYear } from './VegaRules.js';
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
import * as Phaser from 'phaser';
|
import * as Phaser from 'phaser';
|
||||||
|
|
||||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from './VegaButton.js';
|
||||||
import { Tooltip } from '../../ui/Tooltip.js';
|
import { Tooltip } from '../../ui/Tooltip.js';
|
||||||
import { FONT, D, ORBIT, slider, uiClick } from './VegaScreens.js';
|
import { FONT, D, ORBIT, slider, uiClick } from './VegaScreens.js';
|
||||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||||
|
|
@ -82,7 +82,7 @@ const COLONY_FOCUS_OPTIONS = [
|
||||||
{ value: 'manual', label: 'Manual', desc: 'No automation — you queue everything yourself.' },
|
{ value: 'manual', label: 'Manual', desc: 'No automation — you queue everything yourself.' },
|
||||||
{ value: 'improvement', label: 'Colony Improvement', desc: 'Industry buildings first, then any other building not yet built.' },
|
{ value: 'improvement', label: 'Colony Improvement', desc: 'Industry buildings first, then any other building not yet built.' },
|
||||||
{ value: 'research', label: 'Research Focus', desc: 'Only queues research buildings. Once none are left, the queue stays empty and construction spills into research.' },
|
{ value: 'research', label: 'Research Focus', desc: 'Only queues research buildings. Once none are left, the queue stays empty and construction spills into research.' },
|
||||||
{ value: 'fleet', label: 'Fleet Production', desc: 'Builds a diversified warship fleet, favouring whichever hull you have the fewest of at this system.' },
|
{ value: 'fleet', label: 'Fleet Production', desc: 'Builds a diversified warship fleet at this system, targeting a 4:3:2:1 mix of frigates : destroyers : cruisers : battleships.' },
|
||||||
{ value: 'growth', label: 'Population Growth', desc: 'Queues buildings that raise your population ceiling or growth rate.' },
|
{ value: 'growth', label: 'Population Growth', desc: 'Queues buildings that raise your population ceiling or growth rate.' },
|
||||||
{ value: 'trade', label: 'Trade & Commerce', desc: 'Queues buildings that raise trade income.' },
|
{ value: 'trade', label: 'Trade & Commerce', desc: 'Queues buildings that raise trade income.' },
|
||||||
{ value: 'defense', label: 'Homeworld Defense', desc: 'Queues planetary defense buildings.' },
|
{ value: 'defense', label: 'Homeworld Defense', desc: 'Queues planetary defense buildings.' },
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
|
|
||||||
import * as Phaser from 'phaser';
|
import * as Phaser from 'phaser';
|
||||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from './VegaButton.js';
|
||||||
import { queueGameAssets } from '../../services/assetLoader.js';
|
import { queueGameAssets } from '../../services/assetLoader.js';
|
||||||
import { compileRules, markNumeral } from './VegaRules.js';
|
import { compileRules, markNumeral } from './VegaRules.js';
|
||||||
import { ensureSheets } from './VegaArt.js';
|
import { ensureSheets } from './VegaArt.js';
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import * as Phaser from 'phaser';
|
import * as Phaser from 'phaser';
|
||||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from './VegaButton.js';
|
||||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||||
import { FONT, D, uiClick } from './VegaScreens.js';
|
import { FONT, D, uiClick } from './VegaScreens.js';
|
||||||
import VegaFx from './VegaFx.js';
|
import VegaFx from './VegaFx.js';
|
||||||
|
|
@ -205,7 +205,7 @@ export function openCombatView(scene, rules, battle, art, opts = {}) {
|
||||||
playSound(scene, SFX.VEGA_ENDTURN);
|
playSound(scene, SFX.VEGA_ENDTURN);
|
||||||
animate(stepRound(battle, {}));
|
animate(stepRound(battle, {}));
|
||||||
if (battle.done) scene.time.delayedCall(700, finish);
|
if (battle.done) scene.time.delayedCall(700, finish);
|
||||||
}, { width: 220, height: 52 });
|
}, { width: 220, height: 52, scheme: 'magenta' });
|
||||||
layer.add(next);
|
layer.add(next);
|
||||||
|
|
||||||
const auto = new Button(scene, GAME_WIDTH / 2, GAME_HEIGHT - 70, 'Auto-resolve', uiClick(scene, () => {
|
const auto = new Button(scene, GAME_WIDTH / 2, GAME_HEIGHT - 70, 'Auto-resolve', uiClick(scene, () => {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
|
|
||||||
import * as Phaser from 'phaser';
|
import * as Phaser from 'phaser';
|
||||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from './VegaButton.js';
|
||||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||||
import { FONT, D, uiClick } from './VegaScreens.js';
|
import { FONT, D, uiClick } from './VegaScreens.js';
|
||||||
import VegaFx from './VegaFx.js';
|
import VegaFx from './VegaFx.js';
|
||||||
|
|
|
||||||
|
|
@ -275,8 +275,17 @@ export function runDiplomacyTurn(rules, state, e) {
|
||||||
if (atWar(state, e, other.idx)) {
|
if (atWar(state, e, other.idx)) {
|
||||||
// Sue for peace when clearly losing, or when the war has gone cold.
|
// Sue for peace when clearly losing, or when the war has gone cold.
|
||||||
// Only sue for peace when genuinely losing, and not immediately — a war
|
// Only sue for peace when genuinely losing, and not immediately — a war
|
||||||
// that ends on the turn it starts accomplishes nothing.
|
// that ends on the turn it starts accomplishes nothing. Gated on
|
||||||
if (ratio < 0.55 && rand(state) < 0.12) proposeOrOffer(rules, state, e, other.idx, 'peace');
|
// canNegotiate same as every other proposal below: a diplomacy-
|
||||||
|
// incapable species (Lithox) never comes to the table, war included —
|
||||||
|
// the only way out of a war with one is the human's own unilateral
|
||||||
|
// Cease Hostilities toggle (VegaScreens.js), not a held offer. Without
|
||||||
|
// this a losing Lithox would still queue a peace offer the human could
|
||||||
|
// never answer (its Seek Audience button is permanently disabled),
|
||||||
|
// leaving a stray "seeks an audience" card up for nothing.
|
||||||
|
if (canNegotiate(rules, state, e, other.idx) && ratio < 0.55 && rand(state) < 0.12) {
|
||||||
|
proposeOrOffer(rules, state, e, other.idx, 'peace');
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1513,6 +1513,24 @@ function deliverPopulation(rules, state, f) {
|
||||||
stack.popPayload = 0;
|
stack.popPayload = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolves which colony at this star a bombard/invade action actually
|
||||||
|
// targets. A star can host more than one colony (one per orbit), owned by
|
||||||
|
// different empires — colonyAt() only ever returns the first one found in
|
||||||
|
// state.colonies, regardless of orbit. That silently broke Bombard/Invade
|
||||||
|
// whenever an allied or neutral colony shared a system with the actual
|
||||||
|
// (hostile) target: colonyAt picked the ally, the atWar check correctly
|
||||||
|
// refused, and the caller had no way to tell the refusal apart from a real
|
||||||
|
// "no orbital superiority" failure. `orbit`, when supplied, pins the exact
|
||||||
|
// colony the caller means — VegaSystemView.js always knows which one is on
|
||||||
|
// screen. Without it (VegaAI.js's callers, which only know a starIdx), the
|
||||||
|
// fallback targets whichever colony here the attacker is actually at war
|
||||||
|
// with, rather than just whichever happens to be first in the array.
|
||||||
|
function targetColonyAt(state, e, starIdx, orbit) {
|
||||||
|
const here = coloniesAt(state, starIdx);
|
||||||
|
if (orbit != null) return here.find((c) => c.orbit === orbit) ?? null;
|
||||||
|
return here.find((c) => atWar(state, e, c.empireIdx)) ?? here[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
// Orbital superiority at a system: our combat power there exceeds theirs.
|
// Orbital superiority at a system: our combat power there exceeds theirs.
|
||||||
// Bombardment and invasion both require it.
|
// Bombardment and invasion both require it.
|
||||||
export function holdsOrbit(rules, state, e, starIdx, defenderIdx) {
|
export function holdsOrbit(rules, state, e, starIdx, defenderIdx) {
|
||||||
|
|
@ -1534,8 +1552,8 @@ export function holdsOrbit(rules, state, e, starIdx, defenderIdx) {
|
||||||
// denies the attacker the clean orbit an invasion needs. Soaked to 2500 turns,
|
// denies the attacker the clean orbit an invasion needs. Soaked to 2500 turns,
|
||||||
// not one empire in eight games was ever eliminated. Bombing also thins the
|
// not one empire in eight games was ever eliminated. Bombing also thins the
|
||||||
// defenders for a subsequent landing, so the two mechanics work together.
|
// defenders for a subsequent landing, so the two mechanics work together.
|
||||||
export function bombard(rules, state, e, starIdx) {
|
export function bombard(rules, state, e, starIdx, orbit = null) {
|
||||||
const colony = colonyAt(state, starIdx);
|
const colony = targetColonyAt(state, e, starIdx, orbit);
|
||||||
if (!colony || colony.empireIdx === e) return null;
|
if (!colony || colony.empireIdx === e) return null;
|
||||||
if (!atWar(state, e, colony.empireIdx)) return null;
|
if (!atWar(state, e, colony.empireIdx)) return null;
|
||||||
if (!holdsOrbit(rules, state, e, starIdx, colony.empireIdx)) return null;
|
if (!holdsOrbit(rules, state, e, starIdx, colony.empireIdx)) return null;
|
||||||
|
|
@ -1585,8 +1603,8 @@ export function bombard(rules, state, e, starIdx) {
|
||||||
// both the AI and the human's invade button need the same forecast — the AI
|
// both the AI and the human's invade button need the same forecast — the AI
|
||||||
// was committing every transport it had to hopeless landings (1971 failures
|
// was committing every transport it had to hopeless landings (1971 failures
|
||||||
// against 130 successes) purely because nothing told it the odds.
|
// against 130 successes) purely because nothing told it the odds.
|
||||||
export function invasionForecast(rules, state, e, starIdx) {
|
export function invasionForecast(rules, state, e, starIdx, orbit = null) {
|
||||||
const colony = colonyAt(state, starIdx);
|
const colony = targetColonyAt(state, e, starIdx, orbit);
|
||||||
if (!colony || colony.empireIdx === e) return null;
|
if (!colony || colony.empireIdx === e) return null;
|
||||||
const troops = state.fleets
|
const troops = state.fleets
|
||||||
.filter((f) => f.starIdx === starIdx && f.empireIdx === e)
|
.filter((f) => f.starIdx === starIdx && f.empireIdx === e)
|
||||||
|
|
@ -1608,8 +1626,8 @@ export function invasionForecast(rules, state, e, starIdx) {
|
||||||
return { troops, defenders, odds, favourable };
|
return { troops, defenders, odds, favourable };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function invade(rules, state, e, starIdx) {
|
export function invade(rules, state, e, starIdx, orbit = null) {
|
||||||
const colony = colonyAt(state, starIdx);
|
const colony = targetColonyAt(state, e, starIdx, orbit);
|
||||||
if (!colony || colony.empireIdx === e) return null;
|
if (!colony || colony.empireIdx === e) return null;
|
||||||
if (!atWar(state, e, colony.empireIdx)) return null;
|
if (!atWar(state, e, colony.empireIdx)) return null;
|
||||||
// Holding orbit means orbital SUPERIORITY, not an empty sky.
|
// Holding orbit means orbital SUPERIORITY, not an empty sky.
|
||||||
|
|
@ -1987,12 +2005,23 @@ function pickDefense(rules, state, colony) {
|
||||||
enqueueFirstAffordableBuilding(rules, state, colony, defenseBuildingIds(rules));
|
enqueueFirstAffordableBuilding(rules, state, colony, defenseBuildingIds(rules));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Brian's target fleet mix for Colony Focus: Fleet Production — 4 frigates :
|
||||||
|
// 3 destroyers : 2 cruisers : 1 battleship. Any warship hull missing from
|
||||||
|
// this table (there shouldn't be one — frigate/destroyer/cruiser/battleship
|
||||||
|
// are the only role:'warship' hulls in mastervega-rules.json) falls back to
|
||||||
|
// weight 1.
|
||||||
|
const FLEET_MIX_WEIGHT = { frigate: 4, destroyer: 3, cruiser: 2, battleship: 1 };
|
||||||
|
|
||||||
// Independently-implemented twin of VegaAI.js's preferredWarship (same
|
// Independently-implemented twin of VegaAI.js's preferredWarship (same
|
||||||
// (hp + damage*4) / cost scoring, same ~15-turns-of-budget affordability
|
// (hp + damage*4) / cost scoring, same ~15-turns-of-budget affordability
|
||||||
// cutoff — see the header note above for why this is duplicated rather than
|
// cutoff — see the header note above for why this is duplicated rather than
|
||||||
// shared), plus a diversity pass: rather than always maxing out the single
|
// shared), plus a weighted diversity pass: rather than always maxing out the
|
||||||
// best-value hull, it counts what's already docked at this colony's star and
|
// single best-value hull, it counts what's already docked at this colony's
|
||||||
// favours whichever warship role is least represented, ties broken by score.
|
// star and favours whichever warship role is furthest below its target share
|
||||||
|
// of FLEET_MIX_WEIGHT (count/weight, lowest wins — the standard weighted
|
||||||
|
// round-robin comparison), ties broken by score. Starting from an empty
|
||||||
|
// fleet this converges to the 4:3:2:1 ratio after one full 10-ship cycle and
|
||||||
|
// holds it indefinitely, rather than the old equal-count-per-hull split.
|
||||||
function pickFleet(rules, state, colony) {
|
function pickFleet(rules, state, colony) {
|
||||||
const e = colony.empireIdx;
|
const e = colony.empireIdx;
|
||||||
const budget = colonyBuildRate(rules, state, colony);
|
const budget = colonyBuildRate(rules, state, colony);
|
||||||
|
|
@ -2005,16 +2034,18 @@ function pickFleet(rules, state, colony) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let best = null;
|
let best = null;
|
||||||
let bestCount = Infinity;
|
let bestRatio = Infinity;
|
||||||
let bestScore = -Infinity;
|
let bestScore = -Infinity;
|
||||||
for (const hullId of hullIds) {
|
for (const hullId of hullIds) {
|
||||||
const d = empireDesign(rules, state, e, hullId);
|
const d = empireDesign(rules, state, e, hullId);
|
||||||
if (d.damage <= 0) continue;
|
if (d.damage <= 0) continue;
|
||||||
if (d.cost > budget * 15) continue;
|
if (d.cost > budget * 15) continue;
|
||||||
const count = present[hullId] ?? 0;
|
const count = present[hullId] ?? 0;
|
||||||
|
const weight = FLEET_MIX_WEIGHT[hullId] ?? 1;
|
||||||
|
const ratio = count / weight;
|
||||||
const score = (d.hp + d.damage * 4) / d.cost;
|
const score = (d.hp + d.damage * 4) / d.cost;
|
||||||
if (count < bestCount || (count === bestCount && score > bestScore)) {
|
if (ratio < bestRatio || (ratio === bestRatio && score > bestScore)) {
|
||||||
best = hullId; bestCount = count; bestScore = score;
|
best = hullId; bestRatio = ratio; bestScore = score;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Before any weapon tech is known every hull scores damage=0 and `best`
|
// Before any weapon tech is known every hull scores damage=0 and `best`
|
||||||
|
|
@ -2106,7 +2137,7 @@ export function recommendColonyFocus(rules, state, colony) {
|
||||||
return {
|
return {
|
||||||
value: 'fleet', label: 'Fleet Production',
|
value: 'fleet', label: 'Fleet Production',
|
||||||
reason: "Empire-wide fleet strength is below what's typical this far into the game — "
|
reason: "Empire-wide fleet strength is below what's typical this far into the game — "
|
||||||
+ 'additional warships would help, favouring whichever type you have fewest of.',
|
+ 'additional warships would help, built toward a 4:3:2:1 frigate/destroyer/cruiser/battleship mix.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import * as Phaser from 'phaser';
|
import * as Phaser from 'phaser';
|
||||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from './VegaButton.js';
|
||||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||||
import {
|
import {
|
||||||
empireColonies, empireFleets, hireLeader, assignLeader, unassignLeader, leaderHireCost,
|
empireColonies, empireFleets, hireLeader, assignLeader, unassignLeader, leaderHireCost,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
|
|
||||||
import * as Phaser from 'phaser';
|
import * as Phaser from 'phaser';
|
||||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from './VegaButton.js';
|
||||||
import { markNumeral } from './VegaRules.js';
|
import { markNumeral } from './VegaRules.js';
|
||||||
import { empireDesign } from './VegaLogic.js';
|
import { empireDesign } from './VegaLogic.js';
|
||||||
import { refitCost } from './VegaShips.js';
|
import { refitCost } from './VegaShips.js';
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,20 @@ export const COMPONENT_FIELDS = ['weapons', 'construction', 'forcefields', 'prop
|
||||||
|
|
||||||
export const MAX_MARK = 7;
|
export const MAX_MARK = 7;
|
||||||
|
|
||||||
|
// Mark is the AVERAGE tier across all five component fields (see markFor
|
||||||
|
// below), with an unresearched field contributing 0 rather than dragging the
|
||||||
|
// average negative — so a real Mark II/III hull with zero weapons research
|
||||||
|
// is a perfectly reachable state if a player (or the AI) simply prioritises
|
||||||
|
// the other four fields. Without this fallback that ship deals literally 0
|
||||||
|
// damage forever, silently: Bombard refuses (bestComponents.allWeapons is
|
||||||
|
// empty), and space combat is a no-op. Strictly worse than the tier-0 Laser
|
||||||
|
// Cannon on every axis, so actually researching lasercannon (or anything
|
||||||
|
// else) is still a real upgrade, not a formality made moot by this floor.
|
||||||
|
const BASELINE_WEAPON = {
|
||||||
|
id: 'baselinemassdriver', name: 'Mass Driver', kind: 'beam',
|
||||||
|
min: 1, max: 2, shots: 1, space: 3, cost: 10,
|
||||||
|
};
|
||||||
|
|
||||||
// Highest tier the empire has reached in each field. The chains are linear, so
|
// Highest tier the empire has reached in each field. The chains are linear, so
|
||||||
// "best tier" is all we ever need to know.
|
// "best tier" is all we ever need to know.
|
||||||
export function fieldTiers(rules, known) {
|
export function fieldTiers(rules, known) {
|
||||||
|
|
@ -104,6 +118,11 @@ export function bestComponents(rules, known) {
|
||||||
out.missiles = weapons.filter((w) => w.kind === 'missile').sort((a, b) => (avg(b) * b.shots) / b.space - (avg(a) * a.shots) / a.space);
|
out.missiles = weapons.filter((w) => w.kind === 'missile').sort((a, b) => (avg(b) * b.shots) / b.space - (avg(a) * a.shots) / a.space);
|
||||||
out.weapon = out.beams[0] ?? out.missiles[0] ?? null;
|
out.weapon = out.beams[0] ?? out.missiles[0] ?? null;
|
||||||
out.allWeapons = weapons;
|
out.allWeapons = weapons;
|
||||||
|
if (!out.allWeapons.length) {
|
||||||
|
out.allWeapons = [BASELINE_WEAPON];
|
||||||
|
out.beams = [BASELINE_WEAPON];
|
||||||
|
out.weapon = BASELINE_WEAPON;
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
|
|
||||||
import * as Phaser from 'phaser';
|
import * as Phaser from 'phaser';
|
||||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from './VegaButton.js';
|
||||||
import { markNumeral } from './VegaRules.js';
|
import { markNumeral } from './VegaRules.js';
|
||||||
import { parsecs } from './VegaGalaxyGen.js';
|
import { parsecs } from './VegaGalaxyGen.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -759,11 +759,11 @@ export default class VegaSidePanel {
|
||||||
// click, and a fuel-range refusal should stay silent either way.
|
// click, and a fuel-range refusal should stay silent either way.
|
||||||
const accept = new Button(this.scene, PAD + half / 2, btnY + 24, 'Accept',
|
const accept = new Button(this.scene, PAD + half / 2, btnY + 24, 'Accept',
|
||||||
refusal ? null : () => this.cb.onAcceptOrder?.(this.fleet, this.orderStar, this.selectedShips()),
|
refusal ? null : () => this.cb.onAcceptOrder?.(this.fleet, this.orderStar, this.selectedShips()),
|
||||||
{ width: half, height: 48, fontSize: 20 });
|
{ width: half, height: 48, fontSize: 20, scheme: 'green' });
|
||||||
if (refusal) accept.setEnabled?.(false);
|
if (refusal) accept.setEnabled?.(false);
|
||||||
this.body.add(accept);
|
this.body.add(accept);
|
||||||
this.body.add(new Button(this.scene, PAD + half * 1.5 + 12, btnY + 24, 'Cancel',
|
this.body.add(new Button(this.scene, PAD + half * 1.5 + 12, btnY + 24, 'Cancel',
|
||||||
uiClick(this.scene, () => this.cb.onCancelOrder?.()), { width: half, height: 48, fontSize: 20, variant: 'ghost' }));
|
uiClick(this.scene, () => this.cb.onCancelOrder?.()), { width: half, height: 48, fontSize: 20, variant: 'ghost', scheme: 'red' }));
|
||||||
this.y = btnY + 60;
|
this.y = btnY + 60;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
|
|
||||||
import * as Phaser from 'phaser';
|
import * as Phaser from 'phaser';
|
||||||
|
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from './VegaButton.js';
|
||||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||||
import { modalShell, FONT, ORBIT, uiClick } from './VegaScreens.js';
|
import { modalShell, FONT, ORBIT, uiClick } from './VegaScreens.js';
|
||||||
import { planetFrame, starFrame } from './VegaArt.js';
|
import { planetFrame, starFrame } from './VegaArt.js';
|
||||||
|
|
@ -216,7 +216,7 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
|
||||||
if (!mine) {
|
if (!mine) {
|
||||||
// Enemy colony: offer the two things a fleet in orbit can actually do.
|
// Enemy colony: offer the two things a fleet in orbit can actually do.
|
||||||
if (viewerIdx >= 0 && atWar(state, viewerIdx, colony.empireIdx)) {
|
if (viewerIdx >= 0 && atWar(state, viewerIdx, colony.empireIdx)) {
|
||||||
const forecast = invasionForecast(rules, state, viewerIdx, starIdx);
|
const forecast = invasionForecast(rules, state, viewerIdx, starIdx, colony.orbit);
|
||||||
console_.add(scene.add.text(panelX, y, forecast && forecast.troops > 0
|
console_.add(scene.add.text(panelX, y, forecast && forecast.troops > 0
|
||||||
? `${forecast.troops} marines vs ~${forecast.defenders} defenders — ${Math.round(forecast.odds * 100)}% per exchange`
|
? `${forecast.troops} marines vs ~${forecast.defenders} defenders — ${Math.round(forecast.odds * 100)}% per exchange`
|
||||||
: 'No troop transports in orbit.', {
|
: 'No troop transports in orbit.', {
|
||||||
|
|
@ -224,12 +224,35 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
|
||||||
wordWrap: { width: panelW },
|
wordWrap: { width: panelW },
|
||||||
}));
|
}));
|
||||||
y += 44;
|
y += 44;
|
||||||
|
// Both actions used to fail completely silently — refused by a
|
||||||
|
// precondition (no orbital superiority, no weapons) and an attempted-
|
||||||
|
// but-lost invasion (every troop consumed either way — see invade()
|
||||||
|
// in VegaLogic.js) were visually identical to nothing happening at
|
||||||
|
// all, since the only record was a turn-report event the player
|
||||||
|
// wouldn't see until ending their turn. scene.log() surfaces the
|
||||||
|
// outcome immediately in the same ticker the rest of the HUD uses.
|
||||||
console_.add(new Button(scene, panelX + 120, y + 20, 'Bombard', uiClick(scene, () => {
|
console_.add(new Button(scene, panelX + 120, y + 20, 'Bombard', uiClick(scene, () => {
|
||||||
bombard(rules, state, viewerIdx, starIdx); onChanged?.(); rebuild();
|
const result = bombard(rules, state, viewerIdx, starIdx, colony.orbit);
|
||||||
|
if (!result) {
|
||||||
|
scene.log?.('Bombard failed — you need orbital superiority and warships with a working weapon.');
|
||||||
|
} else if (result.destroyed) {
|
||||||
|
scene.log?.(`Bombarded the ${owner.name} colony — ${result.killed} killed, the colony was wiped out.`);
|
||||||
|
} else {
|
||||||
|
scene.log?.(`Bombarded the ${owner.name} colony — ${result.killed} killed.`);
|
||||||
|
}
|
||||||
|
onChanged?.(); rebuild();
|
||||||
}), { width: 220, height: 42, bg: 0x6b2230 }));
|
}), { width: 220, height: 42, bg: 0x6b2230 }));
|
||||||
if (forecast && forecast.troops > 0) {
|
if (forecast && forecast.troops > 0) {
|
||||||
console_.add(new Button(scene, panelX + 370, y + 20, 'Invade', uiClick(scene, () => {
|
console_.add(new Button(scene, panelX + 370, y + 20, 'Invade', uiClick(scene, () => {
|
||||||
invade(rules, state, viewerIdx, starIdx); onChanged?.(); rebuild();
|
const result = invade(rules, state, viewerIdx, starIdx, colony.orbit);
|
||||||
|
if (!result) {
|
||||||
|
scene.log?.('Invasion could not be launched — you need orbital superiority and a troop transport in orbit.');
|
||||||
|
} else if (result.captured) {
|
||||||
|
scene.log?.(`Invasion of the ${owner.name} colony succeeded — ${result.attackersLeft} marines secured it.`);
|
||||||
|
} else {
|
||||||
|
scene.log?.(`Invasion of the ${owner.name} colony failed — all marines were lost.`);
|
||||||
|
}
|
||||||
|
onChanged?.(); rebuild();
|
||||||
}), { width: 220, height: 42 }));
|
}), { width: 220, height: 42 }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,36 @@ function describeBuildingDone(rules, state, ev) {
|
||||||
function describeShipDone(rules, state, ev) {
|
function describeShipDone(rules, state, ev) {
|
||||||
const h = rules.hulls[ev.hullId];
|
const h = rules.hulls[ev.hullId];
|
||||||
const star = state.galaxy.stars[ev.starIdx];
|
const star = state.galaxy.stars[ev.starIdx];
|
||||||
return { headline: `${h.name} completed at ${star.name}.`, lines: [line(h.desc, '#9fb6cc')] };
|
const count = ev.count ?? 1;
|
||||||
|
const headline = count > 1
|
||||||
|
? `${count}× ${h.name} completed at ${star.name}.`
|
||||||
|
: `${h.name} completed at ${star.name}.`;
|
||||||
|
return { headline, lines: [line(h.desc, '#9fb6cc')] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A well-funded queue can pop several copies of the same hull at the same
|
||||||
|
// colony in a single turn (VegaLogic.js's build loop completes as many
|
||||||
|
// queue entries as the turn's production covers) — each one pushes its own
|
||||||
|
// shipDone event, which used to mean N identical "Frigate completed at
|
||||||
|
// Sol." rows in the New Turn report. Collapsed here into one row per
|
||||||
|
// (empire, colony, hull) with a count, keeping the first event as the
|
||||||
|
// representative so its starIdx/colonyId/hullId stay valid for the report
|
||||||
|
// screen's icon/"View Star System" wiring — only the headline needs the
|
||||||
|
// count. Scoped to shipDone specifically: buildings are unique per colony
|
||||||
|
// (enqueue refuses a duplicate), so buildingDone can never repeat this way.
|
||||||
|
export function groupShipDoneEvents(events) {
|
||||||
|
const out = [];
|
||||||
|
const byKey = new Map();
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.type !== 'shipDone') { out.push(ev); continue; }
|
||||||
|
const key = `${ev.empire}|${ev.colonyId}|${ev.hullId}`;
|
||||||
|
const existing = byKey.get(key);
|
||||||
|
if (existing) { existing.count = (existing.count ?? 1) + 1; continue; }
|
||||||
|
const grouped = { ...ev, count: 1 };
|
||||||
|
byKey.set(key, grouped);
|
||||||
|
out.push(grouped);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The recommendations are re-derived here rather than frozen into the event
|
// The recommendations are re-derived here rather than frozen into the event
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ import { makeShipIcon, makeCommanderPortrait } from './VegaShipMedia.js';
|
||||||
import { openShipDetail } from './VegaShipDetail.js';
|
import { openShipDetail } from './VegaShipDetail.js';
|
||||||
import { buildingFrame } from './VegaArt.js';
|
import { buildingFrame } from './VegaArt.js';
|
||||||
import { turnToYear } from './VegaRules.js';
|
import { turnToYear } from './VegaRules.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from './VegaButton.js';
|
||||||
import { describeEvent, categoryWeight } from './VegaTurnReport.js';
|
import { describeEvent, categoryWeight, groupShipDoneEvents } from './VegaTurnReport.js';
|
||||||
import { enqueue, empireColonies, colonyTrade } from './VegaLogic.js';
|
import { enqueue, empireColonies, colonyTrade } from './VegaLogic.js';
|
||||||
|
|
||||||
// A colony's own trade minus what its existing buildings already cost to run
|
// A colony's own trade minus what its existing buildings already cost to run
|
||||||
|
|
@ -50,7 +50,7 @@ export function openTurnReportScreen(scene, rules, state, events, onClose) {
|
||||||
playSound(scene, SFX.VEGA_NEWTURN);
|
playSound(scene, SFX.VEGA_NEWTURN);
|
||||||
const shell = modalShell(scene, `New Turn: ${turnToYear(state.turn)}`, onClose, { width: 1180, height: 760 });
|
const shell = modalShell(scene, `New Turn: ${turnToYear(state.turn)}`, onClose, { width: 1180, height: 760 });
|
||||||
|
|
||||||
const ordered = events
|
const ordered = groupShipDoneEvents(events)
|
||||||
.map((ev, i) => ({ ev, i, desc: describeEvent(rules, state, ev) }))
|
.map((ev, i) => ({ ev, i, desc: describeEvent(rules, state, ev) }))
|
||||||
.sort((a, b) => categoryWeight(a.ev) - categoryWeight(b.ev) || a.i - b.i);
|
.sort((a, b) => categoryWeight(a.ev) - categoryWeight(b.ev) || a.i - b.i);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ import {
|
||||||
import { CHAT } from '../src/games/mastervega/VegaChat.js';
|
import { CHAT } from '../src/games/mastervega/VegaChat.js';
|
||||||
// Turn-report classification is Phaser-free, so what the "New Turn" popup will
|
// Turn-report classification is Phaser-free, so what the "New Turn" popup will
|
||||||
// and will not interrupt the player for is checkable here.
|
// and will not interrupt the player for is checkable here.
|
||||||
import { NOTABLE_TYPES, describeEvent, TYPE_LABEL } from '../src/games/mastervega/VegaTurnReport.js';
|
import { NOTABLE_TYPES, describeEvent, TYPE_LABEL, groupShipDoneEvents } from '../src/games/mastervega/VegaTurnReport.js';
|
||||||
// Ship media is addressed here and nowhere else, so the key convention is
|
// Ship media is addressed here and nowhere else, so the key convention is
|
||||||
// checkable without a canvas.
|
// checkable without a canvas.
|
||||||
import { shipVideoKey, hasShipVideo } from '../src/games/mastervega/VegaShipMedia.js';
|
import { shipVideoKey, hasShipVideo } from '../src/games/mastervega/VegaShipMedia.js';
|
||||||
|
|
@ -981,6 +981,32 @@ section('4. Ship Marks');
|
||||||
const base = Ships.designFor(RULES, known, 'starbase', RULES.species.human.traits);
|
const base = Ships.designFor(RULES, known, 'starbase', RULES.species.human.traits);
|
||||||
check('star base is immobile', base.immobile && base.speed === 0);
|
check('star base is immobile', base.immobile && base.speed === 0);
|
||||||
|
|
||||||
|
// A warship hull with literally zero weapon techs known is a real reachable
|
||||||
|
// state (Mark is an average across all five fields, so the other four can
|
||||||
|
// carry it well past Mark I) — without a fallback it deals 0 damage
|
||||||
|
// forever, silently. bestComponents must synthesize a minimal weapon.
|
||||||
|
{
|
||||||
|
const noTech = Ships.bestComponents(RULES, {});
|
||||||
|
check('an empire with zero known techs still has exactly one fallback weapon',
|
||||||
|
noTech.allWeapons.length === 1, `${noTech.allWeapons.length}`);
|
||||||
|
check('the fallback weapon is the documented baseline, not a real tech',
|
||||||
|
noTech.weapon?.id === 'baselinemassdriver', noTech.weapon?.id);
|
||||||
|
const bareFrigate = Ships.designFor(RULES, {}, 'frigate', RULES.species.human.traits);
|
||||||
|
check('a warship with zero known techs still deals damage',
|
||||||
|
bareFrigate.damage > 0, `${bareFrigate.damage}`);
|
||||||
|
check('non-warship hulls stay unarmed even with zero known techs (hull.space gates it)',
|
||||||
|
Ships.designFor(RULES, {}, 'scout', RULES.species.human.traits).damage === 0);
|
||||||
|
// The fallback must be strictly worse than the real tier-0 weapon, so
|
||||||
|
// researching it (or anything else) is still a genuine upgrade.
|
||||||
|
const lasercannonOnly = Ships.bestComponents(RULES, { lasercannon: true });
|
||||||
|
check('the fallback is replaced (not stacked) once a real weapon is known',
|
||||||
|
lasercannonOnly.allWeapons.length === 1 && lasercannonOnly.weapon?.id !== 'baselinemassdriver',
|
||||||
|
JSON.stringify(lasercannonOnly.allWeapons.map((w) => w.id)));
|
||||||
|
const realFrigate = Ships.designFor(RULES, { lasercannon: true }, 'frigate', RULES.species.human.traits);
|
||||||
|
check('the real tier-0 weapon out-damages the fallback',
|
||||||
|
realFrigate.damage > bareFrigate.damage, `${realFrigate.damage} vs ${bareFrigate.damage}`);
|
||||||
|
}
|
||||||
|
|
||||||
check('refit costs something and is finite', (() => {
|
check('refit costs something and is finite', (() => {
|
||||||
const c = Ships.refitCost(RULES, known, 'cruiser', 1, RULES.species.human.traits);
|
const c = Ships.refitCost(RULES, known, 'cruiser', 1, RULES.species.human.traits);
|
||||||
return Number.isFinite(c) && c > 0;
|
return Number.isFinite(c) && c > 0;
|
||||||
|
|
@ -1292,6 +1318,61 @@ section('4c. Population transport');
|
||||||
Logic.fleetPower(RULES, st, fleet) === 0);
|
Logic.fleetPower(RULES, st, fleet) === 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A star hosting two colonies (different empires, different orbits) used
|
||||||
|
// to break Bombard/Invade/invasionForecast whenever the non-hostile one
|
||||||
|
// came first in state.colonies: colonyAt() is orbit-blind, so an ally
|
||||||
|
// sharing a system with the real target silently ate every click. Brian
|
||||||
|
// hit this directly — allied Rrashaa on one planet, hostile Lithox on
|
||||||
|
// another, orbital superiority confirmed, both actions refused anyway.
|
||||||
|
{
|
||||||
|
const stM = Logic.createGame(RULES, {
|
||||||
|
sizeId: 'medium', shapeId: 'elliptical', seed: 606, difficultyId: 'normal',
|
||||||
|
speciesIds: ['human', 'rrashaa', 'kkrix'], humanIndex: 0,
|
||||||
|
});
|
||||||
|
stM.rules = RULES;
|
||||||
|
let starIdx = -1;
|
||||||
|
for (let i = 0; i < stM.galaxy.stars.length; i += 1) {
|
||||||
|
if ((stM.galaxy.stars[i].planets?.length ?? 0) >= 2 && !stM.galaxy.homeIdx.includes(i)) { starIdx = i; break; }
|
||||||
|
}
|
||||||
|
check('the test galaxy has a star with 2+ planets to place two colonies on', starIdx >= 0);
|
||||||
|
if (starIdx >= 0) {
|
||||||
|
// Ally (rrashaa, empire 1) founded FIRST so it lands first in
|
||||||
|
// state.colonies — colonyAt()'s old first-match behaviour would have
|
||||||
|
// picked this one regardless of which orbit was actually being acted
|
||||||
|
// on. Different populations so a forecast computed against the wrong
|
||||||
|
// colony is numerically distinguishable from one computed correctly.
|
||||||
|
const allyColony = Logic.foundColony(RULES, stM, 1, starIdx, 0, 20);
|
||||||
|
const enemyColony = Logic.foundColony(RULES, stM, 2, starIdx, 1, 10);
|
||||||
|
Diplo.declareWar(RULES, stM, 0, 2); // human at war with kkrix only; rrashaa stays unallied-but-not-at-war
|
||||||
|
Logic.addFleet(RULES, stM, 0, starIdx, [
|
||||||
|
{ hullId: 'frigate', count: 3, mark: 1 },
|
||||||
|
{ hullId: 'transport', count: 1, mark: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
check('colonyAt (orbit-blind) resolves to the ally here, confirming the bug precondition is real',
|
||||||
|
Logic.colonyAt(stM, starIdx)?.empireIdx === 1);
|
||||||
|
|
||||||
|
const noOrbit = Logic.invasionForecast(RULES, stM, 0, starIdx);
|
||||||
|
const byEnemyOrbit = Logic.invasionForecast(RULES, stM, 0, starIdx, enemyColony.orbit);
|
||||||
|
const byAllyOrbit = Logic.invasionForecast(RULES, stM, 0, starIdx, allyColony.orbit);
|
||||||
|
check('invasionForecast without an orbit falls back to the at-war colony, not the ally',
|
||||||
|
noOrbit?.troops > 0 && noOrbit.defenders === byEnemyOrbit?.defenders,
|
||||||
|
`${JSON.stringify(noOrbit)} vs ${JSON.stringify(byEnemyOrbit)}`);
|
||||||
|
check('an explicit orbit actually changes which colony is targeted',
|
||||||
|
byAllyOrbit?.defenders !== byEnemyOrbit?.defenders,
|
||||||
|
`ally ${byAllyOrbit?.defenders} vs enemy ${byEnemyOrbit?.defenders}`);
|
||||||
|
|
||||||
|
check('invade pinned to the ally\'s own orbit correctly refuses (not at war), even though it is a real colony here',
|
||||||
|
Logic.invade(RULES, stM, 0, starIdx, allyColony.orbit) === null);
|
||||||
|
|
||||||
|
// The actual reported bug: no orbit passed at all (VegaAI.js's calling
|
||||||
|
// convention), with the ally still ahead of the enemy in state.colonies.
|
||||||
|
const bombardResult = Logic.bombard(RULES, stM, 0, starIdx);
|
||||||
|
check('bombard without an orbit correctly reaches the hostile colony sharing the star',
|
||||||
|
!!bombardResult, JSON.stringify(bombardResult));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -1592,6 +1673,81 @@ section('6. Colony economy');
|
||||||
c.focus = 'manual';
|
c.focus = 'manual';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Fleet Production's weighted 4:3:2:1 mix (frigate:destroyer:cruiser:
|
||||||
|
// battleship). Grants full weapon tech and maxes the ships slider so every
|
||||||
|
// warship hull clears pickFleet's damage>0 and affordability gates —
|
||||||
|
// otherwise this would be testing which hulls got filtered out, not the
|
||||||
|
// mix logic itself.
|
||||||
|
{
|
||||||
|
const c = st.colonies[0];
|
||||||
|
const emp = st.empires[c.empireIdx];
|
||||||
|
const savedKnown = { ...emp.known };
|
||||||
|
const savedSliders = { ...c.sliders };
|
||||||
|
const savedFleets = st.fleets;
|
||||||
|
const savedPop = c.pop;
|
||||||
|
for (const t of RULES.techsByField.weapons) emp.known[t.id] = true;
|
||||||
|
// empireDesign memoizes per-empire designs keyed on emp.techsKnown, which
|
||||||
|
// a direct emp.known mutation (unlike Logic.grantTech) never bumps — null
|
||||||
|
// the cache out by hand or empireDesign silently keeps returning the
|
||||||
|
// pre-grant (no-weapon) design below.
|
||||||
|
emp._designs = null; emp._designsAt = -1;
|
||||||
|
emp._comps = null; emp._compsAt = -1;
|
||||||
|
Logic.setSlider(RULES, st, c, 'ships', 1);
|
||||||
|
// A fresh homeworld's production can't clear battleship's cost*15
|
||||||
|
// affordability gate on its own (that's a real, correct game-balance
|
||||||
|
// fact, not a bug) — overridden here purely so this check can exercise
|
||||||
|
// all four warship hulls at once rather than skipping the top of the mix.
|
||||||
|
c.pop = Logic.colonyMaxPop(RULES, st, c) * 3;
|
||||||
|
const warshipIds = RULES.hullList.filter((h) => h.role === 'warship').map((h) => h.id);
|
||||||
|
const allAffordable = warshipIds.every((id) => {
|
||||||
|
const d = Logic.empireDesign(RULES, st, c.empireIdx, id);
|
||||||
|
return d.damage > 0 && d.cost <= Logic.colonyBuildRate(RULES, st, c) * 15;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allAffordable) {
|
||||||
|
// Unambiguous minimum: battleship is the only hull under its target
|
||||||
|
// share (ratio 0 vs 1.0 for the other three), so it must win regardless
|
||||||
|
// of score tie-breaking.
|
||||||
|
st.fleets = [];
|
||||||
|
Logic.addFleet(RULES, st, c.empireIdx, c.starIdx,
|
||||||
|
[{ hullId: 'frigate', count: 4, mark: 1 }, { hullId: 'destroyer', count: 3, mark: 1 },
|
||||||
|
{ hullId: 'cruiser', count: 2, mark: 1 }]);
|
||||||
|
c.queue.length = 0;
|
||||||
|
c.focus = 'fleet';
|
||||||
|
Logic.autoQueueColonies(RULES, st, c.empireIdx);
|
||||||
|
check('fleet mix picks the sole hull strictly below its target share',
|
||||||
|
c.queue[0]?.id === 'battleship', c.queue[0]?.id);
|
||||||
|
|
||||||
|
// A full 10-ship cycle from empty must land exactly on 4:3:2:1 — the
|
||||||
|
// weighted round-robin's defining property — no matter how the score
|
||||||
|
// tie-break orders picks along the way.
|
||||||
|
st.fleets = [];
|
||||||
|
const counts = { frigate: 0, destroyer: 0, cruiser: 0, battleship: 0 };
|
||||||
|
for (let i = 0; i < 10; i += 1) {
|
||||||
|
c.queue.length = 0;
|
||||||
|
Logic.autoQueueColonies(RULES, st, c.empireIdx);
|
||||||
|
const picked = c.queue[0]?.id;
|
||||||
|
counts[picked] = (counts[picked] ?? 0) + 1;
|
||||||
|
Logic.addFleet(RULES, st, c.empireIdx, c.starIdx, [{ hullId: picked, count: 1, mark: 1 }]);
|
||||||
|
}
|
||||||
|
check('one full mix cycle (10 ships) lands exactly on 4:3:2:1',
|
||||||
|
counts.frigate === 4 && counts.destroyer === 3 && counts.cruiser === 2 && counts.battleship === 1,
|
||||||
|
JSON.stringify(counts));
|
||||||
|
} else {
|
||||||
|
check('fleet mix hull-set is affordable/tech-eligible for the 4:3:2:1 checks above (skipped)',
|
||||||
|
true, 'skipped: not all warship hulls cleared damage/affordability at this budget');
|
||||||
|
}
|
||||||
|
|
||||||
|
st.fleets = savedFleets;
|
||||||
|
emp.known = savedKnown;
|
||||||
|
emp._designs = null; emp._designsAt = -1;
|
||||||
|
emp._comps = null; emp._compsAt = -1;
|
||||||
|
c.sliders = savedSliders;
|
||||||
|
c.pop = savedPop;
|
||||||
|
c.queue.length = 0;
|
||||||
|
c.focus = 'manual';
|
||||||
|
}
|
||||||
|
|
||||||
// --- Advisor recommendations: recommendColonyFocus / recommendAllocationFocus
|
// --- Advisor recommendations: recommendColonyFocus / recommendAllocationFocus
|
||||||
// / checkAdvisorRecommendations. Uses a throwaway state so nothing here
|
// / checkAdvisorRecommendations. Uses a throwaway state so nothing here
|
||||||
// needs restoring afterward.
|
// needs restoring afterward.
|
||||||
|
|
@ -1775,6 +1931,40 @@ section('6b. Founding vignette and the turn report');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Brian's ask: multiple copies of the same hull finishing at the same
|
||||||
|
// colony in one turn used to render as N identical "Frigate completed at
|
||||||
|
// Sol." rows in the New Turn report — groupShipDoneEvents collapses them
|
||||||
|
// into one row with a count.
|
||||||
|
{
|
||||||
|
const stG = Logic.createGame(RULES, {
|
||||||
|
sizeId: 'small', shapeId: 'spiral', difficultyId: 'normal', seed: 7171,
|
||||||
|
speciesIds: ['human', 'kkrix'], humanIndex: 0,
|
||||||
|
});
|
||||||
|
const homeStar = stG.empires[0].homeStar;
|
||||||
|
const mk = (colonyId, hullId, empire = 0) => ({ type: 'shipDone', empire, colonyId, hullId, starIdx: homeStar, turn: 1 });
|
||||||
|
const events = [
|
||||||
|
mk(1, 'frigate'), mk(1, 'frigate'), mk(1, 'frigate'), // 3 frigates, same colony
|
||||||
|
mk(1, 'destroyer'), // a different hull, same colony — stays separate
|
||||||
|
mk(2, 'frigate'), // same hull, DIFFERENT colony — stays separate
|
||||||
|
{ type: 'techDone', empire: 0, techId: 'lasercannon', turn: 1 }, // non-shipDone — passes through untouched
|
||||||
|
];
|
||||||
|
const grouped = groupShipDoneEvents(events);
|
||||||
|
check('groupShipDoneEvents collapses same-colony same-hull duplicates into one row',
|
||||||
|
grouped.length === 4, `${grouped.length}`);
|
||||||
|
const frigateAt1 = grouped.find((ev) => ev.type === 'shipDone' && ev.colonyId === 1 && ev.hullId === 'frigate');
|
||||||
|
check('the collapsed row counts every duplicate', frigateAt1?.count === 3, `${frigateAt1?.count}`);
|
||||||
|
check('a different hull at the same colony is not folded in',
|
||||||
|
grouped.find((ev) => ev.hullId === 'destroyer')?.count === 1);
|
||||||
|
check('the same hull at a different colony is not folded in',
|
||||||
|
grouped.find((ev) => ev.colonyId === 2 && ev.hullId === 'frigate')?.count === 1);
|
||||||
|
check('non-shipDone events pass through untouched',
|
||||||
|
grouped.some((ev) => ev.type === 'techDone'));
|
||||||
|
check('ungrouped shipDone rows (count 1) still read as singular',
|
||||||
|
!describeEvent(RULES, stG, grouped.find((ev) => ev.hullId === 'destroyer')).headline.includes('×'));
|
||||||
|
check('a grouped shipDone row headlines the count',
|
||||||
|
describeEvent(RULES, stG, frigateAt1).headline.startsWith('3× '), describeEvent(RULES, stG, frigateAt1).headline);
|
||||||
|
}
|
||||||
|
|
||||||
// Every number the vignette reads out, on a colony one tick old, for every
|
// Every number the vignette reads out, on a colony one tick old, for every
|
||||||
// colonisable world type any species could land on. These are called before
|
// colonisable world type any species could land on. These are called before
|
||||||
// the colony has ever been processed, which is a state no other screen sees.
|
// the colony has ever been processed, which is a state no other screen sees.
|
||||||
|
|
@ -1875,6 +2065,29 @@ section('7. Diplomacy and the Galactic Council');
|
||||||
return !Diplo.canNegotiate(RULES, st, 0, 3);
|
return !Diplo.canNegotiate(RULES, st, 0, 3);
|
||||||
})());
|
})());
|
||||||
|
|
||||||
|
// A diplomacy-incapable species at war and clearly losing must never queue
|
||||||
|
// a held peace offer for the human — its Seek Audience button is
|
||||||
|
// permanently disabled (VegaScreens.js/MasterOfVegaGame.js), so a queued
|
||||||
|
// offer would leave an un-clearable "seeks an audience" card up with
|
||||||
|
// nothing the human could ever do about it. 300 iterations at the
|
||||||
|
// underlying 12%-per-turn propose chance would almost certainly have
|
||||||
|
// caught at least one leak under the old (unguarded) behaviour.
|
||||||
|
{
|
||||||
|
const stL = Logic.createGame(RULES, {
|
||||||
|
sizeId: 'medium', shapeId: 'elliptical', seed: 55, difficultyId: 'normal',
|
||||||
|
speciesIds: ['human', 'lithox'], humanIndex: 0,
|
||||||
|
});
|
||||||
|
stL.rules = RULES;
|
||||||
|
stL.empires[0].contacted[1] = true;
|
||||||
|
stL.empires[1].contacted[0] = true;
|
||||||
|
stL.empires[0].totalPop = 900;
|
||||||
|
stL.empires[1].totalPop = 50; // lithox is losing badly: power ratio well under 0.55
|
||||||
|
Diplo.declareWar(RULES, stL, 1, 0);
|
||||||
|
for (let i = 0; i < 300; i += 1) Diplo.runDiplomacyTurn(RULES, stL, 1);
|
||||||
|
check('a losing diplomacy-incapable empire never queues a held peace offer',
|
||||||
|
!stL.empires[0].pendingOffers[1]);
|
||||||
|
}
|
||||||
|
|
||||||
Diplo.declareWar(RULES, st, 0, 1);
|
Diplo.declareWar(RULES, st, 0, 1);
|
||||||
check('war is mutual', Logic.atWar(st, 0, 1) && Logic.atWar(st, 1, 0));
|
check('war is mutual', Logic.atWar(st, 0, 1) && Logic.atWar(st, 1, 0));
|
||||||
check('being attacked is resented', st.empires[1].attitude[0] < 0);
|
check('being attacked is resented', st.empires[1].attitude[0] < 0);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue