From cba84dc0b81e978c72ea86431141c3c085b11237 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 14 Aug 2026 11:19:30 -0600 Subject: [PATCH] Updated Battle Scanner --- src/games/mastervega/MasterOfVegaGame.js | 19 +++++- src/games/mastervega/VegaLogic.js | 64 +++++++++++++++++++- src/games/mastervega/VegaSidePanel.js | 77 ++++++++++++++++++++++-- src/games/mastervega/VegaTechEffects.js | 11 ++-- tools/verifyMasterOfVega.js | 62 +++++++++++++++++++ 5 files changed, 222 insertions(+), 11 deletions(-) diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index faed684..89caa3a 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -1074,8 +1074,25 @@ export default class MasterOfVegaGame extends Phaser.Scene { if (this.modalOpen || this.busy) return; // Someone else's fleet is not something we can give orders to, but the // system it is sitting in is — clicking it reads as clicking that system, - // which with a fleet in hand is how an attack gets ordered. + // which with a fleet in hand is how an attack gets ordered. That takes + // priority over sensor detail below: a fleet already selected for a move + // order must still route through onStarClick's "quote a route" flow + // (same condition onStarClick itself uses), not get swallowed by the + // read-only scan panel. if (fleet.empireIdx !== this.state.humanIndex) { + const ordering = this.selectedFleet && this.state.fleets.includes(this.selectedFleet) + && this.selectedFleet.starIdx >= 0 && this.selectedFleet.starIdx !== fleet.starIdx; + // Detected on sensors (Battle Scanner and friends' scanRange effect, + // Brian's ask, 2026-08-14): the same fleet panel our own fleets use, + // just read-only (VegaSidePanel.js's buildFleet branches on + // ownership). Outside scan range, or mid-order, a rival fleet marker + // still just reads as "click the system," same as before scanRange + // existed. + if (!ordering && Logic.fleetInScanRange(this.rules, this.state, this.state.humanIndex, fleet)) { + playSound(this, SFX.VEGA_UNIT); + this.panel.showFleet(fleet); + return; + } if (fleet.starIdx >= 0) this.onStarClick(fleet.starIdx); return; } diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index 6383c5b..3051165 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -11,7 +11,9 @@ // Ecology cleans industrial waste; Defence raises planetary defences; Research // splits into the six tech fields. -import { generateGalaxy, parsecs, mulberry32 } from './VegaGalaxyGen.js'; +import { + generateGalaxy, parsecs, mulberry32, PARSEC_PX, +} from './VegaGalaxyGen.js'; import { techCost, techCostFactor } from './VegaRules.js'; import { designFor, bestComponents, markFor, refitCost } from './VegaShips.js'; // Space combat is resolved by the V2 per-ship engine (formerly a ?movsim-only @@ -359,6 +361,66 @@ export function reachableStars(rules, state, e) { return out; } +// -------------------------------------------------------------------------- +// Sensors — battlescanner and friends' scanRange tech effect (Brian's ask, +// 2026-08-14). Unlike reachableStars/fuelRange, this isn't cached per turn: +// it is only ever asked for on a fleet click, not in a hot AI loop, so the +// extra cache bookkeeping (and the extra invalidation sites that would need +// it) buys nothing here. + +// A fleet's current position in star-map pixel coordinates: its own star's, +// if docked, or interpolated along its lane if in transit — the same lerp +// VegaStarMap.js's refreshFleets uses to DRAW an in-transit marker, just +// reused here (headless, no Phaser) to MEASURE distance to it instead. +function fleetPosition(state, fleet) { + if (fleet.starIdx >= 0) { + const star = state.galaxy.stars[fleet.starIdx]; + return star ? { x: star.x, y: star.y } : null; + } + const a = state.galaxy.stars[fleet.fromStar]; + const b = state.galaxy.stars[fleet.toStar]; + if (!a || !b) return null; + const t = fleet.total > 0 ? Math.min(1, Math.max(0, fleet.progress / fleet.total)) : 0; + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; +} + +// Every point this empire could currently be detecting FROM: its colonies +// and its own fleets (docked or in transit), unlike reachableStars' sources +// (colonies + starbases only) — a sensor reach is about where your hulls +// actually ARE right now, not where you could send something from. +function scanSources(state, e) { + const sources = []; + for (const c of empireColonies(state, e)) { + const star = state.galaxy.stars[c.starIdx]; + if (star) sources.push({ x: star.x, y: star.y }); + } + for (const f of empireFleets(state, e)) { + const p = fleetPosition(state, f); + if (p) sources.push(p); + } + return sources; +} + +/** + * Whether `viewerE` can currently detect `fleet` (any empire's, including + * its own — always true then via a zero-distance source) on sensors: within + * scanRange parsecs of one of the viewer's own colonies or fleets. Zero + * scanRange (no battlescanner-line tech yet) means nothing is ever + * detected — this is what turns "reveals enemy fleet composition" from + * flavor text into a real mechanic (see VegaTechEffects.js's scanRange + * formatter, and MasterOfVegaGame.js's onFleetClick / VegaSidePanel.js's + * buildFleet for what unlocks behind it). + */ +export function fleetInScanRange(rules, state, viewerE, fleet) { + const range = empireComponents(rules, state, viewerE).scanRange; + if (range <= 0) return false; + const pos = fleetPosition(state, fleet); + if (!pos) return false; + return scanSources(state, viewerE).some( + (s) => Math.hypot(s.x - pos.x, s.y - pos.y) / PARSEC_PX <= range + 1e-9, + ); +} + export function fleetSpeed(rules, state, fleet) { const emp = state.empires[fleet.empireIdx]; const spec = rules.species[emp.speciesId]; diff --git a/src/games/mastervega/VegaSidePanel.js b/src/games/mastervega/VegaSidePanel.js index 2c84daa..d29beb7 100644 --- a/src/games/mastervega/VegaSidePanel.js +++ b/src/games/mastervega/VegaSidePanel.js @@ -29,6 +29,7 @@ import { coloniesAt, fleetsAt, empireDesign, fleetPower, fleetSpeed, fleetEta, etaTo, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories, colonyDefenseCap, habitableForEmpire, reachableStars, atWar, queueItemEta, + fleetInScanRange, } from './VegaLogic.js'; import { FONT, D, ORBIT, uiClick } from './VegaScreens.js'; import { playSound, SFX } from '../../ui/Sounds.js'; @@ -143,7 +144,11 @@ export default class VegaSidePanel { // A fresh selection always starts at full strength: any dial-down from a // prior selection (this fleet or another) must not carry over here. this.sel = []; - this.syncSelection(); + // The dial-down selector only exists for a fleet we can actually give + // orders to — a rival's fleet reaches here read-only, detected on + // sensors (MasterOfVegaGame.js's onFleetClick), and buildFleet's + // read-only branch never looks at this.sel at all. + if (fleet.empireIdx === this.viewerIdx) this.syncSelection(); this.rebuild(); this.open(); } @@ -536,14 +541,20 @@ export default class VegaSidePanel { const { rules, state } = this; const emp = state.empires[fleet.empireIdx]; const mine = fleet.empireIdx === this.viewerIdx; + // A rival fleet only opens into the read-only detail view once it's + // within sensor range — same gate MasterOfVegaGame.js's onFleetClick + // applies to a click on the map marker, mirrored here so this row is + // just as clickable (Brian's ask, 2026-08-14). + const scanned = mine || (this.viewerIdx >= 0 && fleetInScanRange(rules, state, this.viewerIdx, fleet)); const ships = fleet.ships.reduce((t, s) => t + s.count, 0); const label = `${mine ? 'Your fleet' : emp.name} — ${ships} ship${ships === 1 ? '' : 's'}` + ` · power ${fleetPower(rules, state, fleet)}`; const t = this.line(label, { size: 14, color: mine ? '#9fd8ff' : emp.color, gap: 3 }); - if (!mine) return; + if (!scanned) return; t.setInteractive({ useHandCursor: true }); + const baseColor = mine ? '#9fd8ff' : emp.color; t.on('pointerover', () => t.setColor('#ffffff')); - t.on('pointerout', () => t.setColor('#9fd8ff')); + t.on('pointerout', () => t.setColor(baseColor)); t.on('pointerup', () => this.cb.onSelectFleet?.(fleet)); } @@ -589,6 +600,8 @@ export default class VegaSidePanel { const fleet = this.fleet; const total = fleet.ships.reduce((t, s) => t + s.count, 0); + if (fleet.empireIdx !== this.viewerIdx) { this.buildEnemyFleet(total); return; } + if (fleet.starIdx < 0) { const to = state.galaxy.stars[fleet.toStar]; const from = state.galaxy.stars[fleet.fromStar]; @@ -641,6 +654,58 @@ export default class VegaSidePanel { this.action('Done', () => this.cb.onClose?.(), { variant: 'ghost' }); } + /** + * Read-only view of a rival's fleet — reachable only once it is within + * sensor range (VegaLogic.js's fleetInScanRange, gated further up the call + * chain in MasterOfVegaGame.js's onFleetClick; Brian's ask, 2026-08-14). + * Reuses the exact same building blocks the player's own fleet view does — + * shipLine()/leaderRow()/detailHit() — for ship thumbnails, full-stat + * popovers and leader detail, so a rival's ships and captain inspect + * exactly like your own. What's missing is only the −/+/✕ dial-down + * selector and the move-order flow: there is nothing here to command. + */ + buildEnemyFleet(total) { + const { rules, state } = this; + const fleet = this.fleet; + const emp = state.empires[fleet.empireIdx]; + + if (fleet.starIdx < 0) { + const to = state.galaxy.stars[fleet.toStar]; + const from = state.galaxy.stars[fleet.fromStar]; + this.setHead(`${emp.name} fleet`, 'Under way — detected'); + this.line(`${from?.name ?? '?'} → ${to?.name ?? '?'}`, { size: 17, color: emp.color }); + } else { + const star = state.galaxy.stars[fleet.starIdx]; + this.setHead(`${emp.name} fleet`, `In orbit at ${star.name} — detected`); + } + this.line(`${total} ship${total === 1 ? '' : 's'} · power ${fleetPower(rules, state, fleet)}`, + { size: 15, color: '#9fb6cc' }); + this.leaderRow(); + + const mobile = fleet.ships.filter((s) => s.count > 0 + && !empireDesign(rules, state, fleet.empireIdx, s.hullId).immobile); + const garrison = fleet.ships.filter((s) => s.count > 0 + && empireDesign(rules, state, fleet.empireIdx, s.hullId).immobile); + if (mobile.length) { + this.heading('Ships'); + for (const s of mobile) { + this.shipLine(fleet.empireIdx, s.hullId, s.mark, `${s.count} × ${this.designName(s.hullId, s.mark)}`); + } + } + if (garrison.length) { + this.heading('Garrison'); + for (const s of garrison) { + this.shipLine(fleet.empireIdx, s.hullId, s.mark, + `${s.count} × ${this.designName(s.hullId, s.mark)}`, + { color: '#8fa8c0', sub: 'Holds this system' }); + } + } + + this.y = Math.max(this.y + 12, this.h - 138); + this.callout('Detected on sensors — read-only. Click a ship or the captain to inspect it.', '#6fc4ff'); + this.action('Close', () => this.cb.onClose?.(), { variant: 'ghost' }); + } + /** * A ship stack with a −/+ count selector. The name sits on the first line and * the counter on the second, so the thumbnails can take the left gutter @@ -732,9 +797,11 @@ export default class VegaSidePanel { const rowH = Math.max(SIZE, this.y - rowY); this.y = Math.max(this.y, rowY + SIZE + 10); + const mine = fleet.empireIdx === this.viewerIdx; + const whose = mine ? 'Your fleet' : `${state.empires[fleet.empireIdx].name}'s fleet`; 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 ?? '?'}`; + ? `${whose} at ${state.galaxy.stars[fleet.starIdx]?.name ?? '?'}` + : `${whose}, 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)); diff --git a/src/games/mastervega/VegaTechEffects.js b/src/games/mastervega/VegaTechEffects.js index f51348c..1ff7ffe 100644 --- a/src/games/mastervega/VegaTechEffects.js +++ b/src/games/mastervega/VegaTechEffects.js @@ -5,9 +5,11 @@ // // Every formatter is hand-matched to how the effect is actually consumed // elsewhere in the engine (VegaCombat.js/VegaLogic.js/VegaShips.js) — not a -// generic key/value dump. `scanRange` is currently an inert stat with no -// downstream mechanic wired up yet; its wording stays modest/flavor rather -// than promising a numeric benefit that doesn't exist in the sim. +// generic key/value dump. `scanRange` (VegaLogic.js's fleetInScanRange) is +// what unlocks a rival fleet's read-only detail view — MasterOfVegaGame.js's +// onFleetClick and VegaSidePanel.js's buildFleet — once one of your own +// colonies or fleets sits within range of it; it does not reveal star +// systems themselves (that's explored[], unrelated). const pct = (mult) => `${Math.round(Math.abs(mult - 1) * 100)}%`; @@ -46,7 +48,8 @@ const FORMATTERS = { refitCostMult: (rules, v) => `Auto-refit costs reduced by ${pct(v)}.`, repairPerRound: (rules, v) => `Ships self-repair ${Math.round(v * 100)}% of max hull every combat round.`, researchMult: (rules, v) => `Research output increased by ${pct(v)} empire-wide.`, - scanRange: (rules, v) => `+${v} scanner range for detecting distant fleets and systems.`, + scanRange: (rules, v) => `+${v} parsec scanner range — enemy fleets within range of one of your ` + + 'colonies or fleets can be inspected in full, ship by ship.', shield: (rules, v) => `Deflector shields absorb ${v} point${v === 1 ? '' : 's'} of damage from every incoming hit, on ships and planetary defenses alike.`, singularity: (rules) => `A breakthrough in exotic physics — every weapon on this ship warps enemy deflectors, trimming ${rules.combat.singularityShieldPierce ?? 0} point of shielding off every hit it lands.`, targeting: (rules, v) => `Combat accuracy increased by ${Math.round(v * 6)}% (better targeting computers).`, diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index 0aa47cf..fdd24db 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -1474,6 +1474,68 @@ section('4c. Population transport'); } } +// --------------------------------------------------------------------------- +section('4d. Sensor scan range'); +// --------------------------------------------------------------------------- +// fleetInScanRange: Battle Scanner and friends' scanRange tech effect, +// wired from flavor text into an actual mechanic (Brian's ask, 2026-08-14). +{ + const st = Logic.createGame(RULES, { + sizeId: 'medium', shapeId: 'spiral', seed: 44, difficultyId: 'normal', + speciesIds: ['human', 'kkrix'], humanIndex: 0, + }); + st.rules = RULES; + const home = st.colonies.find((c) => c.empireIdx === 0); + + Logic.addFleet(RULES, st, 1, home.starIdx, [{ hullId: 'frigate', count: 2, mark: 1 }]); + const rival = st.fleets.find((f) => f.empireIdx === 1 && f.starIdx === home.starIdx); + check('a rival fleet exists to test against', !!rival); + + check('with no scanner tech, even a same-system rival fleet is undetected', + !Logic.fleetInScanRange(RULES, st, 0, rival)); + + Logic.grantTech(RULES, st, 0, 'battlescanner'); + check('battlescanner grants a positive empire scanRange', + Logic.empireComponents(RULES, st, 0).scanRange > 0); + check('with battlescanner known, a same-system rival fleet is detected', + Logic.fleetInScanRange(RULES, st, 0, rival)); + + // Far enough that even battlescanner's scan bonus can't reach it. + let farIdx = -1; + let farDist = 0; + for (let i = 0; i < st.galaxy.stars.length; i += 1) { + const d = parsecs(st.galaxy, home.starIdx, i); + if (d > farDist) { farDist = d; farIdx = i; } + } + const scanRange = Logic.empireComponents(RULES, st, 0).scanRange; + check('a star far enough away to test the range limit exists', farIdx >= 0 && farDist > scanRange); + if (farIdx >= 0 && farDist > scanRange) { + rival.starIdx = farIdx; + check('a rival fleet beyond scan range is not detected even with battlescanner', + !Logic.fleetInScanRange(RULES, st, 0, rival)); + + // Scan sources are the viewer's own fleets too, not just colonies — + // sending one of ours next to the rival should light it up. + Logic.addFleet(RULES, st, 0, farIdx, [{ hullId: 'scout', count: 1, mark: 1 }]); + check('a rival fleet becomes detected once one of our OWN fleets sits at its star', + Logic.fleetInScanRange(RULES, st, 0, rival)); + + // In-transit detection uses the same interpolated position VegaStarMap.js + // draws an en-route marker at, not just docked fleets. + const transit = { + id: st.nextFleetId += 1, empireIdx: 1, starIdx: -1, + fromStar: home.starIdx, toStar: farIdx, progress: 0, total: farDist, + ships: [{ hullId: 'frigate', mark: 1, count: 1 }], + }; + st.fleets.push(transit); + check('an in-transit rival fleet just leaving our home star is detected', + Logic.fleetInScanRange(RULES, st, 0, transit)); + transit.progress = farDist * 0.5; + check('the same in-transit rival fleet, well down its route, is no longer detected', + !Logic.fleetInScanRange(RULES, st, 0, transit)); + } +} + // --------------------------------------------------------------------------- section('5. Combat'); // ---------------------------------------------------------------------------