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:
parent
8f0b079fa4
commit
44868e5032
|
|
@ -1,364 +1,55 @@
|
||||||
{
|
{
|
||||||
"level": 9,
|
"level": 9,
|
||||||
"name": "Thunder Steps",
|
"name": "Toga",
|
||||||
"frog": [
|
"frog": [
|
||||||
1620,
|
267,
|
||||||
550
|
825
|
||||||
],
|
],
|
||||||
"colors": 5,
|
"colors": 4,
|
||||||
"background": "zuma-background-2",
|
"seed": 12345,
|
||||||
"seed": 72271,
|
"starScores": [
|
||||||
"starScores": [
|
640,
|
||||||
1210,
|
960,
|
||||||
1820,
|
1280
|
||||||
2420
|
],
|
||||||
],
|
"paths": [
|
||||||
"paths": [
|
{
|
||||||
{
|
"shape": "custom",
|
||||||
"shape": "zigzag",
|
"points": [
|
||||||
"points": [
|
[
|
||||||
[
|
-51,
|
||||||
-80,
|
233
|
||||||
130
|
],
|
||||||
],
|
[
|
||||||
[
|
1610,
|
||||||
-11,
|
243
|
||||||
130
|
],
|
||||||
],
|
[
|
||||||
[
|
1706,
|
||||||
58,
|
965
|
||||||
130
|
],
|
||||||
],
|
[
|
||||||
[
|
671,
|
||||||
126,
|
819
|
||||||
130
|
],
|
||||||
],
|
[
|
||||||
[
|
583,
|
||||||
195,
|
378
|
||||||
130
|
],
|
||||||
],
|
[
|
||||||
[
|
1195,
|
||||||
264,
|
409
|
||||||
130
|
],
|
||||||
],
|
[
|
||||||
[
|
1425,
|
||||||
333,
|
755
|
||||||
130
|
]
|
||||||
],
|
],
|
||||||
[
|
"tunnels": [],
|
||||||
401,
|
"quota": 40,
|
||||||
130
|
"introBalls": 12,
|
||||||
],
|
"pushSpeed": 45,
|
||||||
[
|
"powerUpRate": 0.06
|
||||||
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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 778 KiB |
Binary file not shown.
|
|
@ -280,7 +280,7 @@
|
||||||
},
|
},
|
||||||
"leaders": {
|
"leaders": {
|
||||||
"key": "vega-leaders",
|
"key": "vega-leaders",
|
||||||
"path": null,
|
"path": "assets/images/vega/leaders.png",
|
||||||
"kind": "portrait",
|
"kind": "portrait",
|
||||||
"frameWidth": 160,
|
"frameWidth": 160,
|
||||||
"frameHeight": 160,
|
"frameHeight": 160,
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
"_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": [
|
"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": "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": "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": 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": "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": "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": "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." },
|
{ "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." },
|
||||||
|
|
|
||||||
|
|
@ -697,7 +697,7 @@ export function openColoniesScreen(scene, rules, state, e, art, opts = {}) {
|
||||||
|
|
||||||
const maxPop = colonyMaxPop(rules, state, colony);
|
const maxPop = colonyMaxPop(rules, state, colony);
|
||||||
const popPct = colony.pop / Math.max(1, maxPop);
|
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',
|
fontFamily: FONT, fontSize: '16px',
|
||||||
color: popPct < 0.5 ? '#e08a8a' : (popPct < 0.7 ? '#e0b08a' : '#c8dcf0'),
|
color: popPct < 0.5 ? '#e08a8a' : (popPct < 0.7 ? '#e0b08a' : '#c8dcf0'),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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.
|
// tech that changes what a new colony is worth shows up here for free.
|
||||||
const cells = [
|
const cells = [
|
||||||
['POPULATION', colony.pop.toFixed(1)],
|
['POPULATION', `${Math.round(colony.pop)}`],
|
||||||
['MAX POPULATION', `${colonyMaxPop(rules, state, colony)}`],
|
['MAX POPULATION', `${colonyMaxPop(rules, state, colony)}`],
|
||||||
['OUTPUT', `${colonyProduction(rules, state, colony).toFixed(1)} BC`],
|
['OUTPUT', `${colonyProduction(rules, state, colony).toFixed(1)} BC`],
|
||||||
['CONSTRUCTION', `${colonyBuildRate(rules, state, colony).toFixed(1)} BC`],
|
['CONSTRUCTION', `${colonyBuildRate(rules, state, colony).toFixed(1)} BC`],
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,9 @@ import { playSound, SFX } from '../../ui/Sounds.js';
|
||||||
import { worldBackground, buildingFrame } from './VegaArt.js';
|
import { worldBackground, buildingFrame } from './VegaArt.js';
|
||||||
import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js';
|
import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js';
|
||||||
import { openShipDetail } from './VegaShipDetail.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 {
|
import {
|
||||||
CHANNELS, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
|
CHANNELS, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
|
||||||
colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany,
|
colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany,
|
||||||
|
|
@ -275,6 +277,13 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
||||||
{ empireIdx: colony.empireIdx, hullId },
|
{ empireIdx: colony.empireIdx, hullId },
|
||||||
() => { detail = null; if (flyMode === 'queue') pool.setPaused(false); });
|
() => { 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. */
|
/** Take the pool out of whatever is about to be destroyed. */
|
||||||
const detachPool = () => pool.layer.parentContainer?.remove(pool.layer);
|
const detachPool = () => pool.layer.parentContainer?.remove(pool.layer);
|
||||||
|
|
||||||
|
|
@ -327,6 +336,24 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
||||||
iconLayer.add(icon);
|
iconLayer.add(icon);
|
||||||
tooltip.attachTo(icon, () => describeBuildingTooltip(rules, id));
|
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
|
// ------------------------------------------------------------- the panel
|
||||||
|
|
@ -373,7 +400,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
||||||
const rate = colonyBuildRate(rules, state, colony);
|
const rate = colonyBuildRate(rules, state, colony);
|
||||||
|
|
||||||
heading('Status');
|
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(`Factories ${Math.floor(effF)} / ${capF}`, 17, '#c8dcf0', 2);
|
||||||
text(`Output ${prod.toFixed(1)} BC per turn`, 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);
|
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
|
// redrawn — including this one, which may have been clipped by the
|
||||||
// room the locked channels left.
|
// room the locked channels left.
|
||||||
sliders.forEach((other, j) => other.setValue(colony.sliders[CHANNELS[j]] ?? 0));
|
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?.();
|
onChanged?.();
|
||||||
}, CHANNEL_COLOUR[ch]);
|
}, CHANNEL_COLOUR[ch]);
|
||||||
panelLayer.add(s.container);
|
panelLayer.add(s.container);
|
||||||
|
|
@ -962,7 +996,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
||||||
y += 46;
|
y += 46;
|
||||||
|
|
||||||
transportLayer.add(scene.add.text(x, y,
|
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',
|
fontFamily: FONT, fontSize: '15px', color: '#9fb6cc',
|
||||||
}));
|
}));
|
||||||
y += 34;
|
y += 34;
|
||||||
|
|
@ -996,7 +1030,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
||||||
fontFamily: FONT, fontSize: '16px', color: '#e8f4ff',
|
fontFamily: FONT, fontSize: '16px', color: '#e8f4ff',
|
||||||
}));
|
}));
|
||||||
listCol.content.add(scene.add.text(12, ly + 25,
|
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',
|
fontFamily: FONT, fontSize: '13px', color: '#8fa8c0',
|
||||||
}));
|
}));
|
||||||
ly += 48;
|
ly += 48;
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,15 @@ export const GNN_STORY_TYPES = new Set([
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether `ev` should ever become a GNN story. `techDone` is filtered twice:
|
* Whether `ev` should ever become a GNN story. `techDone` is filtered three
|
||||||
* only research-sourced completions (never espionage-stolen or traded techs
|
* ways: only research-sourced completions (never espionage-stolen or traded
|
||||||
* — nobody can headline a tech nobody publicly knows was taken) count, and
|
* techs — nobody can headline a tech nobody publicly knows was taken) count;
|
||||||
* only techs hand-flagged `gnnHeadline` in the rules data count as
|
* 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),
|
* `sabotage`/`techStolen` are visibility-gated on the VIEWER (the human),
|
||||||
* since a successful mission never reveals who did it (see
|
* 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) {
|
export function isGnnStory(rules, state, ev) {
|
||||||
if (!GNN_STORY_TYPES.has(ev.type)) return false;
|
if (!GNN_STORY_TYPES.has(ev.type)) return false;
|
||||||
if (ev.type === 'techDone') {
|
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') {
|
if (ev.type === 'sabotage' || ev.type === 'techStolen') {
|
||||||
const me = state.humanIndex;
|
const me = state.humanIndex;
|
||||||
|
|
|
||||||
|
|
@ -592,7 +592,15 @@ export function openGnnScreen(scene, rules, state, art, opts = {}) {
|
||||||
// the escape hatch for a player who does want the charts.
|
// the escape hatch for a player who does want the charts.
|
||||||
const isEspionageAlert = !usingHistory
|
const isEspionageAlert = !usingHistory
|
||||||
&& storyPages.some((p) => p.desc.kind === 'espionage' || p.desc.kind === 'espionageResult');
|
&& 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
|
// Always-accessible otherwise: present every time GNN opens, independent
|
||||||
// of whether there's a pending story — same footing as the ranking pages.
|
// of whether there's a pending story — same footing as the ranking pages.
|
||||||
const relationsPage = isEspionageAlert ? null : { kind: 'relations' };
|
const relationsPage = isEspionageAlert ? null : { kind: 'relations' };
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,33 @@ export function leaderOf(rules, state, e, kind, id) {
|
||||||
return l ? rules.leaders[l.leaderId] : null;
|
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) {
|
export function unassignedLeaders(rules, state, e) {
|
||||||
return state.empires[e].leaders.filter((l) => l.assignKind === null || l.assignId < 0);
|
return state.empires[e].leaders.filter((l) => l.assignKind === null || l.assignId < 0);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -457,6 +457,12 @@ export function createGame(rules, opts) {
|
||||||
pendingOffers: {},
|
pendingOffers: {},
|
||||||
tradeAgreements: {},
|
tradeAgreements: {},
|
||||||
lastGiftTurn: {},
|
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: {},
|
fleetIntrusions: {},
|
||||||
explored: {},
|
explored: {},
|
||||||
spyPoints: 0,
|
spyPoints: 0,
|
||||||
|
|
@ -854,7 +860,7 @@ function processColony(rules, state, colony) {
|
||||||
if (colony.queue.length === 0) buildSpill = build;
|
if (colony.queue.length === 0) buildSpill = build;
|
||||||
|
|
||||||
// --- Research absorbs everything left over.
|
// --- Research absorbs everything left over.
|
||||||
const research = share.research + ecoSpill + buildSpill;
|
const research = (share.research + ecoSpill + buildSpill) * (skills.researchMult ?? 1);
|
||||||
|
|
||||||
// --- Population.
|
// --- Population.
|
||||||
const maxPop = colonyMaxPop(rules, state, colony);
|
const maxPop = colonyMaxPop(rules, state, colony);
|
||||||
|
|
@ -981,7 +987,9 @@ function runEspionage(rules, state, e) {
|
||||||
const spec = rules.species[emp.speciesId];
|
const spec = rules.species[emp.speciesId];
|
||||||
const comps = empireComponents(rules, state, e);
|
const comps = empireComponents(rules, state, e);
|
||||||
let power = (spec.traits.espionage ?? 0) + comps.espionage;
|
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;
|
if (power <= 0) return;
|
||||||
|
|
||||||
// Only the surplus over a baseline counts. A Battle-Scanner-and-nothing-else
|
// 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 mission = emp.espionageMission[target.idx] ?? 'steal';
|
||||||
const tspec = rules.species[target.speciesId];
|
const tspec = rules.species[target.speciesId];
|
||||||
let defence = (tspec.traits.counterEspionage ?? 0) + empireComponents(rules, state, target.idx).counterEspionage;
|
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));
|
const odds = Math.max(0.05, Math.min(0.8, 0.4 + (power - defence) / 200));
|
||||||
if (rand(state) > odds) {
|
if (rand(state) > odds) {
|
||||||
|
|
@ -1590,6 +1600,14 @@ export function holdsOrbit(rules, state, e, starIdx, defenderIdx) {
|
||||||
return defPower <= 0 || attPower > defPower;
|
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.
|
// Bombard a colony from orbit, killing population.
|
||||||
//
|
//
|
||||||
// This is MOO1's answer to a cornered empire, and without it conquest is
|
// 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,
|
// 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.
|
||||||
|
//
|
||||||
|
// 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) {
|
export function bombard(rules, state, e, starIdx, orbit = null) {
|
||||||
const colony = targetColonyAt(state, e, starIdx, orbit);
|
const colony = targetColonyAt(state, e, starIdx, orbit);
|
||||||
if (!colony || colony.empireIdx === e) return null;
|
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;
|
if (!holdsOrbit(rules, state, e, starIdx, colony.empireIdx)) return null;
|
||||||
|
|
||||||
const emp = state.empires[e];
|
const emp = state.empires[e];
|
||||||
|
if (bombardedThisTurn(state, e, colony)) return null;
|
||||||
const spec = rules.species[emp.speciesId];
|
const spec = rules.species[emp.speciesId];
|
||||||
let damage = 0;
|
let damage = 0;
|
||||||
let cracker = false;
|
let cracker = false;
|
||||||
|
|
@ -1618,6 +1651,7 @@ export function bombard(rules, state, e, starIdx, orbit = null) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (damage <= 0) return null;
|
if (damage <= 0) return null;
|
||||||
|
emp.lastBombardTurn[colony.id] = state.turn;
|
||||||
|
|
||||||
const comps = empireComponents(rules, state, e);
|
const comps = empireComponents(rules, state, e);
|
||||||
const shield = colony.buildings.includes('planetaryshield')
|
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) {
|
export function invasionForecast(rules, state, e, starIdx, orbit = null) {
|
||||||
const colony = targetColonyAt(state, e, starIdx, orbit);
|
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 invadingFleets = state.fleets.filter((f) => f.starIdx === starIdx && f.empireIdx === e);
|
||||||
.filter((f) => f.starIdx === starIdx && f.empireIdx === e)
|
const troops = invadingFleets
|
||||||
.reduce((t, f) => t + f.ships
|
.reduce((t, f) => t + f.ships
|
||||||
.filter((s) => s.hullId === 'transport')
|
.filter((s) => s.hullId === 'transport')
|
||||||
.reduce((n, s) => n + s.count, 0), 0) * (rules.hulls.transport.troops ?? 4);
|
.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 spec = rules.species[state.empires[e].speciesId];
|
||||||
const comps = empireComponents(rules, state, e);
|
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 defenceBonus = colonyGroundDefense(rules, state, colony) + colony.defenseHp / 45;
|
||||||
const defenders = Math.max(1, Math.round(colony.pop / 8)) + Math.round(defenceBonus / 10);
|
const defenders = Math.max(1, Math.round(colony.pop / 8)) + Math.round(defenceBonus / 10);
|
||||||
const odds = Math.max(0.1, Math.min(0.9,
|
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 spec = rules.species[emp.speciesId];
|
||||||
const comps = empireComponents(rules, state, e);
|
const comps = empireComponents(rules, state, e);
|
||||||
const troops = stack.count * (rules.hulls.transport.troops ?? 4);
|
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 —
|
// 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
|
// 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.
|
// 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.
|
// gift cooldown tracking, and foreign-fleet dwell tracking.
|
||||||
emp.tradeAgreements ??= {};
|
emp.tradeAgreements ??= {};
|
||||||
emp.lastGiftTurn ??= {};
|
emp.lastGiftTurn ??= {};
|
||||||
|
emp.lastBombardTurn ??= {};
|
||||||
emp.fleetIntrusions ??= {};
|
emp.fleetIntrusions ??= {};
|
||||||
emp.espionageMission ??= {};
|
emp.espionageMission ??= {};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }. */
|
/** A labelled horizontal slider. Returns { container, setValue }. */
|
||||||
export function slider(scene, x, y, w, label, value, onChange, colour = ACCENT) {
|
export function slider(scene, x, y, w, label, value, onChange, colour = ACCENT) {
|
||||||
const c = scene.add.container(x, y);
|
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 cost = leaderHireCost(rules, state, e, offer.id);
|
||||||
const affordable = emp.bc >= cost;
|
const affordable = emp.bc >= cost;
|
||||||
shell.add(scene.add.image(shell.body.x + 32, y + 30, art.leaders, offer.portraitFrame)
|
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}`, {
|
shell.add(scene.add.text(shell.body.x + 76, y + 4, `${offer.name} — ${offer.kind}`, {
|
||||||
fontFamily: FONT, fontSize: '19px', color: '#e8f4ff',
|
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';
|
where = f ? fleetLabel(rules, state, f) : 'unassigned';
|
||||||
}
|
}
|
||||||
shell.add(scene.add.image(shell.body.x + 24, y + 18, art.leaders, def.portraitFrame)
|
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,
|
shell.add(scene.add.text(shell.body.x + 56, y + 6,
|
||||||
`${def.name} — ${def.kind} — ${where} — ${def.upkeep} BC/turn`, {
|
`${def.name} — ${def.kind} — ${where} — ${def.upkeep} BC/turn`, {
|
||||||
fontFamily: FONT, fontSize: '16px', color: '#c8dcf0',
|
fontFamily: FONT, fontSize: '16px', color: '#c8dcf0',
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ import { playSound, SFX } from '../../ui/Sounds.js';
|
||||||
import { etaText } from './VegaColonyView.js';
|
import { etaText } from './VegaColonyView.js';
|
||||||
import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js';
|
import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js';
|
||||||
import { openShipDetail } from './VegaShipDetail.js';
|
import { openShipDetail } from './VegaShipDetail.js';
|
||||||
|
import { openLeaderDetail } from './VegaLeaderDetail.js';
|
||||||
|
import { leaderOf, leaderSkillSummary } from './VegaLeaders.js';
|
||||||
|
|
||||||
const W = 400;
|
const W = 400;
|
||||||
const PAD = 18;
|
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
|
* 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
|
* garrison and the order screen's task force all take this shape. The
|
||||||
|
|
@ -483,7 +499,7 @@ export default class VegaSidePanel {
|
||||||
if (!mine) {
|
if (!mine) {
|
||||||
// What a rival tells you about their colony is what you can see from
|
// 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.
|
// 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)) {
|
if (this.viewerIdx >= 0 && atWar(state, this.viewerIdx, colony.empireIdx)) {
|
||||||
this.line('At war — bombard and invade from the system view.',
|
this.line('At war — bombard and invade from the system view.',
|
||||||
{ size: 13, color: '#e08a8a' });
|
{ size: 13, color: '#e08a8a' });
|
||||||
|
|
@ -495,7 +511,7 @@ export default class VegaSidePanel {
|
||||||
|
|
||||||
const prod = colonyProduction(rules, state, colony);
|
const prod = colonyProduction(rules, state, colony);
|
||||||
this.line(
|
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`
|
+ `Factories ${Math.floor(effectiveFactories(rules, state, colony))} / ${colonyFactoryCap(rules, state, colony)}\n`
|
||||||
+ `Output ${prod.toFixed(1)} BC per turn\n`
|
+ `Output ${prod.toFixed(1)} BC per turn\n`
|
||||||
+ `Defences ${Math.round(colony.defenseHp)} / ${colonyDefenseCap(rules, state, colony)}`
|
+ `Defences ${Math.round(colony.defenseHp)} / ${colonyDefenseCap(rules, state, colony)}`
|
||||||
|
|
@ -579,6 +595,7 @@ export default class VegaSidePanel {
|
||||||
this.setHead('Fleet', 'Under way');
|
this.setHead('Fleet', 'Under way');
|
||||||
this.line(`${from?.name ?? '?'} → ${to?.name ?? '?'}`, { size: 17, color: '#cfe8ff' });
|
this.line(`${from?.name ?? '?'} → ${to?.name ?? '?'}`, { size: 17, color: '#cfe8ff' });
|
||||||
this.etaLine(fleetEta(rules, state, fleet));
|
this.etaLine(fleetEta(rules, state, fleet));
|
||||||
|
this.leaderRow();
|
||||||
this.shipList();
|
this.shipList();
|
||||||
this.y += 8;
|
this.y += 8;
|
||||||
this.callout('A fleet in transit cannot be redirected until it arrives.', '#e0b08a');
|
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.setHead('Fleet', `In orbit at ${star.name}`);
|
||||||
this.line(`${total} ship${total === 1 ? '' : 's'} · power ${fleetPower(rules, state, fleet)}`,
|
this.line(`${total} ship${total === 1 ? '' : 's'} · power ${fleetPower(rules, state, fleet)}`,
|
||||||
{ size: 15, color: '#9fb6cc' });
|
{ size: 15, color: '#9fb6cc' });
|
||||||
|
this.leaderRow();
|
||||||
|
|
||||||
this.heading('Task force');
|
this.heading('Task force');
|
||||||
if (!this.sel.length) {
|
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() {
|
shipList() {
|
||||||
this.heading('Ships');
|
this.heading('Ships');
|
||||||
for (const s of this.fleet.ships) {
|
for (const s of this.fleet.ships) {
|
||||||
|
|
@ -753,7 +799,7 @@ export default class VegaSidePanel {
|
||||||
for (const c of hostiles) {
|
for (const c of hostiles) {
|
||||||
const owner = state.empires[c.empireIdx];
|
const owner = state.empires[c.empireIdx];
|
||||||
const war = this.viewerIdx >= 0 && atWar(state, this.viewerIdx, 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 });
|
{ size: 14, color: war ? '#e08a8a' : owner.color, gap: 2 });
|
||||||
}
|
}
|
||||||
for (const f of enemyFleets) {
|
for (const f of enemyFleets) {
|
||||||
|
|
|
||||||
|
|
@ -449,7 +449,7 @@ export default class VegaStarMap {
|
||||||
const own = cols.filter((c) => c.empireIdx === emp.idx).sort((a, b) => a.orbit - b.orbit);
|
const own = cols.filter((c) => c.empireIdx === emp.idx).sort((a, b) => a.orbit - b.orbit);
|
||||||
if (!own.length) continue;
|
if (!own.length) continue;
|
||||||
const text = showText ? this.scene.add.text(0, 0, own.map((c) => (
|
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'), {
|
)).join('\n'), {
|
||||||
fontFamily: FONT, fontSize: `${textSize}px`, color: '#f0f4fa', lineSpacing: 2,
|
fontFamily: FONT, fontSize: `${textSize}px`, color: '#f0f4fa', lineSpacing: 2,
|
||||||
}).setOrigin(1, 0) : null;
|
}).setOrigin(1, 0) : null;
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import {
|
||||||
CHANNELS, coloniesAt, canColonize, colonize, colonyMaxPop, colonyProduction,
|
CHANNELS, coloniesAt, canColonize, colonize, colonyMaxPop, colonyProduction,
|
||||||
colonyFactoryCap, effectiveFactories, colonyDefenseCap, colonyBuildRate,
|
colonyFactoryCap, effectiveFactories, colonyDefenseCap, colonyBuildRate,
|
||||||
collapseQueue, queueItemCost, queueEtas, invasionForecast, invade, bombard, atWar,
|
collapseQueue, queueItemCost, queueEtas, invasionForecast, invade, bombard, atWar,
|
||||||
peekColonyName,
|
peekColonyName, bombardedThisTurn,
|
||||||
} from './VegaLogic.js';
|
} from './VegaLogic.js';
|
||||||
|
|
||||||
export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
|
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 maxPop = colonyMaxPop(rules, state, colony);
|
||||||
const prod = colonyProduction(rules, state, colony);
|
const prod = colonyProduction(rules, state, colony);
|
||||||
console_.add(scene.add.text(panelX, y,
|
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`
|
+ `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)}`
|
+ `Output ${prod.toFixed(1)} BC Defences ${Math.round(colony.defenseHp)} / ${colonyDefenseCap(rules, state, colony)}`
|
||||||
+ (colony.waste > 0.5 ? `\nUncleaned waste ${colony.waste.toFixed(1)}` : ''), {
|
+ (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.
|
// 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, colony.orbit);
|
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
|
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,6 +225,12 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
|
||||||
wordWrap: { width: panelW },
|
wordWrap: { width: panelW },
|
||||||
}));
|
}));
|
||||||
y += 44;
|
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
|
// Both actions used to fail completely silently — refused by a
|
||||||
// precondition (no orbital superiority, no weapons) and an attempted-
|
// precondition (no orbital superiority, no weapons) and an attempted-
|
||||||
// but-lost invasion (every troop consumed either way — see invade()
|
// 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
|
// all, since the only record was a turn-report event the player
|
||||||
// wouldn't see until ending their turn. scene.log() surfaces the
|
// wouldn't see until ending their turn. scene.log() surfaces the
|
||||||
// outcome immediately in the same ticker the rest of the HUD uses.
|
// 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);
|
const result = bombard(rules, state, viewerIdx, starIdx, colony.orbit);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
scene.log?.('Bombard failed — you need orbital superiority and warships with a working weapon.');
|
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.`);
|
scene.log?.(`Bombarded the ${owner.name} colony — ${result.killed} killed.`);
|
||||||
}
|
}
|
||||||
onChanged?.(); rebuild();
|
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) {
|
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, () => {
|
||||||
const result = invade(rules, state, viewerIdx, starIdx, colony.orbit);
|
const result = invade(rules, state, viewerIdx, starIdx, colony.orbit);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import { COLORS } from '../../config.js';
|
import { COLORS } from '../../config.js';
|
||||||
import { coloniesAt, habitableForEmpire } from './VegaLogic.js';
|
import { coloniesAt, habitableForEmpire } from './VegaLogic.js';
|
||||||
import { techCost, techCostFactor } from './VegaRules.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
|
// 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
|
// 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) {
|
for (const c of cols) {
|
||||||
const owner = state.empires[c.empireIdx];
|
const owner = state.empires[c.empireIdx];
|
||||||
lines.push({
|
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,
|
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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1423,6 +1423,55 @@ section('4c. Population transport');
|
||||||
!!bombardResult, JSON.stringify(bombardResult));
|
!!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 evResearchHeadline = { type: 'techDone', empire: 0, techId: headlineTech, source: 'research' };
|
||||||
const evEspionageHeadline = { type: 'techDone', empire: 0, techId: headlineTech, source: 'espionage' };
|
const evEspionageHeadline = { type: 'techDone', empire: 0, techId: headlineTech, source: 'espionage' };
|
||||||
const evResearchPlain = { type: 'techDone', empire: 0, techId: plainTech, source: 'research' };
|
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 evSabotageMine = { type: 'sabotage', empire: 0, target: 1, starIdx: 0, factoriesLost: 2, defenseLost: 3 };
|
||||||
const evTechStolenMine = { type: 'techStolen', empire: 0, target: 1, techId: headlineTech };
|
const evTechStolenMine = { type: 'techStolen', empire: 0, target: 1, techId: headlineTech };
|
||||||
const evTechStolenVictimIsMe = { type: 'techStolen', empire: 1, target: 0, 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)',
|
check('espionage-sourced headline tech is NOT a GNN story (nobody can publicly name the thief\'s target)',
|
||||||
!Gnn.isGnnStory(RULES, fakeState, evEspionageHeadline));
|
!Gnn.isGnnStory(RULES, fakeState, evEspionageHeadline));
|
||||||
check('research-sourced non-headline tech is NOT a GNN story', !Gnn.isGnnStory(RULES, fakeState, evResearchPlain));
|
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',
|
check('the player\'s own successful sabotage IS a GNN story, attributed to them',
|
||||||
Gnn.isGnnStory(RULES, fakeState, evSabotageMine));
|
Gnn.isGnnStory(RULES, fakeState, evSabotageMine));
|
||||||
check('the player\'s own successful tech theft IS a GNN story, attributed to them',
|
check('the player\'s own successful tech theft IS a GNN story, attributed to them',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue