feat(mastervega): leader system, bombard cap, GNN contact-gating, population rounding

- Add leader detail pop-over (VegaLeaderDetail.js) with portrait, bio, and
  current posting bonuses displayed in fleet and colony views
- Add leader portrait click-to-zoom in the Leaders screen
- Add leader skill summary formatting (leaderSkillSummary) for readable
  bonus descriptions across UI components
- Integrate leader skills into espionage power/defense and invasion attack
  calculations in VegaLogic.js
- Cap bombard() at once per (attacker, colony) per turn via lastBombardTurn
  tracking; fixes UI double-click and AI double-bombard bugs
- Contact-gate GNN breakthrough stories and ranking charts on first contact,
  matching existing rankingRows behavior
- Round population displays throughout the UI (toFixed(1) → Math.round)
  for cleaner presentation
- Bump colony ship and transport base costs in rules data
- Add leaders.png artwork reference to mastervega-artwork.json
- Add tests for bombard cap and GNN contact-gating in verifyMasterOfVega.js
This commit is contained in:
Brian Fertig 2026-08-13 23:14:42 -06:00
parent 8f0b079fa4
commit 44868e5032
19 changed files with 501 additions and 395 deletions

View File

@ -1,364 +1,55 @@
{
"level": 9,
"name": "Thunder Steps",
"frog": [
1620,
550
],
"colors": 5,
"background": "zuma-background-2",
"seed": 72271,
"starScores": [
1210,
1820,
2420
],
"paths": [
{
"shape": "zigzag",
"points": [
[
-80,
130
],
[
-11,
130
],
[
58,
130
],
[
126,
130
],
[
195,
130
],
[
264,
130
],
[
333,
130
],
[
401,
130
],
[
470,
130
],
[
539,
130
],
[
608,
130
],
[
676,
130
],
[
745,
130
],
[
814,
130
],
[
882,
130
],
[
951,
130
],
[
1020,
130
],
[
1089,
130
],
[
1158,
130
],
[
1226,
130
],
[
1295,
130
],
[
1365,
149
],
[
1416,
200
],
[
1435,
270
],
[
1416,
340
],
[
1365,
391
],
[
1295,
410
],
[
1225,
410
],
[
1156,
410
],
[
1086,
410
],
[
1016,
410
],
[
947,
410
],
[
877,
410
],
[
807,
410
],
[
738,
410
],
[
668,
410
],
[
598,
410
],
[
529,
410
],
[
459,
410
],
[
389,
410
],
[
320,
410
],
[
250,
410
],
[
180,
429
],
[
129,
480
],
[
110,
550
],
[
129,
620
],
[
180,
671
],
[
250,
690
],
[
320,
690
],
[
389,
690
],
[
459,
690
],
[
529,
690
],
[
598,
690
],
[
668,
690
],
[
738,
690
],
[
807,
690
],
[
877,
690
],
[
947,
690
],
[
1016,
690
],
[
1086,
690
],
[
1156,
690
],
[
1225,
690
],
[
1295,
690
],
[
1365,
709
],
[
1416,
760
],
[
1435,
830
],
[
1416,
900
],
[
1365,
951
],
[
1295,
970
],
[
1225,
970
],
[
1156,
970
],
[
1086,
970
],
[
1016,
970
],
[
947,
970
],
[
877,
970
],
[
807,
970
],
[
738,
970
],
[
668,
970
],
[
598,
970
],
[
529,
970
],
[
459,
970
],
[
389,
970
],
[
320,
970
],
[
250,
970
]
],
"tunnels": [],
"quota": 36,
"introBalls": 11,
"pushSpeed": 96,
"powerUpRate": 0.055
}
]
"level": 9,
"name": "Toga",
"frog": [
267,
825
],
"colors": 4,
"seed": 12345,
"starScores": [
640,
960,
1280
],
"paths": [
{
"shape": "custom",
"points": [
[
-51,
233
],
[
1610,
243
],
[
1706,
965
],
[
671,
819
],
[
583,
378
],
[
1195,
409
],
[
1425,
755
]
],
"tunnels": [],
"quota": 40,
"introBalls": 12,
"pushSpeed": 45,
"powerUpRate": 0.06
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 KiB

Binary file not shown.

View File

@ -280,7 +280,7 @@
},
"leaders": {
"key": "vega-leaders",
"path": null,
"path": "assets/images/vega/leaders.png",
"kind": "portrait",
"frameWidth": 160,
"frameHeight": 160,

View File

@ -114,8 +114,8 @@
"_hullsReadme": "Preset ship classes — there is no ship designer. space is the weapon capacity a Mark refit fills with the best available weapon; VegaShips.js derives every other stat from the owner's researched tech. sizeScale/sizeSpeedMult/turnRateBase (degrees/round)/brakeSeconds are only consumed by the VegaCombatV2 per-ship prototype (behind ?movsim's Live/V2 toggle) — the live engine (VegaCombat.js) ignores them entirely. brakeSeconds is how long (seconds) a hull takes to kill its own top speed under max thrust — small relative to a hull's speed means it can actually decelerate and hold position within weapon range (battleship/cruiser); large relative to speed means its stopping distance exceeds beamRange and it physically cannot stop before reaching a target, so it blows through and loops back for another pass instead (frigate/scout) — see VegaCombatV2.js's computeShipMove.",
"hulls": [
{ "id": "scout", "name": "Scout", "role": "recon", "baseCost": 18, "baseHp": 10, "space": 0, "speedBonus": 2, "rangeBonus": 3, "frame": 0, "sizeScale": 0.4, "sizeSpeedMult": 1.3, "turnRateBase": 170, "brakeSeconds": 15, "desc": "Unarmed, fast and long-legged. Peels back the dark." },
{ "id": "colonyship", "name": "Colony Ship", "role": "colony", "baseCost": 100, "baseHp": 18, "space": 0, "speedBonus": 0, "rangeBonus": 0, "frame": 1, "sizeScale": 0.9, "sizeSpeedMult": 0.8, "turnRateBase": 90, "brakeSeconds": 3, "desc": "Carries a founding population. Consumed on arrival." },
{ "id": "transport", "name": "Troop Transport","role": "troops","baseCost": 40, "baseHp": 20, "space": 0, "speedBonus": 0, "rangeBonus": 0, "frame": 2, "sizeScale": 0.8, "sizeSpeedMult": 0.8, "turnRateBase": 90, "brakeSeconds": 3, "troops": 4, "desc": "Four divisions of marines for boarding and invasion." },
{ "id": "colonyship", "name": "Colony Ship", "role": "colony", "baseCost": 800, "baseHp": 18, "space": 0, "speedBonus": 0, "rangeBonus": 0, "frame": 1, "sizeScale": 0.9, "sizeSpeedMult": 0.8, "turnRateBase": 90, "brakeSeconds": 3, "desc": "Carries a founding population. Consumed on arrival." },
{ "id": "transport", "name": "Troop Transport","role": "troops","baseCost": 400, "baseHp": 20, "space": 0, "speedBonus": 0, "rangeBonus": 0, "frame": 2, "sizeScale": 0.8, "sizeSpeedMult": 0.8, "turnRateBase": 90, "brakeSeconds": 3, "troops": 4, "desc": "Four divisions of marines for boarding and invasion." },
{ "id": "poptransport","name": "Colony Transport","role": "colony","baseCost": 30,"baseHp": 20, "space": 0, "speedBonus": 0, "rangeBonus": 0, "frame": 2, "sizeScale": 0.8, "sizeSpeedMult": 0.8, "turnRateBase": 90, "brakeSeconds": 3, "desc": "Ferries colonists between worlds you already hold. Dispatched directly from a colony's Send Population order, never built from the queue — shares the Troop Transport's hull picture (frame 2, the ships sheet is full at 8 columns). sizeScale/sizeSpeedMult/turnRateBase/brakeSeconds intentionally match transport's — they share art and should render/move identically." },
{ "id": "frigate", "name": "Frigate", "role": "warship", "baseCost": 35, "baseHp": 18, "space": 6, "speedBonus": 1, "rangeBonus": 1, "frame": 3, "sizeScale": 0.5, "sizeSpeedMult": 1.2, "turnRateBase": 340, "brakeSeconds": 1.0, "desc": "Cheap escort. Screens the line and hunts scouts." },
{ "id": "destroyer", "name": "Destroyer", "role": "warship", "baseCost": 90, "baseHp": 40, "space": 14, "speedBonus": 1, "rangeBonus": 0, "frame": 4, "sizeScale": 1.0, "sizeSpeedMult": 1.0, "turnRateBase": 230, "brakeSeconds": 1.35,"desc": "The workhorse combatant of any mid-game fleet." },

View File

@ -697,7 +697,7 @@ export function openColoniesScreen(scene, rules, state, e, art, opts = {}) {
const maxPop = colonyMaxPop(rules, state, colony);
const popPct = colony.pop / Math.max(1, maxPop);
scroller.content.add(scene.add.text(COL_POP, y + 20, `${colony.pop.toFixed(1)} / ${maxPop}`, {
scroller.content.add(scene.add.text(COL_POP, y + 20, `${Math.round(colony.pop)} / ${maxPop}`, {
fontFamily: FONT, fontSize: '16px',
color: popPct < 0.5 ? '#e08a8a' : (popPct < 0.7 ? '#e0b08a' : '#c8dcf0'),
}));

View File

@ -644,7 +644,7 @@ export function openColonyIntro(scene, rules, state, colony, art, opts = {}) {
// engine rather than hard-coded from foundColony(), so a species trait or a
// tech that changes what a new colony is worth shows up here for free.
const cells = [
['POPULATION', colony.pop.toFixed(1)],
['POPULATION', `${Math.round(colony.pop)}`],
['MAX POPULATION', `${colonyMaxPop(rules, state, colony)}`],
['OUTPUT', `${colonyProduction(rules, state, colony).toFixed(1)} BC`],
['CONSTRUCTION', `${colonyBuildRate(rules, state, colony).toFixed(1)} BC`],

View File

@ -40,7 +40,9 @@ import { playSound, SFX } from '../../ui/Sounds.js';
import { worldBackground, buildingFrame } from './VegaArt.js';
import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js';
import { openShipDetail } from './VegaShipDetail.js';
import { describeBuildingTooltip } from './VegaTooltips.js';
import { openLeaderDetail } from './VegaLeaderDetail.js';
import { leaderOf } from './VegaLeaders.js';
import { describeBuildingTooltip, describeLeaderTooltip } from './VegaTooltips.js';
import {
CHANNELS, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany,
@ -275,6 +277,13 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
{ empireIdx: colony.empireIdx, hullId },
() => { detail = null; if (flyMode === 'queue') pool.setPaused(false); });
};
const openLeaderDetailFor = (leaderDef) => {
if (detail) return;
pool.setPaused(true);
detail = openLeaderDetail(scene, rules, state, art,
{ leader: leaderDef, where: `${colony.name ?? worldName} (colony)` },
() => { detail = null; if (flyMode === 'queue') pool.setPaused(false); });
};
/** Take the pool out of whatever is about to be destroyed. */
const detachPool = () => pool.layer.parentContainer?.remove(pool.layer);
@ -327,6 +336,24 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
iconLayer.add(icon);
tooltip.attachTo(icon, () => describeBuildingTooltip(rules, id));
});
// The colony's administrator, if posted here — one more row above the
// buildings, ringed in gold so it doesn't read as just another building.
const leader = leaderOf(rules, state, colony.empireIdx, 'colony', colony.id);
if (leader) {
const buildingRows = realBuildings.length ? Math.ceil(realBuildings.length / perRow) : 0;
const cx = ICON_ROW_X + ICON / 2;
const cy = GAME_HEIGHT - ICON_ROW_BOTTOM - ICON / 2 - buildingRows * (ICON + ICON_GAP);
const ring = scene.add.rectangle(cx, cy, ICON + 6, ICON + 6, 0x000000, 0)
.setStrokeStyle(2, 0xffd88a, 0.9);
iconLayer.add(ring);
const portrait = scene.add.image(cx, cy, art.leaders, leader.portraitFrame)
.setDisplaySize(ICON, ICON)
.setInteractive({ useHandCursor: true });
iconLayer.add(portrait);
tooltip.attachTo(portrait, () => describeLeaderTooltip(leader));
portrait.on('pointerup', () => openLeaderDetailFor(leader));
}
}
// ------------------------------------------------------------- the panel
@ -373,7 +400,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
const rate = colonyBuildRate(rules, state, colony);
heading('Status');
text(`Population ${colony.pop.toFixed(1)} / ${maxPop}`, 17, '#c8dcf0', 2);
text(`Population ${Math.round(colony.pop)} / ${maxPop}`, 17, '#c8dcf0', 2);
text(`Factories ${Math.floor(effF)} / ${capF}`, 17, '#c8dcf0', 2);
text(`Output ${prod.toFixed(1)} BC per turn`, 17, '#c8dcf0', 2);
text(`Construction ${rate.toFixed(1)} BC per turn`, 17, '#9fd8ff', 2);
@ -426,6 +453,13 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
// redrawn — including this one, which may have been clipped by the
// room the locked channels left.
sliders.forEach((other, j) => other.setValue(colony.sliders[CHANNELS[j]] ?? 0));
// Refresh the build queue's ETAs live as the allocation changes —
// but ONLY the flyout (flyLayer), never buildPanel()/panelLayer:
// the slider being dragged lives in panelLayer, and rebuilding that
// mid-drag would destroy its own interactive zone and cut the drag
// short. buildQueueFlyout() never touches panelLayer, so this is
// safe to call on every drag tick.
if (flyMode === 'queue') buildQueueFlyout();
onChanged?.();
}, CHANNEL_COLOUR[ch]);
panelLayer.add(s.container);
@ -962,7 +996,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
y += 46;
transportLayer.add(scene.add.text(x, y,
`From ${colony.name ?? `${star.name} ${ORBIT[colony.orbit] ?? colony.orbit + 1}`}${colony.pop.toFixed(1)} population`, {
`From ${colony.name ?? `${star.name} ${ORBIT[colony.orbit] ?? colony.orbit + 1}`}${Math.round(colony.pop)} population`, {
fontFamily: FONT, fontSize: '15px', color: '#9fb6cc',
}));
y += 34;
@ -996,7 +1030,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
fontFamily: FONT, fontSize: '16px', color: '#e8f4ff',
}));
listCol.content.add(scene.add.text(12, ly + 25,
`${c.pop.toFixed(1)} population · ${Number.isFinite(eta) ? `${eta} turn${eta === 1 ? '' : 's'}` : 'unreachable'}`, {
`${Math.round(c.pop)} population · ${Number.isFinite(eta) ? `${eta} turn${eta === 1 ? '' : 's'}` : 'unreachable'}`, {
fontFamily: FONT, fontSize: '13px', color: '#8fa8c0',
}));
ly += 48;

View File

@ -21,11 +21,15 @@ export const GNN_STORY_TYPES = new Set([
]);
/**
* Whether `ev` should ever become a GNN story. `techDone` is filtered twice:
* only research-sourced completions (never espionage-stolen or traded techs
* nobody can headline a tech nobody publicly knows was taken) count, and
* Whether `ev` should ever become a GNN story. `techDone` is filtered three
* ways: only research-sourced completions (never espionage-stolen or traded
* techs nobody can headline a tech nobody publicly knows was taken) count;
* only techs hand-flagged `gnnHeadline` in the rules data count as
* "breakthrough" news; the other ~75 techs stay ordinary.
* "breakthrough" news, the other ~75 techs stay ordinary; and it's
* contact-gated on the human (Brian's ask) — GNN can't headline a
* breakthrough by a species the player hasn't met yet, same logic as
* rankingRows/RANKING_METRICS staying off the page rotation until first
* contact. The human's own breakthroughs always qualify.
*
* `sabotage`/`techStolen` are visibility-gated on the VIEWER (the human),
* since a successful mission never reveals who did it (see
@ -39,7 +43,9 @@ export const GNN_STORY_TYPES = new Set([
export function isGnnStory(rules, state, ev) {
if (!GNN_STORY_TYPES.has(ev.type)) return false;
if (ev.type === 'techDone') {
return ev.source === 'research' && !!rules.techs[ev.techId]?.gnnHeadline;
if (ev.source !== 'research' || !rules.techs[ev.techId]?.gnnHeadline) return false;
const me = state.humanIndex;
return ev.empire === me || !!state.empires[me]?.contacted?.[ev.empire];
}
if (ev.type === 'sabotage' || ev.type === 'techStolen') {
const me = state.humanIndex;

View File

@ -592,7 +592,15 @@ export function openGnnScreen(scene, rules, state, art, opts = {}) {
// the escape hatch for a player who does want the charts.
const isEspionageAlert = !usingHistory
&& storyPages.some((p) => p.desc.kind === 'espionage' || p.desc.kind === 'espionageResult');
const rankingPages = isEspionageAlert ? [] : Gnn.RANKING_METRICS.map((metric) => ({ kind: 'ranking', metric }));
// The charts rank the human against every empire it has met (rankingRows
// is contact-gated) — with nobody met yet that's just a one-bar "race"
// against yourself, which reads as broken rather than informative. So the
// whole ranking rotation stays off the page list until first contact
// (Brian's ask), same suppression flag as the espionage-alert case above.
const me = state.humanIndex;
const hasMetAnyone = state.empires.some((o) => o.alive && o.idx !== me && state.empires[me].contacted[o.idx]);
const rankingPages = (isEspionageAlert || !hasMetAnyone)
? [] : Gnn.RANKING_METRICS.map((metric) => ({ kind: 'ranking', metric }));
// Always-accessible otherwise: present every time GNN opens, independent
// of whether there's a pending story — same footing as the ranking pages.
const relationsPage = isEspionageAlert ? null : { kind: 'relations' };

View File

@ -0,0 +1,144 @@
// Master of Vega — the leader detail pop-over.
//
// Clicking a leader's portrait anywhere (fleet side panel, colony view)
// opens this: mirrors openShipDetail's chrome (full veil at D.detail, corner
// ticks, grow-from-nothing entrance) but for a leader — the bonus they are
// actually providing at their current posting, not just their catalogue
// skill bag.
//
// Like openShipDetail, it does NOT go through MasterOfVegaGame.openModal —
// it draws its own veil and clears whatever screen it was opened from.
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from './VegaButton.js';
import { leaderSkillSummary } from './VegaLeaders.js';
import { FONT, D, uiClick } from './VegaScreens.js';
import { playSound, SFX } from '../../ui/Sounds.js';
const ACCENT = 0x6fc4ff;
const PANEL = 0x0b1220;
const PANEL_W = 620;
// Tall enough for a 3-skill leader (e.g. Elis Sedra) plus a two-line bio
// without the PROVIDES list running into the Close button.
const PANEL_H = 620;
/**
* @param {object} opts
* leader the rules leader def (name, kind, bio, skills, portraitFrame, upkeep).
* where one-line description of the current posting, e.g.
* "Your fleet at Vega Prime" or "Vega Prime (colony)".
* @param {function} onClose called after the window has finished closing.
* @returns {{ close: function }}
*/
export function openLeaderDetail(scene, rules, state, art, opts, onClose) {
const { leader, where } = opts;
playSound(scene, SFX.VEGA_UNIT);
const halfW = PANEL_W / 2;
const halfH = PANEL_H / 2;
const layer = scene.add.container(0, 0).setDepth(D.detail);
const veil = scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0)
.setOrigin(0, 0).setInteractive();
layer.add(veil);
scene.tweens.add({ targets: veil, fillAlpha: 0.8, duration: 180 });
const pop = scene.add.container(GAME_WIDTH / 2, GAME_HEIGHT / 2);
pop.setScale(0.2).setAlpha(0.3);
layer.add(pop);
const panel = scene.add.rectangle(-halfW, -halfH, PANEL_W, PANEL_H, PANEL, 0.97).setOrigin(0, 0);
panel.setStrokeStyle(2, ACCENT, 0.85);
pop.add(panel);
const ticks = scene.add.graphics();
ticks.lineStyle(3, ACCENT, 0.9);
const t = 30;
for (const [tx, ty, dx, dy] of [
[-halfW, -halfH, 1, 1], [halfW, -halfH, -1, 1],
[-halfW, halfH, 1, -1], [halfW, halfH, -1, -1],
]) {
ticks.lineBetween(tx, ty, tx + dx * t, ty);
ticks.lineBetween(tx, ty, tx, ty + dy * t);
}
pop.add(ticks);
pop.add(scene.add.image(0, -halfH + 130, art.leaders, leader.portraitFrame).setDisplaySize(220, 220));
pop.add(scene.add.text(0, -halfH + 258, leader.name.toUpperCase(), {
fontFamily: FONT, fontSize: '26px', color: '#cfe8ff',
}).setOrigin(0.5));
pop.add(scene.add.text(0, -halfH + 290, leader.kind === 'admin' ? 'Colony Administrator' : 'Fleet Captain', {
fontFamily: FONT, fontSize: '15px', color: '#9fd8ff',
}).setOrigin(0.5));
let y = -halfH + 322;
const colX = -halfW + 40;
const colW = PANEL_W - 80;
const text = (str, size, colour, gap = 6, align = 0.5) => {
const o = scene.add.text(align === 0.5 ? 0 : colX, y, str, {
fontFamily: FONT, fontSize: `${size}px`, color: colour, lineSpacing: 3,
wordWrap: { width: colW }, align: align === 0.5 ? 'center' : 'left',
}).setOrigin(align, 0);
pop.add(o);
y += o.height + gap;
return o;
};
text(leader.bio, 15, '#a8c4e0', 12);
const heading = (label) => {
y += 4;
pop.add(scene.add.text(colX, y, label, {
fontFamily: FONT, fontSize: '13px', color: '#6f8aa3',
}));
y += 20;
pop.add(scene.add.rectangle(colX, y, colW, 1, ACCENT, 0.22).setOrigin(0, 0));
y += 10;
};
heading('CURRENT POSTING');
text(where ?? 'Unassigned — costs upkeep, provides nothing.', 16, where ? '#7fd8a0' : '#e08a8a', 4, 0);
heading('PROVIDES');
const bonuses = leaderSkillSummary(leader.skills);
if (bonuses.length) {
for (const b of bonuses) text(b, 16, '#ffd88a', 4, 0);
} else {
text('No active bonus.', 15, '#7f97b3', 4, 0);
}
y += 6;
text(`Upkeep ${leader.upkeep} BC/turn`, 14, '#6f8aa3', 4, 0);
// ------------------------------------------------------------------ close
let closing = false;
const close = () => {
if (closing) return;
closing = true;
scene.tweens.add({ targets: veil, fillAlpha: 0, duration: 160 });
scene.tweens.add({
targets: pop,
scale: 0.2,
alpha: 0.25,
duration: 180,
ease: 'Cubic.easeIn',
onComplete: () => {
layer.destroy();
onClose?.();
},
});
};
pop.add(new Button(scene, halfW - 110, halfH - 46, 'Close', uiClick(scene, close),
{ width: 180, height: 52, fontSize: 22, variant: 'ghost' }));
veil.on('pointerup', close);
scene.tweens.add({
targets: pop, scale: 1, alpha: 1, duration: 260, ease: 'Back.easeOut',
});
return { close };
}

View File

@ -38,6 +38,33 @@ export function leaderOf(rules, state, e, kind, id) {
return l ? rules.leaders[l.leaderId] : null;
}
// Human-readable form of a leader's `skills` bag, for screens that show a
// posted leader's bonus rather than just their name (the Leaders screen's own
// hire listing gets away with raw key/value pairs, but that reads badly next
// to a fleet's other stats). Falls back to the raw key for anything not
// covered here rather than hiding a skill the rules ever add.
const SKILL_LABELS = {
shipAttack: (v) => `+${v} attack`,
shipDefense: (v) => `+${v} defense`,
initiative: (v) => `+${v} initiative`,
speedBonus: (v) => `+${v} speed`,
rangeBonus: (v) => `+${v} range`,
repairPerRound: (v) => `+${Math.round(v * 100)}% repair/round`,
groundAttack: (v) => `+${v} ground attack`,
groundDefense: (v) => `+${v} ground defense`,
industryMult: (v) => `+${Math.round((v - 1) * 100)}% industry`,
researchMult: (v) => `+${Math.round((v - 1) * 100)}% research`,
tradeMult: (v) => `+${Math.round((v - 1) * 100)}% trade`,
growthMult: (v) => `+${Math.round((v - 1) * 100)}% growth`,
ecologyMult: (v) => `${Math.round((1 - v) * 100)}% less pollution`,
maxPopBonus: (v) => `+${v} max pop`,
espionage: (v) => `+${v} espionage`,
counterEspionage: (v) => `+${v} counter-espionage`,
};
export function leaderSkillSummary(skills) {
return Object.entries(skills ?? {}).map(([k, v]) => (SKILL_LABELS[k] ? SKILL_LABELS[k](v) : `${k} ${v}`));
}
export function unassignedLeaders(rules, state, e) {
return state.empires[e].leaders.filter((l) => l.assignKind === null || l.assignId < 0);
}

View File

@ -457,6 +457,12 @@ export function createGame(rules, opts) {
pendingOffers: {},
tradeAgreements: {},
lastGiftTurn: {},
// colony.id -> the turn this empire last bombarded it. Caps bombard()
// at once per (attacker, colony) per turn — see bombard()'s own
// comment for why: unlike invade() it spends no resource of its own
// (warships aren't consumed), so without this cap the UI's Bombard
// button could be clicked any number of times in one turn.
lastBombardTurn: {},
fleetIntrusions: {},
explored: {},
spyPoints: 0,
@ -854,7 +860,7 @@ function processColony(rules, state, colony) {
if (colony.queue.length === 0) buildSpill = build;
// --- Research absorbs everything left over.
const research = share.research + ecoSpill + buildSpill;
const research = (share.research + ecoSpill + buildSpill) * (skills.researchMult ?? 1);
// --- Population.
const maxPop = colonyMaxPop(rules, state, colony);
@ -981,7 +987,9 @@ function runEspionage(rules, state, e) {
const spec = rules.species[emp.speciesId];
const comps = empireComponents(rules, state, e);
let power = (spec.traits.espionage ?? 0) + comps.espionage;
for (const c of empireColonies(state, e)) power += buildingEffect(rules, c, 'espionage');
for (const c of empireColonies(state, e)) {
power += buildingEffect(rules, c, 'espionage') + (colonyLeaderSkills(rules, state, c).espionage ?? 0);
}
if (power <= 0) return;
// Only the surplus over a baseline counts. A Battle-Scanner-and-nothing-else
@ -1007,7 +1015,9 @@ function runEspionage(rules, state, e) {
const mission = emp.espionageMission[target.idx] ?? 'steal';
const tspec = rules.species[target.speciesId];
let defence = (tspec.traits.counterEspionage ?? 0) + empireComponents(rules, state, target.idx).counterEspionage;
for (const c of empireColonies(state, target.idx)) defence += buildingEffect(rules, c, 'espionage');
for (const c of empireColonies(state, target.idx)) {
defence += buildingEffect(rules, c, 'espionage') + (colonyLeaderSkills(rules, state, c).counterEspionage ?? 0);
}
const odds = Math.max(0.05, Math.min(0.8, 0.4 + (power - defence) / 200));
if (rand(state) > odds) {
@ -1590,6 +1600,14 @@ export function holdsOrbit(rules, state, e, starIdx, defenderIdx) {
return defPower <= 0 || attPower > defPower;
}
// Whether `e` has already bombarded this exact colony this turn — exported
// so the UI can grey out/hide the Bombard button proactively instead of
// letting the player click it into a silent no-op (bombard() itself returns
// null either way, same as every other precondition failure).
export function bombardedThisTurn(state, e, colony) {
return state.empires[e].lastBombardTurn[colony.id] === state.turn;
}
// Bombard a colony from orbit, killing population.
//
// This is MOO1's answer to a cornered empire, and without it conquest is
@ -1598,6 +1616,20 @@ 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.
//
// Capped at once per (attacker, colony) per turn (lastBombardTurn, keyed by
// colony.id on the attacking empire — not global, so two different empires
// bombarding the same contested world in the same turn don't block each
// other). Unlike invade(), which naturally self-limits by consuming the
// troop transports that carried it out, bombard() spends no resource of its
// own — warships aren't expended — so without this cap VegaSystemView.js's
// Bombard button could be clicked any number of times in one turn and wipe
// a colony's population instantly regardless of fleet size. This also
// quietly fixes the same bug on the AI side: VegaAI.js calls bombard() once
// per fleet it has at a hostile star, and bombard() has always summed EVERY
// attacker fleet at that star regardless of which one was passed in, so an
// AI with two fleets at one siege was double-charging the same pooled
// damage before this cap existed.
export function bombard(rules, state, e, starIdx, orbit = null) {
const colony = targetColonyAt(state, e, starIdx, orbit);
if (!colony || colony.empireIdx === e) return null;
@ -1605,6 +1637,7 @@ export function bombard(rules, state, e, starIdx, orbit = null) {
if (!holdsOrbit(rules, state, e, starIdx, colony.empireIdx)) return null;
const emp = state.empires[e];
if (bombardedThisTurn(state, e, colony)) return null;
const spec = rules.species[emp.speciesId];
let damage = 0;
let cracker = false;
@ -1618,6 +1651,7 @@ export function bombard(rules, state, e, starIdx, orbit = null) {
}
}
if (damage <= 0) return null;
emp.lastBombardTurn[colony.id] = state.turn;
const comps = empireComponents(rules, state, e);
const shield = colony.buildings.includes('planetaryshield')
@ -1653,8 +1687,8 @@ export function bombard(rules, state, e, starIdx, orbit = null) {
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)
const invadingFleets = state.fleets.filter((f) => f.starIdx === starIdx && f.empireIdx === e);
const troops = invadingFleets
.reduce((t, f) => t + f.ships
.filter((s) => s.hullId === 'transport')
.reduce((n, s) => n + s.count, 0), 0) * (rules.hulls.transport.troops ?? 4);
@ -1662,7 +1696,8 @@ export function invasionForecast(rules, state, e, starIdx, orbit = null) {
const spec = rules.species[state.empires[e].speciesId];
const comps = empireComponents(rules, state, e);
const attackBonus = (spec.traits.groundAttack ?? 0) + (comps.groundAttack ?? 0);
const leaderBonus = invadingFleets.reduce((t, f) => t + (fleetLeaderSkills(rules, state, f).groundAttack ?? 0), 0);
const attackBonus = (spec.traits.groundAttack ?? 0) + (comps.groundAttack ?? 0) + leaderBonus;
const defenceBonus = colonyGroundDefense(rules, state, colony) + colony.defenseHp / 45;
const defenders = Math.max(1, Math.round(colony.pop / 8)) + Math.round(defenceBonus / 10);
const odds = Math.max(0.1, Math.min(0.9,
@ -1704,7 +1739,8 @@ export function invade(rules, state, e, starIdx, orbit = null) {
const spec = rules.species[emp.speciesId];
const comps = empireComponents(rules, state, e);
const troops = stack.count * (rules.hulls.transport.troops ?? 4);
const attackBonus = (spec.traits.groundAttack ?? 0) + (comps.groundAttack ?? 0);
const attackBonus = (spec.traits.groundAttack ?? 0) + (comps.groundAttack ?? 0)
+ (fleetLeaderSkills(rules, state, fleet).groundAttack ?? 0);
// Intact orbital batteries shell the landing zones, but only as a modifier —
// at a twelfth of their hit points they alone drove the attacker's odds to
// the 10% floor and only 21 of 268 landings succeeded.
@ -2591,6 +2627,7 @@ export function deserialize(json) {
// gift cooldown tracking, and foreign-fleet dwell tracking.
emp.tradeAgreements ??= {};
emp.lastGiftTurn ??= {};
emp.lastBombardTurn ??= {};
emp.fleetIntrusions ??= {};
emp.espionageMission ??= {};
}

View File

@ -109,6 +109,31 @@ export function modalShell(scene, title, onClose, { width = 1220, height = 800,
};
}
/**
* Click-to-enlarge a portrait thumbnail. Sits at D.detail so it clears the
* Leaders modal (D.modal) without either having to be torn down; a click
* anywhere (veil or portrait) closes it, same one-shot-overlay feel as
* openListPicker.
*/
function openPortraitZoom(scene, art, frame, name) {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const box = scene.add.container(0, 0).setDepth(D.detail);
const close = () => { playSound(scene, SFX.VEGA_CLOSE); box.destroy(); };
box.add(scene.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000914, 0.82)
.setOrigin(0.5).setInteractive({ useHandCursor: true }).on('pointerdown', close));
const size = 420;
box.add(scene.add.rectangle(cx, cy, size + 20, size + 20, PANEL, 0.98).setStrokeStyle(2, ACCENT));
box.add(scene.add.image(cx, cy, art.leaders, frame).setDisplaySize(size, size)
.setInteractive({ useHandCursor: true }).on('pointerdown', close));
if (name) {
box.add(scene.add.text(cx, cy + size / 2 + 30, name, {
fontFamily: FONT, fontSize: '20px', color: '#ffd88a',
}).setOrigin(0.5));
}
return box;
}
/** A labelled horizontal slider. Returns { container, setValue }. */
export function slider(scene, x, y, w, label, value, onChange, colour = ACCENT) {
const c = scene.add.container(x, y);
@ -401,7 +426,8 @@ export function openLeaderScreen(scene, rules, state, e, art, onClose, onChanged
const cost = leaderHireCost(rules, state, e, offer.id);
const affordable = emp.bc >= cost;
shell.add(scene.add.image(shell.body.x + 32, y + 30, art.leaders, offer.portraitFrame)
.setDisplaySize(60, 60));
.setDisplaySize(60, 60).setInteractive({ useHandCursor: true })
.on('pointerdown', () => openPortraitZoom(scene, art, offer.portraitFrame, offer.name)));
shell.add(scene.add.text(shell.body.x + 76, y + 4, `${offer.name}${offer.kind}`, {
fontFamily: FONT, fontSize: '19px', color: '#e8f4ff',
}));
@ -445,7 +471,8 @@ export function openLeaderScreen(scene, rules, state, e, art, onClose, onChanged
where = f ? fleetLabel(rules, state, f) : 'unassigned';
}
shell.add(scene.add.image(shell.body.x + 24, y + 18, art.leaders, def.portraitFrame)
.setDisplaySize(40, 40));
.setDisplaySize(40, 40).setInteractive({ useHandCursor: true })
.on('pointerdown', () => openPortraitZoom(scene, art, def.portraitFrame, def.name)));
shell.add(scene.add.text(shell.body.x + 56, y + 6,
`${def.name}${def.kind}${where}${def.upkeep} BC/turn`, {
fontFamily: FONT, fontSize: '16px', color: '#c8dcf0',

View File

@ -35,6 +35,8 @@ import { playSound, SFX } from '../../ui/Sounds.js';
import { etaText } from './VegaColonyView.js';
import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js';
import { openShipDetail } from './VegaShipDetail.js';
import { openLeaderDetail } from './VegaLeaderDetail.js';
import { leaderOf, leaderSkillSummary } from './VegaLeaders.js';
const W = 400;
const PAD = 18;
@ -364,6 +366,20 @@ export default class VegaSidePanel {
);
}
/** Same one-detail-window-at-a-time gate as openDetail, for a leader row. */
openLeaderDetailFor(leader, where) {
if (this.detail) return;
this.pool.setPaused(true);
this.detail = openLeaderDetail(
this.scene, this.rules, this.state, this.art,
{ leader, where },
() => {
this.detail = null;
if (this.root.visible) this.pool.setPaused(false);
},
);
}
/**
* A read-only `N × Name` line with thumbnails the in-transit list, the
* garrison and the order screen's task force all take this shape. The
@ -483,7 +499,7 @@ export default class VegaSidePanel {
if (!mine) {
// What a rival tells you about their colony is what you can see from
// orbit: how many people live there, and whether they are shooting.
this.line(`Population ${colony.pop.toFixed(1)}`, { size: 14, color: '#9fb6cc', gap: 2 });
this.line(`Population ${Math.round(colony.pop)}`, { size: 14, color: '#9fb6cc', gap: 2 });
if (this.viewerIdx >= 0 && atWar(state, this.viewerIdx, colony.empireIdx)) {
this.line('At war — bombard and invade from the system view.',
{ size: 13, color: '#e08a8a' });
@ -495,7 +511,7 @@ export default class VegaSidePanel {
const prod = colonyProduction(rules, state, colony);
this.line(
`Population ${colony.pop.toFixed(1)} / ${colonyMaxPop(rules, state, colony)}\n`
`Population ${Math.round(colony.pop)} / ${colonyMaxPop(rules, state, colony)}\n`
+ `Factories ${Math.floor(effectiveFactories(rules, state, colony))} / ${colonyFactoryCap(rules, state, colony)}\n`
+ `Output ${prod.toFixed(1)} BC per turn\n`
+ `Defences ${Math.round(colony.defenseHp)} / ${colonyDefenseCap(rules, state, colony)}`
@ -579,6 +595,7 @@ export default class VegaSidePanel {
this.setHead('Fleet', 'Under way');
this.line(`${from?.name ?? '?'}${to?.name ?? '?'}`, { size: 17, color: '#cfe8ff' });
this.etaLine(fleetEta(rules, state, fleet));
this.leaderRow();
this.shipList();
this.y += 8;
this.callout('A fleet in transit cannot be redirected until it arrives.', '#e0b08a');
@ -589,6 +606,7 @@ export default class VegaSidePanel {
this.setHead('Fleet', `In orbit at ${star.name}`);
this.line(`${total} ship${total === 1 ? '' : 's'} · power ${fleetPower(rules, state, fleet)}`,
{ size: 15, color: '#9fb6cc' });
this.leaderRow();
this.heading('Task force');
if (!this.sel.length) {
@ -695,6 +713,34 @@ export default class VegaSidePanel {
});
}
/**
* The fleet's posted captain, if any, and the bonus they are applying.
* Clicking the row opens the same detail pop-over a ship row does.
*/
leaderRow() {
const { rules, state, fleet } = this;
const leader = leaderOf(rules, state, fleet.empireIdx, 'fleet', fleet.id);
if (!leader) return;
const rowY = this.y;
const SIZE = 44;
this.body.add(this.scene.add.image(PAD + SIZE / 2, rowY + SIZE / 2, this.art.leaders, leader.portraitFrame)
.setDisplaySize(SIZE, SIZE));
const textX = PAD + SIZE + 14;
this.line(leader.name, { x: textX, size: 15, color: '#ffd88a', gap: 2, wrap: COL - SIZE - 14 });
this.line(leaderSkillSummary(leader.skills).join(' · ') || 'No active bonus',
{ x: textX, size: 13, color: '#7fd8a0', wrap: COL - SIZE - 14 });
const rowH = Math.max(SIZE, this.y - rowY);
this.y = Math.max(this.y, rowY + SIZE + 10);
const where = fleet.starIdx >= 0
? `Your fleet at ${state.galaxy.stars[fleet.starIdx]?.name ?? '?'}`
: `Your fleet, en route to ${state.galaxy.stars[fleet.toStar]?.name ?? '?'}`;
const hit = this.scene.add.rectangle(PAD, rowY, COL, rowH, 0xffffff, 0.001)
.setOrigin(0, 0).setInteractive({ useHandCursor: true });
hit.on('pointerup', () => this.openLeaderDetailFor(leader, where));
this.body.add(hit);
}
shipList() {
this.heading('Ships');
for (const s of this.fleet.ships) {
@ -753,7 +799,7 @@ export default class VegaSidePanel {
for (const c of hostiles) {
const owner = state.empires[c.empireIdx];
const war = this.viewerIdx >= 0 && atWar(state, this.viewerIdx, c.empireIdx);
this.line(`${owner.name} colony — pop ${c.pop.toFixed(1)}${war ? ' — at war' : ''}`,
this.line(`${owner.name} colony — pop ${Math.round(c.pop)}${war ? ' — at war' : ''}`,
{ size: 14, color: war ? '#e08a8a' : owner.color, gap: 2 });
}
for (const f of enemyFleets) {

View File

@ -449,7 +449,7 @@ export default class VegaStarMap {
const own = cols.filter((c) => c.empireIdx === emp.idx).sort((a, b) => a.orbit - b.orbit);
if (!own.length) continue;
const text = showText ? this.scene.add.text(0, 0, own.map((c) => (
`${c.name ?? `${s.star.name} ${ORBIT[c.orbit] ?? c.orbit + 1}`} (pop. ${c.pop.toFixed(1)})`
`${c.name ?? `${s.star.name} ${ORBIT[c.orbit] ?? c.orbit + 1}`} (pop. ${Math.round(c.pop)})`
)).join('\n'), {
fontFamily: FONT, fontSize: `${textSize}px`, color: '#f0f4fa', lineSpacing: 2,
}).setOrigin(1, 0) : null;

View File

@ -24,7 +24,7 @@ import {
CHANNELS, coloniesAt, canColonize, colonize, colonyMaxPop, colonyProduction,
colonyFactoryCap, effectiveFactories, colonyDefenseCap, colonyBuildRate,
collapseQueue, queueItemCost, queueEtas, invasionForecast, invade, bombard, atWar,
peekColonyName,
peekColonyName, bombardedThisTurn,
} from './VegaLogic.js';
export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
@ -205,7 +205,7 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
const maxPop = colonyMaxPop(rules, state, colony);
const prod = colonyProduction(rules, state, colony);
console_.add(scene.add.text(panelX, y,
`Population ${colony.pop.toFixed(1)} / ${maxPop}\n`
`Population ${Math.round(colony.pop)} / ${maxPop}\n`
+ `Factories ${Math.floor(effectiveFactories(rules, state, colony))} / ${colonyFactoryCap(rules, state, colony)}\n`
+ `Output ${prod.toFixed(1)} BC Defences ${Math.round(colony.defenseHp)} / ${colonyDefenseCap(rules, state, colony)}`
+ (colony.waste > 0.5 ? `\nUncleaned waste ${colony.waste.toFixed(1)}` : ''), {
@ -217,6 +217,7 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
// 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, colony.orbit);
const alreadyBombarded = bombardedThisTurn(state, viewerIdx, colony);
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,6 +225,12 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
wordWrap: { width: panelW },
}));
y += 44;
if (alreadyBombarded) {
console_.add(scene.add.text(panelX, y, 'Already bombarded this world this turn — try again next turn.', {
fontFamily: FONT, fontSize: '14px', color: '#7f97b3',
}));
y += 26;
}
// 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()
@ -231,7 +238,11 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
// 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 is capped at once per colony per turn (VegaLogic.js's
// lastBombardTurn) — disabled here rather than hidden, since it's a
// temporary "wait for next turn" state, not a structural
// unavailability like Invade's "no transports" below.
const bombardBtn = new Button(scene, panelX + 120, y + 20, 'Bombard', uiClick(scene, () => {
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.');
@ -241,7 +252,9 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
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 (alreadyBombarded) bombardBtn.setEnabled(false);
console_.add(bombardBtn);
if (forecast && forecast.troops > 0) {
console_.add(new Button(scene, panelX + 370, y + 20, 'Invade', uiClick(scene, () => {
const result = invade(rules, state, viewerIdx, starIdx, colony.orbit);

View File

@ -6,6 +6,7 @@
import { COLORS } from '../../config.js';
import { coloniesAt, habitableForEmpire } from './VegaLogic.js';
import { techCost, techCostFactor } from './VegaRules.js';
import { leaderSkillSummary } from './VegaLeaders.js';
// Hover tooltip for a star on the map. `viewerEmp` is the human empire (or
// null in observer mode, which sees everything). An unexplored star only
@ -34,7 +35,7 @@ export function describeStarTooltip(rules, state, viewerEmp, starIdx) {
for (const c of cols) {
const owner = state.empires[c.empireIdx];
lines.push({
text: `${owner.name} colony — pop ${c.pop.toFixed(1)}${c.capital ? ' (capital)' : ''}`,
text: `${owner.name} colony — pop ${Math.round(c.pop)}${c.capital ? ' (capital)' : ''}`,
color: owner.color,
});
}
@ -107,3 +108,20 @@ export function describeBuildingTooltip(rules, buildingId) {
],
};
}
// Hover tooltip for a colony's posted administrator, shown beside the
// building icon row. `leader` is the rules leader def (name/bio/skills).
export function describeLeaderTooltip(leader) {
const bonuses = leaderSkillSummary(leader.skills);
return {
title: leader.name,
titleColor: COLORS.goldHex,
lines: [
{ text: 'Colony Administrator', color: '#9fd8ff' },
...(bonuses.length
? bonuses.map((b) => ({ text: b, color: COLORS.goldHex }))
: [{ text: 'No active bonus.', color: COLORS.mutedHex }]),
{ text: leader.bio },
],
};
}

View File

@ -1423,6 +1423,55 @@ section('4c. Population transport');
!!bombardResult, JSON.stringify(bombardResult));
}
}
// --- bombard() is capped at once per (attacker, colony) per turn. Without
// it, VegaSystemView.js's Bombard button could be clicked any number of
// times in a single turn — warships aren't consumed the way invade()'s
// transports are, so nothing else stopped a colony being wiped out
// instantly regardless of fleet size.
{
const stB = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'cluster', seed: 4747, difficultyId: 'normal',
speciesIds: ['human', 'kkrix', 'rrashaa'], humanIndex: 0,
});
stB.rules = RULES;
let starIdxB = -1;
for (let i = 0; i < stB.galaxy.stars.length; i += 1) {
if ((stB.galaxy.stars[i].planets?.length ?? 0) >= 1 && !stB.galaxy.homeIdx.includes(i)) { starIdxB = i; break; }
}
check('bombard-cap test galaxy has a spare star', starIdxB >= 0);
if (starIdxB >= 0) {
const target = Logic.foundColony(RULES, stB, 1, starIdxB, 0, 500); // big enough to survive one hit
Diplo.declareWar(RULES, stB, 0, 1);
Logic.addFleet(RULES, stB, 0, starIdxB, [{ hullId: 'frigate', count: 3, mark: 1 }]);
const first = Logic.bombard(RULES, stB, 0, starIdxB);
check('bombard-cap fixture: first bombard succeeds and does not destroy the colony',
!!first && !first.destroyed, JSON.stringify(first));
check('bombardedThisTurn reports true immediately after a successful bombard',
Logic.bombardedThisTurn(stB, 0, target) === true);
const popAfterFirst = target.pop;
const second = Logic.bombard(RULES, stB, 0, starIdxB);
check('a second bombard on the same colony in the same turn is refused', second === null);
check('a refused repeat bombard costs no additional population', target.pop === popAfterFirst);
stB.turn += 1;
const nextTurn = Logic.bombard(RULES, stB, 0, starIdxB);
check('bombard works again on a later turn', !!nextTurn);
check('a later-turn bombard actually reduces population further', target.pop < popAfterFirst);
// The cap is per (attacker, colony), not global — a different attacker
// bombarding the same colony in the same turn is unaffected.
Diplo.declareWar(RULES, stB, 2, 1);
Logic.addFleet(RULES, stB, 2, starIdxB, [{ hullId: 'frigate', count: 3, mark: 1 }]);
const popBeforeOther = target.pop;
const otherAttacker = Logic.bombard(RULES, stB, 2, starIdxB);
check('a second, different attacker can still bombard the same colony in the same turn',
!!otherAttacker, JSON.stringify(otherAttacker));
check('the other attacker\'s bombard actually lands', target.pop < popBeforeOther);
}
}
}
// ---------------------------------------------------------------------------
@ -2886,6 +2935,8 @@ section('7b. Galactic News Network');
const evResearchHeadline = { type: 'techDone', empire: 0, techId: headlineTech, source: 'research' };
const evEspionageHeadline = { type: 'techDone', empire: 0, techId: headlineTech, source: 'espionage' };
const evResearchPlain = { type: 'techDone', empire: 0, techId: plainTech, source: 'research' };
const evResearchHeadlineMetBystander = { type: 'techDone', empire: 3, techId: headlineTech, source: 'research' };
const evResearchHeadlineUnmetBystander = { type: 'techDone', empire: 5, techId: headlineTech, source: 'research' };
const evSabotageMine = { type: 'sabotage', empire: 0, target: 1, starIdx: 0, factoriesLost: 2, defenseLost: 3 };
const evTechStolenMine = { type: 'techStolen', empire: 0, target: 1, techId: headlineTech };
const evTechStolenVictimIsMe = { type: 'techStolen', empire: 1, target: 0, techId: headlineTech };
@ -2900,6 +2951,10 @@ section('7b. Galactic News Network');
check('espionage-sourced headline tech is NOT a GNN story (nobody can publicly name the thief\'s target)',
!Gnn.isGnnStory(RULES, fakeState, evEspionageHeadline));
check('research-sourced non-headline tech is NOT a GNN story', !Gnn.isGnnStory(RULES, fakeState, evResearchPlain));
check('a met bystander\'s research-sourced headline tech IS a GNN story',
Gnn.isGnnStory(RULES, fakeState, evResearchHeadlineMetBystander));
check('an unmet bystander\'s research-sourced headline tech is NOT a GNN story (never contacted)',
!Gnn.isGnnStory(RULES, fakeState, evResearchHeadlineUnmetBystander));
check('the player\'s own successful sabotage IS a GNN story, attributed to them',
Gnn.isGnnStory(RULES, fakeState, evSabotageMine));
check('the player\'s own successful tech theft IS a GNN story, attributed to them',