diff --git a/assets/gamedata/zuma/level-009.json b/assets/gamedata/zuma/level-009.json index d4c0504..794bd62 100644 --- a/assets/gamedata/zuma/level-009.json +++ b/assets/gamedata/zuma/level-009.json @@ -1,364 +1,55 @@ { - "level": 9, - "name": "Thunder Steps", - "frog": [ - 1620, - 550 - ], - "colors": 5, - "background": "zuma-background-2", - "seed": 72271, - "starScores": [ - 1210, - 1820, - 2420 - ], - "paths": [ - { - "shape": "zigzag", - "points": [ - [ - -80, - 130 - ], - [ - -11, - 130 - ], - [ - 58, - 130 - ], - [ - 126, - 130 - ], - [ - 195, - 130 - ], - [ - 264, - 130 - ], - [ - 333, - 130 - ], - [ - 401, - 130 - ], - [ - 470, - 130 - ], - [ - 539, - 130 - ], - [ - 608, - 130 - ], - [ - 676, - 130 - ], - [ - 745, - 130 - ], - [ - 814, - 130 - ], - [ - 882, - 130 - ], - [ - 951, - 130 - ], - [ - 1020, - 130 - ], - [ - 1089, - 130 - ], - [ - 1158, - 130 - ], - [ - 1226, - 130 - ], - [ - 1295, - 130 - ], - [ - 1365, - 149 - ], - [ - 1416, - 200 - ], - [ - 1435, - 270 - ], - [ - 1416, - 340 - ], - [ - 1365, - 391 - ], - [ - 1295, - 410 - ], - [ - 1225, - 410 - ], - [ - 1156, - 410 - ], - [ - 1086, - 410 - ], - [ - 1016, - 410 - ], - [ - 947, - 410 - ], - [ - 877, - 410 - ], - [ - 807, - 410 - ], - [ - 738, - 410 - ], - [ - 668, - 410 - ], - [ - 598, - 410 - ], - [ - 529, - 410 - ], - [ - 459, - 410 - ], - [ - 389, - 410 - ], - [ - 320, - 410 - ], - [ - 250, - 410 - ], - [ - 180, - 429 - ], - [ - 129, - 480 - ], - [ - 110, - 550 - ], - [ - 129, - 620 - ], - [ - 180, - 671 - ], - [ - 250, - 690 - ], - [ - 320, - 690 - ], - [ - 389, - 690 - ], - [ - 459, - 690 - ], - [ - 529, - 690 - ], - [ - 598, - 690 - ], - [ - 668, - 690 - ], - [ - 738, - 690 - ], - [ - 807, - 690 - ], - [ - 877, - 690 - ], - [ - 947, - 690 - ], - [ - 1016, - 690 - ], - [ - 1086, - 690 - ], - [ - 1156, - 690 - ], - [ - 1225, - 690 - ], - [ - 1295, - 690 - ], - [ - 1365, - 709 - ], - [ - 1416, - 760 - ], - [ - 1435, - 830 - ], - [ - 1416, - 900 - ], - [ - 1365, - 951 - ], - [ - 1295, - 970 - ], - [ - 1225, - 970 - ], - [ - 1156, - 970 - ], - [ - 1086, - 970 - ], - [ - 1016, - 970 - ], - [ - 947, - 970 - ], - [ - 877, - 970 - ], - [ - 807, - 970 - ], - [ - 738, - 970 - ], - [ - 668, - 970 - ], - [ - 598, - 970 - ], - [ - 529, - 970 - ], - [ - 459, - 970 - ], - [ - 389, - 970 - ], - [ - 320, - 970 - ], - [ - 250, - 970 - ] - ], - "tunnels": [], - "quota": 36, - "introBalls": 11, - "pushSpeed": 96, - "powerUpRate": 0.055 - } - ] + "level": 9, + "name": "Toga", + "frog": [ + 267, + 825 + ], + "colors": 4, + "seed": 12345, + "starScores": [ + 640, + 960, + 1280 + ], + "paths": [ + { + "shape": "custom", + "points": [ + [ + -51, + 233 + ], + [ + 1610, + 243 + ], + [ + 1706, + 965 + ], + [ + 671, + 819 + ], + [ + 583, + 378 + ], + [ + 1195, + 409 + ], + [ + 1425, + 755 + ] + ], + "tunnels": [], + "quota": 40, + "introBalls": 12, + "pushSpeed": 45, + "powerUpRate": 0.06 + } + ] } diff --git a/assets/images/vega/leaders.png b/assets/images/vega/leaders.png new file mode 100644 index 0000000..726a5e4 Binary files /dev/null and b/assets/images/vega/leaders.png differ diff --git a/assets/images/vega/leaders.psd b/assets/images/vega/leaders.psd new file mode 100644 index 0000000..488931c Binary files /dev/null and b/assets/images/vega/leaders.psd differ diff --git a/data/mastervega-artwork.json b/data/mastervega-artwork.json index 65ef87f..875e454 100644 --- a/data/mastervega-artwork.json +++ b/data/mastervega-artwork.json @@ -280,7 +280,7 @@ }, "leaders": { "key": "vega-leaders", - "path": null, + "path": "assets/images/vega/leaders.png", "kind": "portrait", "frameWidth": 160, "frameHeight": 160, diff --git a/data/mastervega-rules.json b/data/mastervega-rules.json index 9a1c44a..ee979d2 100644 --- a/data/mastervega-rules.json +++ b/data/mastervega-rules.json @@ -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." }, diff --git a/src/games/mastervega/VegaColoniesScreen.js b/src/games/mastervega/VegaColoniesScreen.js index fff2204..6c3b5ed 100644 --- a/src/games/mastervega/VegaColoniesScreen.js +++ b/src/games/mastervega/VegaColoniesScreen.js @@ -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'), })); diff --git a/src/games/mastervega/VegaColonyIntro.js b/src/games/mastervega/VegaColonyIntro.js index 3c189e8..de5c05d 100644 --- a/src/games/mastervega/VegaColonyIntro.js +++ b/src/games/mastervega/VegaColonyIntro.js @@ -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`], diff --git a/src/games/mastervega/VegaColonyView.js b/src/games/mastervega/VegaColonyView.js index 2b07658..4f3ea75 100644 --- a/src/games/mastervega/VegaColonyView.js +++ b/src/games/mastervega/VegaColonyView.js @@ -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; diff --git a/src/games/mastervega/VegaGnn.js b/src/games/mastervega/VegaGnn.js index 8222ba3..ffef1c5 100644 --- a/src/games/mastervega/VegaGnn.js +++ b/src/games/mastervega/VegaGnn.js @@ -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; diff --git a/src/games/mastervega/VegaGnnScreen.js b/src/games/mastervega/VegaGnnScreen.js index 347a6f6..fe13f5f 100644 --- a/src/games/mastervega/VegaGnnScreen.js +++ b/src/games/mastervega/VegaGnnScreen.js @@ -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' }; diff --git a/src/games/mastervega/VegaLeaderDetail.js b/src/games/mastervega/VegaLeaderDetail.js new file mode 100644 index 0000000..ac31438 --- /dev/null +++ b/src/games/mastervega/VegaLeaderDetail.js @@ -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 }; +} diff --git a/src/games/mastervega/VegaLeaders.js b/src/games/mastervega/VegaLeaders.js index 73feb38..2d928b6 100644 --- a/src/games/mastervega/VegaLeaders.js +++ b/src/games/mastervega/VegaLeaders.js @@ -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); } diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index 2fcc45f..95fa613 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -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 ??= {}; } diff --git a/src/games/mastervega/VegaScreens.js b/src/games/mastervega/VegaScreens.js index 8544b29..ae5fe48 100644 --- a/src/games/mastervega/VegaScreens.js +++ b/src/games/mastervega/VegaScreens.js @@ -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', diff --git a/src/games/mastervega/VegaSidePanel.js b/src/games/mastervega/VegaSidePanel.js index dd16a18..2c84daa 100644 --- a/src/games/mastervega/VegaSidePanel.js +++ b/src/games/mastervega/VegaSidePanel.js @@ -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) { diff --git a/src/games/mastervega/VegaStarMap.js b/src/games/mastervega/VegaStarMap.js index 5f06112..f3203bf 100644 --- a/src/games/mastervega/VegaStarMap.js +++ b/src/games/mastervega/VegaStarMap.js @@ -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; diff --git a/src/games/mastervega/VegaSystemView.js b/src/games/mastervega/VegaSystemView.js index fd0435e..ff9d66d 100644 --- a/src/games/mastervega/VegaSystemView.js +++ b/src/games/mastervega/VegaSystemView.js @@ -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); diff --git a/src/games/mastervega/VegaTooltips.js b/src/games/mastervega/VegaTooltips.js index 4996c5d..1d2f6a7 100644 --- a/src/games/mastervega/VegaTooltips.js +++ b/src/games/mastervega/VegaTooltips.js @@ -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 }, + ], + }; +} diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index 11c3661..8bcc160 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -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',