import { config } from '../config/Config.js'; import { Rng } from '../utils/Rng.js'; import { rollSystemComposition, settlementDensity } from './SystemGenerator.js'; /** * STATION VARIANT SPREAD — the galaxy-wide deep-space-station frame * assignment (the sibling of the planet frame pass, PlanetFrames.js). * * data/stations.json → variants lists the spacestations.png sheet frames * a DEEP-SPACE STATION may wear (today 0..2 — the first three frames of * the sheet; add a line when the art lands). Picking a random frame per * system would let neighboring stars wear the same face; this pass * spreads the variants instead: when a system's deep-space station needs * a frame, it AVOIDS the frames its NEAREST stars already wear (the * galaxy's neighbor pool, data/galaxy.json → neighbors), so the same * station type reappears only far away. * * Only DEEP-SPACE STATIONS take a variant — and a system holds up to TWO * of them (rollSystemComposition rolls a count): each station picks its * own frame, avoiding the frames its NEAREST stars already wear AND, for * the second station, its own system's first frame. The landing handoff * reads the same frame (settlement.stationFrame → data/landing.json → * stationVideos), so a station always lands with the clip matching the * art it is drawn with. * * Why the pass is order-independent (determinism): systems are visited * in a FIXED order — sorted by (x, y), a pure function of the seeded * roster — and each system only counts frames ALREADY assigned to its * neighbors. Nothing depends on generation timing or visit order, so * lazy (on-arrival) content generation and eager generateAll stamp the * same frames. Ties are broken by a derived, per-pick Rng (seeded). * * Shared rolls: the pass re-derives each system's composition via the * same exported roll and forks as the content generator * (SystemGenerator.rollSystemComposition) — guaranteed to agree, since * the forks are pure functions of (seed, id, type, density). * * Cost: one pool scan per station per station-bearing system — trivial * at this galaxy size (data/galaxy.json → systemCount), computed once at * galaxy build time (js/galaxy/Galaxy.js). */ export function assignStationFrames({ seed, records, homeId, params, neighborsOf, typeDefs = null }) { const defs = typeDefs ?? config.get('systems.types', {}); const shim = { params: params ?? {} }; // settlementDensity's shape // The variant pool (data/stations.json → variants). An empty/missing // pool means "no variants yet" — nothing is stamped and the renderer // keeps its procedural station. const raw = config.get('stations.variants'); const pool = Array.isArray(raw) ? raw.filter((f) => Number.isInteger(f) && f >= 0) : []; const frames = new Map(); // id → [frame, ...] (one per station, draw order) if (pool.length === 0) return { frames }; const assigned = new Map(); // id → [frame, ...] (assigned so far) // FIXED spatial order (x, then y): a pure function of the seeded // roster, so the assignment never depends on generation timing or // visit order (the planet pass's contract). const ordered = records.slice().sort((a, b) => (a.x - b.x) || (a.y - b.y)); for (const rec of ordered) { const isHome = rec.id === homeId; const attr = defs[rec.type]?.attributes ?? {}; const density = settlementDensity(shim, rec); const { stations } = rollSystemComposition( seed, rec, isHome, attr.settlements ?? {}, density, attr, ); if (!stations) continue; // no deep-space station here — nothing to frame const neighbors = (typeof neighborsOf === 'function' ? neighborsOf(rec.id) : []) ?? []; // One frame PER STATION (a system holds up to two): a system never // wears the same face twice (hard — the original "one station per // system" invariant, now per slot), and among the allowed faces each // pick MINIMIZES usage among the NEIGHBORS' stations (the spread // objective — the same lexicographic pick as the planet pass). // Remaining ties broken by a deterministic derived pick (seeded, per // slot). const picked = []; for (let slot = 0; slot < stations; slot++) { const allowed = pool.filter((f) => !picked.includes(f)); const cand = allowed.length > 0 ? allowed : pool; // 1-variant pool ⇒ repeat const nbUsage = (f) => { let nb = 0; for (const n of neighbors) { const nfs = assigned.get(n.id); if (nfs) nb += nfs.filter((x) => x === f).length; } return nb; }; let best = Infinity; for (const f of cand) best = Math.min(best, nbUsage(f)); const tied = cand.filter((f) => nbUsage(f) === best); picked.push(tied.length === 1 ? tied[0] : Rng.derive(seed, 'station-frames', rec.id, slot).pick(tied)); } frames.set(rec.id, picked); assigned.set(rec.id, picked); } return { frames }; }