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,
|
||||
"name": "Thunder Steps",
|
||||
"name": "Toga",
|
||||
"frog": [
|
||||
1620,
|
||||
550
|
||||
267,
|
||||
825
|
||||
],
|
||||
"colors": 5,
|
||||
"background": "zuma-background-2",
|
||||
"seed": 72271,
|
||||
"colors": 4,
|
||||
"seed": 12345,
|
||||
"starScores": [
|
||||
1210,
|
||||
1820,
|
||||
2420
|
||||
640,
|
||||
960,
|
||||
1280
|
||||
],
|
||||
"paths": [
|
||||
{
|
||||
"shape": "zigzag",
|
||||
"shape": "custom",
|
||||
"points": [
|
||||
[
|
||||
-80,
|
||||
130
|
||||
-51,
|
||||
233
|
||||
],
|
||||
[
|
||||
-11,
|
||||
130
|
||||
1610,
|
||||
243
|
||||
],
|
||||
[
|
||||
58,
|
||||
130
|
||||
1706,
|
||||
965
|
||||
],
|
||||
[
|
||||
126,
|
||||
130
|
||||
671,
|
||||
819
|
||||
],
|
||||
[
|
||||
195,
|
||||
130
|
||||
583,
|
||||
378
|
||||
],
|
||||
[
|
||||
264,
|
||||
130
|
||||
1195,
|
||||
409
|
||||
],
|
||||
[
|
||||
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
|
||||
1425,
|
||||
755
|
||||
]
|
||||
],
|
||||
"tunnels": [],
|
||||
"quota": 36,
|
||||
"introBalls": 11,
|
||||
"pushSpeed": 96,
|
||||
"powerUpRate": 0.055
|
||||
"quota": 40,
|
||||
"introBalls": 12,
|
||||
"pushSpeed": 45,
|
||||
"powerUpRate": 0.06
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 778 KiB |
Binary file not shown.
|
|
@ -280,7 +280,7 @@
|
|||
},
|
||||
"leaders": {
|
||||
"key": "vega-leaders",
|
||||
"path": null,
|
||||
"path": "assets/images/vega/leaders.png",
|
||||
"kind": "portrait",
|
||||
"frameWidth": 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.",
|
||||
"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." },
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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`],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ??= {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue