diff --git a/dev/smoke-game.mjs b/dev/smoke-game.mjs index d26994d..b6c1463 100644 --- a/dev/smoke-game.mjs +++ b/dev/smoke-game.mjs @@ -29,6 +29,7 @@ import { config } from '../js/config/Config.js'; import { ConfigLoader } from '../js/config/ConfigLoader.js'; import { createGameConfig } from '../js/config/GameConfig.js'; import { GameScene } from '../js/scenes/GameScene.js'; +import { SurfaceScene } from '../js/scenes/SurfaceScene.js'; const data = await ConfigLoader.load(); config.init(data); @@ -51,7 +52,11 @@ if (typeof window !== 'undefined') { console.error = (...a) => { __errors.push(a.map(String).join(' ')); origErr(...a); }; window.addEventListener('error', (e) => __errors.push(String(e.message))); } -gameConfig.scene = [GameScene]; +// SurfaceScene registered too (the real entry registers +// [MenuScene, GameScene, SurfaceScene] — js/main.js): the smoke boot +// skips the menu, but the LANDING flow (?type=… world preview included) +// launches the surface on top of the flight scene, so it must exist. +gameConfig.scene = [GameScene, SurfaceScene]; const game = new Phaser.Game(gameConfig); window.game = game; console.info('smoke: game booted into GameScene'); diff --git a/dev/type-preview.test.mjs b/dev/type-preview.test.mjs new file mode 100644 index 0000000..471ef23 --- /dev/null +++ b/dev/type-preview.test.mjs @@ -0,0 +1,448 @@ +/** + * World preview test (dev tool, run with Node — no browser needed): + * + * node dev/type-preview.test.mjs + * + * ?type= drops the player next to a landable world/station to + * judge its clips + surface loop (js/galaxy/TypeSystems.js): + * + * - parseTypeSpec: the spec syntax — [-NN] planet faces (NN a + * 1-based ordinal into the class's data/planets.json → frames pool) + * and ss[-NN] station variants (data/stations.json → variants), + * case-insensitive, with out-of-range/unknown values → null; + * - pickPreviewSystem on the REAL seeded galaxy: every (class, face) + * and every station variant resolves to a system — exact (class, + * face) wins whenever the galaxy wears the face (cross-checked + * against the galaxy's actual content, lazy === eager), and the + * reskin fallback only fires for faces the galaxy wears nowhere + * (the home world — the galaxy's only terran — for terran faces it + * doesn't wear); an exact match ALWAYS beats a reskin, whatever the + * richness; the systemType filter (?fx= + ?type=) restricts the + * candidates; determinism (same call ⇒ same pick); + * - the RANKING on a crafted galaxy (same roll inputs, controlled + * frames/gates): richest-first within a tier, roster-order tiebreak, + * exact beats reskin. + */ + +process.env.NODE_ENV = 'dev'; + +import { pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href); +const fs = await import('node:fs'); +const dataDir = join(__dirname, '../data'); +const configData = {}; +for (const f of fs.readdirSync(dataDir)) { + if (!f.endsWith('.json') || f === 'manifest.json') continue; + configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8')); +} +config.init(configData); + +const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href); +const { rollSystemComposition, settlementDensity } = await import( + pathToFileURL(join(__dirname, '../js/galaxy/SystemGenerator.js')).href +); +const { parseTypeSpec, pickPreviewSystem } = await import( + pathToFileURL(join(__dirname, '../js/galaxy/TypeSystems.js')).href +); + +let failures = 0; +const check = (label, cond, extra = '') => { + console.log(`${cond ? '✔' : '✘ FAIL'} ${label}${cond ? '' : ' — ' + extra}`); + if (!cond) failures++; +}; + +const POOL = (k) => { + const p = config.get(k); + return Array.isArray(p) ? p : []; +}; +const planetClasses = Object.keys(config.get('planets.frames', {})); +const stationVariants = POOL('stations.variants'); + +// ---------------------------------------------------------------------- +// 1. parseTypeSpec — the spec syntax +// ---------------------------------------------------------------------- +check('parse: terran-01 = the FIRST terran face (pool [0,1,2] → frame 0)', (() => { + const s = parseTypeSpec('terran-01'); + return s?.kind === 'planet' && s.cls === 'terran' && s.frame === POOL('planets.frames.terran')[0]; +})()); +check('parse: ice-03 = the THIRD ice face (pool [9,10,11] → frame 11)', (() => { + const pool = POOL('planets.frames.ice'); + const s = parseTypeSpec('ice-03'); + return pool.length >= 3 && s?.kind === 'planet' && s.cls === 'ice' && s.frame === pool[2]; +})()); +check('parse: ss-02 = the SECOND station variant (pool → variants[1])', (() => { + const s = parseTypeSpec('ss-02'); + return stationVariants.length >= 2 && s?.kind === 'station' && s.frame === stationVariants[1]; +})()); +check('parse: bare class / bare ss = any face', (() => { + const a = parseTypeSpec('rocky'); + const b = parseTypeSpec('ss'); + return a?.kind === 'planet' && a.cls === 'rocky' && a.frame === null && + b?.kind === 'station' && b.frame === null; +})()); +check('parse: case-insensitive (ICE-03, SS-02, Terran)', (() => { + const a = parseTypeSpec('ICE-03'); + const b = parseTypeSpec('SS-02'); + const c = parseTypeSpec('Terran'); + return a?.cls === 'ice' && a.frame === POOL('planets.frames.ice')[2] && + b?.kind === 'station' && b.frame === stationVariants[1] && + c?.cls === 'terran' && c.frame === null; +})()); +check('parse: station aliases (station / spacestation) map like ss', (() => { + const a = parseTypeSpec('station-01'); + const b = parseTypeSpec('spacestation'); + return a?.kind === 'station' && a.frame === stationVariants[0] && + b?.kind === 'station' && b.frame === null; +})()); +check('parse: out-of-range / malformed / unknown → null', (() => { + return ( + parseTypeSpec('terran-04') === null && + parseTypeSpec('terran-0') === null && + parseTypeSpec('terran-x') === null && + parseTypeSpec('ss-9') === null && + parseTypeSpec('jupiter') === null && + parseTypeSpec('terran-') === null && + parseTypeSpec('') === null && + parseTypeSpec(null) === null + ); +})()); + +// ---------------------------------------------------------------------- +// 2. pickPreviewSystem — the REAL seeded galaxy +// ---------------------------------------------------------------------- +const SEED = 'type-preview-test'; +const g = Galaxy.create(SEED); +const types = config.section('systems.types', {}); + +// A system's actual content, as the generator will produce it (lazy). +const contentOf = (id) => g.ensureContent(id); + +// Verify a pick against the real content: the target world/station must +// EXIST with the spec's class/variant, and wear the spec's face unless +// the pick is a reskin (then the scene paints it — the CLASS must fit). +function verifyPick(spec, pick, why) { + if (!pick?.record) return `pick is null for ${why}`; + const c = contentOf(pick.record.id); + if (spec.kind === 'station') { + if (!pick.station) return `missing station flag on ${pick.record.id}`; + const s = c.settlements.find((x) => x.kind === 'deepSpaceStation'); + if (!s) return `${pick.record.id} has no deepSpaceStation in content`; + if (!pick.reskin && spec.frame != null && s.stationFrame !== spec.frame) { + return `${pick.record.id} station wears ${s.stationFrame}, spec wants ${spec.frame}`; + } + return null; + } + if (typeof pick.ordinal !== 'number') return `missing ordinal on ${pick.record.id}`; + if (pick.ordinal === 0) { + if (pick.record.id !== g.homeSystemId) return `ordinal 0 (home) but ${pick.record.id} ≠ home ${g.homeSystemId}`; + if (spec.cls !== config.get('planets.homePlanet', 'terran')) return `home world is not a ${spec.cls}`; + if (!pick.reskin && spec.frame != null && c.homeFrame !== spec.frame) { + return `home world wears ${c.homeFrame}, spec wants ${spec.frame}`; + } + return null; + } + const p = c.planets[pick.ordinal - 1]; + if (!p) return `${pick.record.id} has no planet #${pick.ordinal}`; + if (p.class !== spec.cls) return `planet #${pick.ordinal} is ${p.class}, spec wants ${spec.cls}`; + if (!pick.reskin && spec.frame != null && p.frame !== spec.frame) { + return `planet #${pick.ordinal} wears ${p.frame}, spec wants ${spec.frame}`; + } + return null; +} + +// 2a. Every (class, face) and every station variant resolves, and the +// pick is CORRECT against the content (exact ⇒ the world wears the face). +{ + let ok = true; + let why = ''; + const problems = []; + for (const cls of planetClasses) { + const pool = POOL(`planets.frames.${cls}`); + for (let i = 0; i < pool.length; i++) { + const spec = { kind: 'planet', cls, frame: pool[i] }; + const pick = pickPreviewSystem(g, spec); + const bad = verifyPick(spec, pick, `${cls} face ${i + 1}`); + if (bad) { ok = false; problems.push(bad); } + } + // bare class + const spec = { kind: 'planet', cls, frame: null }; + const pick = pickPreviewSystem(g, spec); + const bad = verifyPick(spec, pick, `${cls} (any face)`); + if (bad) { ok = false; problems.push(bad); } + } + for (const frame of stationVariants) { + const spec = { kind: 'station', frame }; + const pick = pickPreviewSystem(g, spec); + const bad = verifyPick(spec, pick, `ss variant ${frame}`); + if (bad) { ok = false; problems.push(bad); } + } + const spec = { kind: 'station', frame: null }; + const pick = pickPreviewSystem(g, spec); + const bad = verifyPick(spec, pick, 'ss (any variant)'); + if (bad) { ok = false; problems.push(bad); } + check('every (class, face) + every station variant resolves and matches the content', ok, problems.slice(0, 3).join('; ')); +} + +// 2b. EXACT beats RESKIN whenever the galaxy wears the face somewhere. +{ + let ok = true; + let why = ''; + const problems = []; + for (const cls of planetClasses) { + const pool = POOL(`planets.frames.${cls}`); + for (const frame of pool) { + const wornSomewhere = g.records.some((rec) => { + const c = contentOf(rec.id); + if (c.planets.some((p) => p.class === cls && p.frame === frame)) return true; + if (rec.id === g.homeSystemId && cls === config.get('planets.homePlanet') && c.homeFrame === frame) return true; + return false; + }); + const spec = { kind: 'planet', cls, frame }; + const pick = pickPreviewSystem(g, spec); + if (wornSomewhere) { + if (!pick || pick.reskin) { ok = false; problems.push(`${cls}/${frame} worn but pick is ${pick ? 'a reskin' : 'null'}`); } + } else if (pick && !pick.reskin) { + // an "exact" pick for a face nobody wears would be a lie + ok = false; + problems.push(`${cls}/${frame} unworn but pick claims exact`); + } + } + } + check('an exact match beats a reskin whenever the galaxy wears the face', ok, problems.slice(0, 3).join('; ')); +} + +// 2c. The RESKIN fallback exists for terran faces the home world doesn't +// wear (the home world is the galaxy's only terran). +{ + const homeFrame = g.ensureContent(g.homeSystemId).homeFrame; + const pool = POOL('planets.frames.terran'); + let reskins = 0; + let ok = true; + let why = ''; + for (const frame of pool) { + const spec = { kind: 'planet', cls: 'terran', frame }; + const pick = pickPreviewSystem(g, spec); + if (!pick) { ok = false; why = `terran face ${frame}: null`; break; } + if (frame === homeFrame) { + if (pick.reskin) { ok = false; why = `terran face ${frame} (the home world's) reskinned`; break; } + if (pick.ordinal !== 0 || pick.record.id !== g.homeSystemId) { + // a genuine terran world elsewhere would be fine, but there is + // no other terran in the galaxy — the home world is THE terran + ok = false; why = `terran face ${frame}: expected the home world, got ${pick.record.id}#${pick.ordinal}`; break; + } + } else { + if (!pick.reskin) { ok = false; why = `terran face ${frame}: unworn face not reskinned`; break; } + reskins++; + } + } + check('terran faces the home world lacks are reskinned onto it (the only terran)', ok && reskins === pool.length - 1, why || `${reskins} reskinned of ${pool.length - 1}`); +} + +// 2d. Determinism — the same spec always picks the same system. +{ + const specs = [ + { kind: 'planet', cls: 'ice', frame: POOL('planets.frames.ice')[1] }, + { kind: 'planet', cls: 'gas', frame: null }, + { kind: 'station', frame: stationVariants[0] }, + ]; + let ok = true; + for (const spec of specs) { + const a = pickPreviewSystem(g, spec); + const b = pickPreviewSystem(g, spec); + if (!a || a.record.id !== b?.record.id || a.ordinal !== b?.ordinal || a.station !== b?.station) { ok = false; break; } + } + check('deterministic (same spec ⇒ same pick, twice)', ok); +} + +// 2e. The ?fx= FILTER — ?type= inside a ?fx= system type. +{ + // Find a class/face that exists in SOME system type but NOT in + // another (both types present in the galaxy), then the filter must + // steer the pick (or give up) accordingly. + let ok = true; + let why = ''; + const typeIds = Object.keys(types); + const found = []; + for (const cls of planetClasses) { + for (const frame of POOL(`planets.frames.${cls}`)) { + const byType = new Set(); + for (const rec of g.records) { + const c = contentOf(rec.id); + const has = c.planets.some((p) => p.class === cls && p.frame === frame) || + (rec.id === g.homeSystemId && cls === config.get('planets.homePlanet') && c.homeFrame === frame); + if (has) byType.add(rec.type); + } + if (byType.size >= 2) found.push({ cls, frame, byType }); + } + } + if (found.length === 0) { + ok = false; why = 'no face found in ≥2 system types (test galaxy too small?)'; + } else { + for (const { cls, frame, byType } of found.slice(0, 4)) { + const spec = { kind: 'planet', cls, frame }; + const inside = [...byType].sort().join(','); + const outside = typeIds.filter((t) => !byType.has(t)); + const pIn = pickPreviewSystem(g, spec, [...byType][0]); + const pOut = outside.length > 0 ? pickPreviewSystem(g, spec, outside[0]) : null; + if (!pIn || pIn.record.type !== [...byType][0]) { ok = false; why = `${cls}/${frame}: in-pick ${pIn?.record?.type}`; break; } + // OUTSIDE the wearing types: the face is unworn there, so a pick is + // only legal as a RESKIN (the class present, the face painted on); + // null when the class itself is absent from that type's systems. + if (pOut !== null) { + const c = contentOf(pOut.record.id); + const clsPresent = pOut.ordinal === 0 + ? pOut.record.id === g.homeSystemId + : c.planets[pOut.ordinal - 1]?.class === cls; + if (pOut.reskin !== true || !clsPresent) { ok = false; why = `${cls}/${frame}: out-pick ${pOut.record.id} (reskin=${pOut.reskin})`; break; } + } + } + if (ok) console.log(` (filter cases: ${found.slice(0, 4).map((f) => `${f.cls}/${f.frame}`).join(', ')})`); + } + check('systemType filter: exact inside the wearing types; reskin-or-null outside', ok, why); +} + +// 2f. Stations: a variant worn somewhere ⇒ exact (no reskin). +{ + const worn = new Set(); + for (const rec of g.records) { + const c = contentOf(rec.id); + const s = c.settlements.find((x) => x.kind === 'deepSpaceStation'); + if (s?.stationFrame != null) worn.add(s.stationFrame); + } + let ok = true; + let why = ''; + for (const frame of stationVariants) { + const spec = { kind: 'station', frame }; + const pick = pickPreviewSystem(g, spec); + if (!pick) { ok = false; why = `ss variant ${frame}: null`; continue; } + if (worn.has(frame) && pick.reskin) { ok = false; why = `ss variant ${frame} worn but reskinned`; } + if (!worn.has(frame) && !pick.reskin) { ok = false; why = `ss variant ${frame} unworn but exact`; } + } + check('station variants: exact where worn, reskin where unworn', ok && worn.size > 0, why || `${[...worn].join(',')} worn`); +} + +// ---------------------------------------------------------------------- +// 3. The RANKING — crafted galaxy (controlled frames + gates) +// ---------------------------------------------------------------------- +// Reuse REAL record ids so rollSystemComposition (seed, id, type, d) +// agrees with the real galaxy's rolls — only the stamped frames and the +// gate counts are crafted. +{ + // Two non-home systems whose roll gives an ICE planet (real rolls). + const iceRecs = g.records.filter((rec) => { + if (rec.id === g.homeSystemId) return false; + const c = contentOf(rec.id); + return c.planets.some((p) => p.class === 'ice'); + }); + const stRecs = g.records.filter((rec) => { + if (rec.id === g.homeSystemId) return false; + return contentOf(rec.id).settlements.some((x) => x.kind === 'deepSpaceStation'); + }); + const iceA = iceRecs[0]; + const iceB = iceRecs[1]; + const ok = iceA && iceB; + check('ranking fixture: two ice-world systems exist in the test galaxy', !!ok); + if (ok) { + const iceFrame = POOL('planets.frames.ice')[1]; // the 2nd ice face + const iceOrdinal = (rec) => contentOf(rec.id).planets.findIndex((p) => p.class === 'ice') + 1; + const mkGalaxy = (gateCounts) => ({ + seed: g.seed, + records: [iceA, iceB], + homeSystemId: g.homeSystemId, // neither fixture record is home + homeWorldFrame: g.homeWorldFrame, + params: g.params, + planetFrames: new Map(), + stationFrames: new Map(), + jumpGatesFor: (id) => Array.from({ length: gateCounts[id] ?? 0 }, (_, i) => ({ id: `${id}-gate-${i}` })), + }); + + // 3a. EXACT beats a RICHER reskin: A is richer (3 gates) but wears a + // different ice face; B (1 gate) wears the requested face. + { + const other = POOL('planets.frames.ice').find((f) => f !== iceFrame) ?? POOL('planets.frames.ice')[0]; + const gf = mkGalaxy({ [iceA.id]: 3, [iceB.id]: 1 }); + gf.planetFrames.set(iceA.id, new Array(contentOf(iceA.id).planets.length).fill(other)); + gf.planetFrames.set(iceB.id, (() => { const l = contentOf(iceB.id).planets.length; const a = new Array(l).fill(other); a[iceOrdinal(iceB) - 1] = iceFrame; return a; })()); + const pick = pickPreviewSystem(gf, { kind: 'planet', cls: 'ice', frame: iceFrame }); + check('ranking: an exact face beats a richer reskin (B wins despite A\u2019s 3 gates)', pick?.record?.id === iceB.id && pick.reskin === false, `got ${pick?.record?.id} reskin=${pick?.reskin}`); + } + // 3b. Within the RESKIN tier the richer system wins (A: 3 gates). + { + const gf = mkGalaxy({ [iceA.id]: 3, [iceB.id]: 1 }); + const other = POOL('planets.frames.ice').find((f) => f !== iceFrame) ?? POOL('planets.frames.ice')[0]; + gf.planetFrames.set(iceA.id, new Array(contentOf(iceA.id).planets.length).fill(other)); + gf.planetFrames.set(iceB.id, new Array(contentOf(iceB.id).planets.length).fill(other)); + const pick = pickPreviewSystem(gf, { kind: 'planet', cls: 'ice', frame: iceFrame }); + check('ranking: reskin tier ranks richest-first (A wins)', pick?.record?.id === iceA.id && pick.reskin === true, `got ${pick?.record?.id} reskin=${pick?.reskin}`); + } + // 3c. Within the EXACT tier the richer system wins (flip the frames). + { + const gf = mkGalaxy({ [iceA.id]: 3, [iceB.id]: 1 }); + const other = POOL('planets.frames.ice').find((f) => f !== iceFrame) ?? POOL('planets.frames.ice')[0]; + gf.planetFrames.set(iceA.id, (() => { const l = contentOf(iceA.id).planets.length; const a = new Array(l).fill(other); a[iceOrdinal(iceA) - 1] = iceFrame; return a; })()); + gf.planetFrames.set(iceB.id, new Array(contentOf(iceB.id).planets.length).fill(other)); + const pick = pickPreviewSystem(gf, { kind: 'planet', cls: 'ice', frame: iceFrame }); + check('ranking: exact tier ranks richest-first (A wins)', pick?.record?.id === iceA.id && pick.reskin === false, `got ${pick?.record?.id} reskin=${pick?.reskin}`); + } + // 3d. No ice world at all in the (filtered) candidates ⇒ null. + { + const noIce = iceRecs.filter((r) => r.id !== iceA.id && r.id !== iceB.id && !contentOf(r.id).planets.some((p) => p.class === 'ice')); + const victim = noIce[0]; + if (victim) { + const gf = mkGalaxy({ [victim.id]: 5 }); + const pick = pickPreviewSystem(gf, { kind: 'planet', cls: 'ice', frame: iceFrame }); + check('ranking: a candidate without the class (and no home) ⇒ null', pick === null, `got ${JSON.stringify(pick)}`); + } else { + console.log(' (3d skipped — no ice-free fixture system)'); + } + } + // 3e. Station ranking: exact beats a richer reskin. + { + const variant = stationVariants[1]; + if (stRecs.length >= 2) { + const [sA, sB] = stRecs; + const gsf = { + seed: g.seed, + records: [sA, sB], + homeSystemId: g.homeSystemId, + homeWorldFrame: g.homeWorldFrame, + params: g.params, + planetFrames: new Map(), + stationFrames: new Map(), + jumpGatesFor: (id) => Array.from({ length: id === sA.id ? 3 : 1 }, (_, i) => ({ id: `${id}-gate-${i}` })), + }; + const other = stationVariants.find((f) => f !== variant) ?? stationVariants[0]; + gsf.stationFrames.set(sA.id, other); + gsf.stationFrames.set(sB.id, variant); + const pick = pickPreviewSystem(gsf, { kind: 'station', frame: variant }); + check('ranking (station): exact beats a richer reskin (B wins)', pick?.record?.id === sB.id && pick.station === true && pick.reskin === false, `got ${pick?.record?.id} reskin=${pick?.reskin}`); + } else { + console.log(' (3e skipped — need ≥2 station systems in the test galaxy)'); + } + } + } +} + +// ---------------------------------------------------------------------- +// 4. Bad inputs +// ---------------------------------------------------------------------- +check('picker: null/empty/foreign specs ⇒ null', (() => { + return ( + pickPreviewSystem(g, null) === null && + pickPreviewSystem(g, {}) === null && + pickPreviewSystem(g, { kind: 'planet' }) === null && + pickPreviewSystem(g, { kind: 'comet', frame: 0 }) === null && + pickPreviewSystem({ records: [] }, { kind: 'station', frame: 0 }) === null + ); +})()); + +if (failures > 0) { + console.error(`\n✘ type-preview: ${failures} check(s) FAILED`); + process.exit(1); +} +console.log('\n✔ type-preview: all checks passed'); diff --git a/js/galaxy/TypeSystems.js b/js/galaxy/TypeSystems.js new file mode 100644 index 0000000..317c4f0 --- /dev/null +++ b/js/galaxy/TypeSystems.js @@ -0,0 +1,210 @@ +import { config } from '../config/Config.js'; +import { rollSystemComposition, settlementDensity } from './SystemGenerator.js'; + +/** + * Picking a system for the WORLD PREVIEW (?type=) — pure over the + * seeded galaxy, so dev/type-preview.test.mjs can exercise it in bare + * Node (the sibling of FxSystems.js, the ?fx= star-effects demo). + * + * The preview wants to drop the player NEXT TO a specific world or + * station they can land on and judge its clips/music: the surface + * videos and the surface loop are keyed by the SHEET FRAME (the same + * frame the world is drawn with — data/landing.json → videos / + * stationVideos, data/music.json → frames / stationFrames), so "preview + * terran-02" must mean a world wearing the SECOND terran face. + * + * SPEC SYNTAX (parseTypeSpec) — the NN is a 1-based ordinal INTO THE + * CLASS'S FRAME POOL (data/planets.json → frames / data/stations.json → + * variants), which is also the asset naming (terran-land-0N.mp4, + * ss-land-0N.mp4): + * + * ?type=terran any terran world (the home world — terran faces + * exist nowhere else in the galaxy; the starting + * system's home world is the one and only) + * ?type=terran-02 the terran world wearing the 2nd terran face + * (pool [0,1,2] → sheet frame 1) + * ?type=ice-03 the 3rd ice face (pool [9,10,11] → sheet frame 11) + * ?type=rocky | gas | lava (+ -NN for a specific face) + * ?type=ss any deep-space station + * ?type=ss-02 the station wearing the 2nd variant + * (pool [0,1,2] → variant 1) + * + * CANDIDATES are ranked richest-first — more objects, then more jump + * gates (the FxSystems convention) — with roster order as the stable + * tiebreak (seed-deterministic either way). The composition of each + * system is RE-DERIVED from the seed via the same exported roll the + * content generator uses (SystemGenerator.rollSystemComposition — + * guaranteed to agree, since the forks are pure functions of + * (seed, id, type, density)), and the (class, frame) assignments come + * from the galaxy-wide spread pass already stamped on the galaxy + * (galaxy.planetFrames / galaxy.stationFrames / galaxy.homeWorldFrame) + * — so the picker needs NO content generation (lazy === eager holds). + * + * RESKIN FALLBACK: a requested face the galaxy wears NOWHERE (e.g. the + * home world is the only terran and it wears face 1, but the preview + * asks for face 2) still previews: the pick drops to the RICHEST system + * holding a world of that class (the home world for terran), flagged + * `reskin: true` — the scene repaints that world's sheet frame to the + * requested one before its entities build, so the demo world wears the + * requested face and its landing clips + surface loop follow (they + * index off the drawn frame). An exact (class, frame) match ALWAYS + * beats a reskin, whatever the richness. + */ + +/** + * Parse a `?type=` value into a preview spec. + * @param {string} raw the raw URL parameter (e.g. 'terran-02', 'ss-01', 'ICE-03') + * @returns {{kind:'planet',cls:string,frame:?number}|{kind:'station',frame:?number}|null} + * the spec, or null when the value is empty/unknown (not a pool + * ordinal, not a class, out of range, or a malformed station tag). + */ +export function parseTypeSpec(raw) { + const s = String(raw ?? '').trim().toLowerCase(); + if (!s) return null; + + const pool = (v) => (Array.isArray(v) ? v.filter((f) => Number.isInteger(f) && f >= 0) : []); + + // SPACE STATIONS — ss / station / spacestation (+ -NN variant ordinal). + const mStation = /^(ss|station|spacestation)(?:-(\d+))?$/.exec(s); + if (mStation) { + const variants = pool(config.get('stations.variants')); + if (mStation[2] === undefined) return { kind: 'station', frame: null }; + const n = Number(mStation[2]); + if (!(n >= 1 && n <= variants.length)) return null; + return { kind: 'station', frame: variants[n - 1] }; + } + + // PLANET CLASSES — the data/planets.json → frames keys + // (terran / rocky / gas / ice / lava), case-insensitive (+ -NN). + const framesCfg = config.get('planets.frames', {}); + const dash = s.lastIndexOf('-'); + const base = dash > 0 ? s.slice(0, dash) : s; + const num = dash > 0 ? s.slice(dash + 1) : null; + let cls = null; + for (const key of Object.keys(framesCfg)) { + if (key.toLowerCase() === base) { cls = key; break; } + } + if (!cls) return null; + const classPool = pool(config.get(`planets.frames.${cls}`)); + if (num === null) return { kind: 'planet', cls, frame: null }; + if (!/^\d+$/.test(num)) return null; + const n = Number(num); + if (!(n >= 1 && n <= classPool.length)) return null; + return { kind: 'planet', cls, frame: classPool[n - 1] }; +} + +/** + * Pick the preview system for a spec. + * @param {object} galaxy a seeded galaxy (Galaxy.js) — reads `.records`, + * `.seed`, `.homeSystemId`, `.homeWorldFrame`, `.planetFrames`, + * `.stationFrames`, `.params` (settlement density) and + * `.jumpGatesFor(id)` (the gate count). No contentCache needed. + * @param {object} spec a parseTypeSpec() result + * @param {string | null} [systemType] optional archetype filter — when + * set (?type= combined with ?fx=), only systems of that type are + * considered (a preview of a world UNDER a given star's character). + * @returns {object | null} the pick, or null when no system holds the + * kind at all: + * { record (has .id/.type), ordinal (1-based planet ordinal — 0 = + * the home world, planets only), station (true for stations), + * reskin (true when the face must be painted on the pick) } + */ +export function pickPreviewSystem(galaxy, spec, systemType = null) { + const records = Array.isArray(galaxy?.records) ? galaxy.records : []; + if (records.length === 0) return null; + if (!spec || (spec.kind !== 'planet' && spec.kind !== 'station')) return null; + if (spec.kind === 'planet' && typeof spec.cls !== 'string') return null; + if (typeof galaxy?.seed !== 'string' || galaxy.seed.length === 0) return null; + + const defs = config.get('systems.types', {}); + const homeId = galaxy?.homeSystemId ?? null; + const homeCls = config.get('planets.homePlanet', 'terran'); + const planetFrames = galaxy?.planetFrames instanceof Map ? galaxy.planetFrames : null; + const stationFrames = galaxy?.stationFrames instanceof Map ? galaxy.stationFrames : null; + + let best = null; // the winning pick + let bestKey = null; // its richness score + let bestExact = null; // true = exact face worn, false = reskin + + const consider = (rec, target, exact) => { + // An EXACT face (the world already wears it) always beats a RESKIN, + // whatever the richness; within a tier the richer system wins. + if (best !== null) { + if (exact === bestExact && !(keyOf(rec) > bestKey)) return; + if (bestExact === true && exact === false) return; + } + best = { record: rec, ...target, reskin: exact ? false : true }; + bestKey = keyOf(rec); + bestExact = exact; + }; + + const keyOf = (rec) => { + const attr = defs[rec.type]?.attributes ?? {}; + const comp = rollSystemComposition( + galaxy.seed, rec, rec.id === homeId, attr.settlements ?? {}, + settlementDensity(galaxy, rec), attr, + ); + const objects = Number.isFinite(comp?.objects) ? comp.objects : 0; + const gates = + typeof galaxy?.jumpGatesFor === 'function' + ? ((galaxy.jumpGatesFor(rec.id) ?? []).length) + : 0; + return objects * 1000 + gates; + }; + + for (const rec of records) { + if (!rec) continue; + if (systemType && rec.type !== systemType) continue; + const isHome = rec.id === homeId; + const attr = defs[rec.type]?.attributes ?? {}; + let comp; + try { + comp = rollSystemComposition( + galaxy.seed, rec, isHome, attr.settlements ?? {}, + settlementDensity(galaxy, rec), attr, + ); + } catch { + continue; + } + const classes = Array.isArray(comp?.classes) ? comp.classes : []; + + if (spec.kind === 'planet') { + const frames = planetFrames?.get(rec.id) ?? null; + // The system's planets in ORBITAL order; ordinal 0 = the home + // world (starting system only — a terran-class body by rule). + const candidates = classes.map((cls, i) => ({ cls, frame: frames ? frames[i] : null, ordinal: i + 1 })); + if (isHome) candidates.push({ cls: homeCls, frame: galaxy?.homeWorldFrame ?? null, ordinal: 0 }); + + let exact = null; + for (const c of candidates) { + if (c.cls !== spec.cls) continue; + if (spec.frame != null && c.frame !== spec.frame) continue; + exact = c.ordinal; + break; + } + if (exact !== null) { + consider(rec, { ordinal: exact }, true); + continue; + } + // RESKIN tier (a specific face the system wears on none of its + // matching-class worlds): the FIRST such world takes the paint. + if (spec.frame == null) continue; + const reskinOrdinal = candidates.find((c) => c.cls === spec.cls)?.ordinal; + if (reskinOrdinal === undefined) continue; + consider(rec, { ordinal: reskinOrdinal }, false); + continue; + } + + // STATIONS — at most one deep-space station per system. + if (!comp.deepSpace) continue; + const frame = stationFrames?.get(rec.id) ?? null; + if (spec.frame == null) { + consider(rec, { station: true }, true); + continue; + } + if (frame === spec.frame) consider(rec, { station: true }, true); + else consider(rec, { station: true }, false); // reskin the variant + } + + return best; +} diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 3e78408..08e5e5d 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -27,6 +27,7 @@ import { StormLayer } from '../visuals/StormLayer.js'; import { SystemEffects } from '../visuals/SystemEffects.js'; import { assignUi, assignWorld } from '../visuals/UiCameras.js'; import { pickFxSystem } from '../galaxy/FxSystems.js'; +import { parseTypeSpec, pickPreviewSystem } from '../galaxy/TypeSystems.js'; import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js'; import { ActionBar } from '../ui/ActionBar.js'; import { MineralHud } from '../ui/MineralHud.js'; @@ -343,8 +344,46 @@ export class GameScene extends Phaser.Scene { // CURRENT SYSTEM only (the seed and the galaxy are untouched), so // everything downstream — the record, the contents, the worlds, // the gates, the tethers — builds for the demo system for free. + // + // THE WORLD PREVIEW (?type=[-NN] / ss[-NN] — js/galaxy/ + // TypeSystems.js): drop the run onto the richest system holding a + // world/station of the requested kind (a specific SHEET FACE when + // -NN names one — the face its landing clips + surface loop wear), + // and spawn the ship right beside it (below), so a landing puts the + // player on its surface for the videos/music. ?type= wins the system + // pick; when BOTH are given the world must live in a system of the + // ?fx= type (a preview under a chosen star's character), and a + // ?type= that finds nothing there falls back to the plain ?fx= demo. this.fxSystemType = this._readFxSystemType(); - if (this.fxSystemType) { + this.typeSpec = this._readTypeSpec(); + this.typeSpecRaw = this._typeSpecRaw; + this.typeTarget = null; + if (this.typeSpec) { + const pick = pickPreviewSystem(this.galaxy, this.typeSpec, this.fxSystemType); + if (pick) { + this.galaxy.currentSystemId = pick.record.id; + this.typeTarget = pick; // { record, ordinal|station, reskin } + if (pick.reskin) { + console.info( + `[orbit] ?type=${this.typeSpecRaw} — no world wears that exact face in this galaxy; ` + + `previewing it on ${pick.record.id}'s closest match (reskinned for the demo).`, + ); + } + } else if (this.fxSystemType) { + console.warn( + `[orbit] ?type=${this.typeSpecRaw} — no world of that kind in a ${this.fxSystemType}-type system; falling back to the ?fx= demo.`, + ); + const rec = pickFxSystem(this.galaxy, this.fxSystemType); + if (rec) this.galaxy.currentSystemId = rec.id; + else { + console.warn(`[orbit] ?fx=${this.fxSystemType} — no system of that type in this galaxy; starting as usual.`); + this.fxSystemType = null; + } + } else { + console.warn(`[orbit] ?type=${this.typeSpecRaw} — no world of that kind in this galaxy; starting as usual.`); + this.typeSpec = null; + } + } else if (this.fxSystemType) { const rec = pickFxSystem(this.galaxy, this.fxSystemType); if (rec) this.galaxy.currentSystemId = rec.id; else { @@ -364,6 +403,23 @@ export class GameScene extends Phaser.Scene { this._freshRun = this._pendingRestore === null; this.systemRecord = this.galaxy.currentSystem(); this.systemContent = this.galaxy.ensureContent(this.systemRecord.id); + // WORLD PREVIEW RESKIN (?type=-NN whose face the galaxy wears + // nowhere — pick.reskin): paint the requested sheet frame on the + // target BEFORE its entities build (below) — the demo world wears + // the face, and its landing/surface clips + surface loop follow + // (data/landing.json / data/music.json index off the drawn frame). + if (this.typeTarget?.reskin && typeof this.typeSpec?.frame === 'number') { + const frame = this.typeSpec.frame; + if (this.typeTarget.station) { + const s = (this.systemContent.settlements ?? []).find((x) => x?.kind === 'deepSpaceStation'); + if (s) s.stationFrame = frame; + } else if (this.typeTarget.ordinal === 0) { + this.systemContent.homeFrame = frame; // the home world (starting system) + } else { + const p = (this.systemContent.planets ?? [])[this.typeTarget.ordinal - 1]; + if (p) p.frame = frame; + } + } // DEPLETED FIELDS — asteroid clusters the run has mined to nothing: // per-run world state, registry-backed like discovery/research // (js/save/SaveData.js). The galaxy's content regenerates from the @@ -597,14 +653,31 @@ export class GameScene extends Phaser.Scene { } this.ship = new Ship(this, 0, 0); this.ship.setDepth(10); - const spawn = this.planet - ? this.planet.edgePoint( + // WORLD PREVIEW (?type=…): the ship starts beside the TARGET world + // (this._previewEntity — the planet/station the pick named), not + // the home world — the point is to land on it and judge its clips + // and surface loop. Same edge-to-edge hop as the home spawn, same + // seed-derived direction (same galaxy ⇒ same start). + const spawnBody = (this.typeTarget ? this._previewEntity() : null) ?? this.planet; + const spawn = spawnBody + ? spawnBody.edgePoint( Rng.derive(this.galaxy.seed, 'spawn', 'ship').range(0, Math.PI * 2), config.get('planets.spawnDistanceFromEdge', 150), this.ship.radius, ) : { x: 0, y: 0 }; this.ship.setPosition(spawn.x, spawn.y); + // DEV QA handle for the world preview (?type=…): what the boot named + // and where the ship was placed — inspect in the console. + if (typeof window !== 'undefined') { + window.__ORBIT_PREVIEW__ = { + typeSpec: this.typeSpec, + systemId: this.systemRecord?.id, + spawnBody: spawnBody ? spawnBody.discoveryName ?? spawnBody.label ?? null : null, + spawn: { x: Math.round(spawn.x), y: Math.round(spawn.y) }, + ship: { x: Math.round(this.ship.x), y: Math.round(this.ship.y) }, + }; + } // Center the camera on the ship from the very first frame. this.cameras.main.setScroll( @@ -1056,6 +1129,19 @@ export class GameScene extends Phaser.Scene { this.applyRestore(this._pendingRestore); this._pendingRestore = null; } + // WORLD PREVIEW (?type=…): the ship is parked at the TARGET world — + // anchor that world's first-landing tether (data/builds.json → + // tether-l1: free + instant, "the moment the player lands on a + // world") so the player's room to move is the world's rim — exactly + // the state a first landing leaves. Without it the ship would sit + // outside every tether (the other systems' gate tethers don't + // necessarily reach the target world) and the rim constraint would + // drag it back. Idempotent: the home world already hosts its home + // tether, and ensureLandingTether adopts existing fields. + if (this.typeTarget) { + const previewEntity = this._previewEntity(); + if (previewEntity?.discoveryName) this.ensureLandingTether(previewEntity.discoveryName); + } // The starter quest — granted ONCE onto a fresh ledger (a loaded save's // ledger already holds whatever was granted back then — and give() is // idempotent, so a jump-cut restart that rebuilds this scene off the @@ -1483,6 +1569,61 @@ export class GameScene extends Phaser.Scene { } } + /** + * The world preview's spec (js/galaxy/TypeSystems.js): read the + * `?type=` URL parameter (browser only — no-op in Node/tests) and + * parse it into a { kind, cls?, frame? } spec — a planet class + * (terran/rocky/gas/ice/lava) with an optional 1-based face ordinal + * (terran-02 = the 2nd face of the class's pool), or a station + * (ss / ss-NN variant). Absent → null (no preview); malformed → + * null (with a note — `this._typeSpecRaw` keeps the raw value for + * the message). Case-insensitive throughout. + * + * Examples: http://localhost:8080/?type=terran-01 …or ?type=ss-02 …or ?type=ice-03 + * + * @returns {object | null} a parseTypeSpec() result, or null + */ + _readTypeSpec() { + this._typeSpecRaw = null; + const loc = typeof globalThis !== 'undefined' ? globalThis.location : null; + if (!loc?.search) return null; + try { + const raw = new URLSearchParams(loc.search).get('type'); + if (raw === null) return null; + this._typeSpecRaw = raw; + const spec = parseTypeSpec(raw); + if (!spec) { + const classes = Object.keys(config.get('planets.frames', {})).join('/'); + console.warn(`[orbit] ?type=${raw} — unknown world (try ${classes} or -NN faces) or station (ss / ss-NN).`); + } + return spec; + } catch { + return null; + } + } + + /** + * The world preview's TARGET entity (the planet/station the ship + * spawns beside) — resolved from this.typeTarget now that the + * system's entities exist. null = no preview target (or the entity + * is missing — the home-world spawn fallback applies). + */ + _previewEntity() { + const t = this.typeTarget; + if (!t) return null; + if (t.station) { + // At most one deep-space station per system — match the entity + // to the settlement record by id (name-safe). + const rec = (this.systemContent?.settlements ?? []).find((s) => s?.kind === 'deepSpaceStation'); + if (!rec) return null; + return (this.systemStations ?? []).find((s) => s.settlement?.id === rec.id) ?? null; + } + if (t.ordinal === 0) return this.planet ?? null; // the home world + const rec = (this.systemContent?.planets ?? [])[t.ordinal - 1]; + if (!rec) return null; + return (this.systemPlanets ?? []).find((p) => p.discoveryName === rec.name) ?? null; + } + /** * Top-left HUD: the current system's dossier — the system name, its * identity (" system · star "), and its standing: faction