import { config } from '../config/Config.js'; import { Rng } from '../utils/Rng.js'; import { rollSystemComposition, settlementDensity } from './SystemGenerator.js'; /** * FRAME DIVERSITY — the galaxy-wide (class, frame) assignment. * * data/planets.json → frames maps each planet class to a pool of * spritesheet frames (terran/ice/lava share 0–2, gas 3–5, rocky 6–8 — * the same face can be worn by different classes; that's fine). Picking * a random frame per system at render time would let neighboring stars * wear the same face; this pass spreads each (class, frame) across the * galaxy instead: when a system's class-C planet needs a frame, it * AVOIDS frames class C already wears among the NEAREST stars (the * galaxy's neighbor pool, data/galaxy.json → neighbors), so the same * face reappears only far away. * * Why the pass is order-independent (determinism): systems are assigned * 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 (plus its own earlier planets). Nothing depends on generation * timing or visit order, so lazy (on-arrival) content generation and * eager generateAll stamp the same frames. The spatial order also makes * the "already assigned" set spatially consistent, which spreads the * faces a little better than roster order. Ties are broken by a * derived, per-pick Rng (seeded). * * Shared rolls: the pass re-derives each system's planet classes 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 weighted draw + a few pool scans per planet — trivial at this * galaxy size (data/galaxy.json → systemCount), computed once at galaxy * build time (js/galaxy/Galaxy.js). */ export function assignPlanetFrames({ seed, records, homeId, params, neighborsOf, typeDefs = null }) { const defs = typeDefs ?? config.get('systems.types', {}); const shim = { params: params ?? {} }; // settlementDensity's shape const poolFor = (cls) => { const pool = config.get(`planets.frames.${cls}`); return Array.isArray(pool) && pool.length > 0 ? pool : [0]; }; const assigned = new Map(); // id → [[class, frame], ...] (assigned so far) const frames = new Map(); // id → [frame, ...] (one per planet, ordinal order) let homeFrame = null; // FIXED spatial order (x, then y): a pure function of the seeded roster, // so the assignment never depends on generation timing or visit order. 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 { classes } = rollSystemComposition( seed, rec, isHome, attr.settlements ?? {}, density, attr, ); const neighbors = (typeof neighborsOf === 'function' ? neighborsOf(rec.id) : []) ?? []; // usage(class, frame) = [own, nb]: same-class planets already wearing // that frame in THIS system (own) and among the ALREADY-ASSIGNED // neighbor systems (nb — the spread objective). const list = []; const usage = (cls, f) => { let own = 0; let nb = 0; for (const [c2, f2] of list) if (c2 === cls && f2 === f) own++; for (const n2 of neighbors) { for (const [c2, f2] of assigned.get(n2.id) ?? []) if (c2 === cls && f2 === f) nb++; } return [own, nb]; }; const less = (a, b) => a[0] < b[0] || (a[0] === b[0] && a[1] < b[1]); // Pick the frame with the lowest (own, nb) usage — lexicographic: a // free face in this system always beats a used-but-neighborly one — // ties broken by a deterministic derived pick (seed, id, ordinal, class). const pick = (cls, idx) => { const pool = poolFor(cls); if (pool.length === 1) return pool[0]; let best = null; for (const f of pool) { const u = usage(cls, f); if (best === null || less(u, best)) best = u; } const tied = pool.filter((f) => { const u = usage(cls, f); return u[0] === best[0] && u[1] === best[1]; }); if (tied.length === 1) return tied[0]; return Rng.derive(seed, 'frames', rec.id, String(idx), cls).pick(tied); }; classes.forEach((cls, i) => { const f = pick(cls, i); list.push([cls, f]); }); frames.set(rec.id, list.map(([, f]) => f)); if (isHome) { // The home world (the origin) is a class-terran body — it takes a // frame from the same pass so its face is spread too. const homeCls = config.get('planets.homePlanet', 'terran'); homeFrame = pick(homeCls, 99); list.push([homeCls, homeFrame]); } assigned.set(rec.id, list); } return { frames, homeFrame }; }