/** * SystemChart — the pure geometry + stats behind the deck's MAP console * (js/ui/MapWindow.js draws on it; GameScene feeds it). * * chartBounds(objects, padding) the system's chart frame: the furthest * X/Y extents of EVERY object (planets, * stations, jump gates AND asteroid * clusters — discovered or not) plus * `padding` px (data/map.json → * bounds.padding, ~1024) on each edge. * fitToRect(bounds, w, h) the world → plate transform that fits * that frame into the map plate, centred. * navDiscoveryStats(d, sysId, c) the SYSTEM DISCOVERY share — the NAV * objects (planets + free-space stations * + jump gates; the central body is the * chart's anchor, not a count) vs. the * player's Discovery state. * resourceStats(d, sysId, c) the SYSTEM RESOURCES share — the * asteroid fields vs. Discovery (the only * resource kind for now). * systemChartSnapshot(id, c, o) the SYSTEM tab's snapshot (the * 'system' socket of js/ui/MapWindow.js) * — the SAME shape GameScene. * mapChartSnapshot() returns for the * CURRENT system, built purely from a * REMOTE system's generated content * (no ship, no tethers — they live in * the current system only). * * Pure (no Phaser) — Node-testable (dev/system-chart.test.mjs). The NAV * point set is SystemCategory.navPoints (the same discoverable set the * jumpgate chart gate uses) minus the central body, per the readout's * rule: planets, stations, gates. */ import { navPoints } from '../research/SystemCategory.js'; import { config } from '../config/Config.js'; /** * The chart's bounding frame from a list of objects. * * @param {Array<{x:number, y:number, radius:number}>} objects — the system's * objects (any discoverable set; `radius` = the object's extent from its * center — planet disc radius, station/gate keepout, cluster bound) * @param {number} [padding=1024] — px of margin added on every edge * @returns {{minX:number, minY:number, maxX:number, maxY:number, * w:number, h:number, cx:number, cy:number}} * (a degenerate empty list yields a `2×padding` box about the origin) */ export function chartBounds(objects, padding = 1024) { let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const o of objects ?? []) { if (!o || typeof o.x !== 'number' || typeof o.y !== 'number') continue; const r = Math.max(0, Number(o.radius) || 0); if (o.x - r < minX) minX = o.x - r; if (o.y - r < minY) minY = o.y - r; if (o.x + r > maxX) maxX = o.x + r; if (o.y + r > maxY) maxY = o.y + r; } if (!Number.isFinite(minX) || !Number.isFinite(maxX) || !Number.isFinite(minY) || !Number.isFinite(maxY)) { // No objects: a zero-size box about the origin (the padding then makes // the chart a 2×padding box about it). minX = 0; minY = 0; maxX = 0; maxY = 0; } minX -= padding; minY -= padding; maxX += padding; maxY += padding; return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY, cx: (minX + maxX) / 2, cy: (minY + maxY) / 2 }; } /** * Fit a world-space rect into a plate of `w × h`, centred, uniform scale. * * @param {ReturnType} bounds * @param {number} w — plate width (px) * @param {number} h — plate height (px) * @returns {{scale:number, ox:number, oy:number, toX:(x:number)=>number, * toY:(y:number)=>number}} * `toX`/`toY` map world → plate; `scale` = plate px per world px. */ export function fitToRect(bounds, w = 100, h = 100) { const scale = Math.min(w, h) / Math.max(1, Math.max(bounds.w, bounds.h)); const ox = w / 2 - bounds.cx * scale; const oy = h / 2 - bounds.cy * scale; return { scale, ox, oy, toX: (x) => x * scale + ox, toY: (y) => y * scale + oy, }; } /** * The SYSTEM DISCOVERY share (data/map.json → stats): the player's found / * total over the system's NAV objects — planets, free-space stations and * jump gates (SystemCategory.navPoints minus the central body 'home', * which is the chart's anchor rather than a discoverable count — and * minus the asteroid clusters, which are the RESOURCE side of the readout). * * @param {object} discovery — the Discovery state (isDiscovered) * @param {string} systemId * @param {object} content — the generated system content * @returns {{total:number, found:number, pct:number}} — `pct` is 0..1 * (0 when the system has no NAV objects, which the readout renders as —) */ export function navDiscoveryStats(discovery, systemId, content) { const points = navPoints(content).filter((p) => p.kind !== 'home'); const canQuery = typeof discovery?.isDiscovered === 'function'; let found = 0; for (const p of points) if (canQuery && discovery.isDiscovered(systemId, p.id)) found++; const total = points.length; return { total, found, pct: total > 0 ? found / total : 0 }; } /** * The SYSTEM RESOURCES share (data/map.json → stats): the player's found / * total over the system's asteroid fields (content.asteroids — the only * resource kind for now). * * @param {object} discovery — the Discovery state (isDiscovered) * @param {string} systemId * @param {object} content — the generated system content * @returns {{total:number, found:number, pct:number}} */ export function resourceStats(discovery, systemId, content) { const clusters = (content?.asteroids ?? []).filter((c) => c && typeof c.id === 'string'); const canQuery = typeof discovery?.isDiscovered === 'function'; let found = 0; for (const c of clusters) if (canQuery && discovery.isDiscovered(systemId, c.id)) found++; const total = clusters.length; return { total, found, pct: total > 0 ? found / total : 0 }; } /** * The SYSTEM tab's chart snapshot — a CHARTED star picked on the GALAXY * tab, drawn by the same painter as the CURRENT SYSTEM tab (the plate * covers EVERY object of that system; the chart shows the discovered * ones). Same shape as GameScene.mapChartSnapshot(), minus what cannot * exist in a remote system: the ship (it is in the CURRENT system) and * the tether union (the player's built-in tethers are current-system * state). * * Object ids are the DISCOVERY identities (navPoints + asteroid cluster * ids), so a later jump to the system repaints an identical chart from * the same Discovery footprint. * * @param {string} systemId * @param {object} content — the system's generated content (ensureContent) * @param {object} [o={}] — hooks * @param {(id:string)=>boolean} [o.isDiscovered] — discovery check for * `systemId` (pass `(id) => discovery.isDiscovered(systemId, id)`); * absent → nothing discovered (the safe default for a REMOTE system — * the stats share the same footing) * @param {object} [o.discovery] — the Discovery state (for the stats) * @param {string|null} [o.destinationId] — the DESTINATION object's id, if * this system is the destination and the destination names an object * (GameScene.systemChartSnapshotFor passes it). The chart circles it in * the route's orange (data/gates.json → route.compassColor) so the final * stop is unmistakable. Null/absent → no destination ring. * @returns {?{systemId:string, systemName:string, isHome:false, * central:object, objects:object[], tethers:number[], * ship:null, destinationId:string|null, stats:{nav:object, res:object}}} * (null when the inputs are missing) */ export function systemChartSnapshot(systemId, content, o = {}) { if (!systemId || !content) return null; const isDisc = (id) => (typeof o.isDiscovered === 'function' ? !!o.isDiscovered(id) : false); const frameWidth = config.get('planets.frameWidth', 1024); const planetScale = config.get('planets.scale', 1); const objects = []; // planets — the discovery identity is the planet's NAME (navPoints) for (const rec of content.planets ?? []) { if (!rec || typeof rec.x !== 'number' || typeof rec.y !== 'number') continue; objects.push({ id: rec.name, kind: 'planet', x: rec.x, y: rec.y, radius: (frameWidth * planetScale * (rec.scale ?? 1)) / 2, name: rec.name, typeLabel: config.get(`planets.typeLabels.${rec.class}`, rec.class), discovered: isDisc(rec.name), tint: config.get(`planets.classTint.${rec.class}`, '#9fb6d8'), }); } // free-space stations (a settlement's id — navPoints) for (const s of content.settlements ?? []) { if (!s || s.anchor?.type !== 'space' || typeof s.x !== 'number' || typeof s.y !== 'number') continue; const kind = s.kind ?? 'deepSpaceStation'; objects.push({ id: s.id, kind: 'station', x: s.x, y: s.y, radius: config.get(`stations.kinds.${kind}.size`, kind === 'waypoint' ? 46 : 108), name: s.name, typeLabel: kind === 'waypoint' ? 'Waypoint' : 'Station', discovered: isDisc(s.id), }); } // jump gates (their id — navPoints) for (const j of content.jumps ?? []) { if (!j || typeof j.x !== 'number' || typeof j.y !== 'number') continue; objects.push({ id: j.id, kind: 'gate', x: j.x, y: j.y, radius: j.size ?? config.get('gates.size', 96), name: j.name, typeLabel: j.toName ? `Gate → ${j.toName}` : 'Jump Gate', discovered: isDisc(j.id), rotation: j.rotation, active: j.active === true, }); } // asteroid clusters (their id — resourceStats) for (const c of content.asteroids ?? []) { if (!c || typeof c.x !== 'number' || typeof c.y !== 'number') continue; objects.push({ id: c.id, kind: 'cluster', x: c.x, y: c.y, radius: c.bound, name: c.name, typeLabel: 'Rock Field', discovered: isDisc(c.id), rocks: (c.asteroids ?? []).map((m) => ({ dx: m.x, dy: m.y, r: Math.max(1, m.size / 2), seed: ((m.x * 7919 + m.y * 104729) % 1000) / 1000, })), }); } // central body — the same rule as the current system's chart: the star // is invisible dossier flavor (its class feeds the plate tag + hue) const starClass = String(content.star?.class ?? '').toUpperCase(); const central = { name: 'Star', isHome: false, visible: false, radius: 240, typeLabel: config.get(`planets.starTypeLabels.${starClass}`, 'Star'), color: config.get(`planets.star.classColor.${starClass}`, '#ffe9b0'), }; return { systemId, systemName: content.name ?? systemId, isHome: false, central, objects, tethers: [], // the player's tethers live in the CURRENT system only ship: null, // the ship lives in the CURRENT system only // The DESTINATION object (if this system is the destination and the // destination names one) — the chart circles it (MapWindow.paintChart). destinationId: typeof o.destinationId === 'string' ? o.destinationId : null, stats: { nav: navDiscoveryStats(o.discovery, systemId, content), res: resourceStats(o.discovery, systemId, content), }, }; }