Add world preview (?type=) for judging landable worlds/stations

- New js/galaxy/TypeSystems.js: parseTypeSpec + pickPreviewSystem rank the seeded galaxy richest-first, exact face always beats a reskin fallback, optional ?fx= type filter.
- GameScene reads ?type=<class>[-NN] / ss[-NN], spawns the ship beside the target world/station, anchors its landing tether, and reskins the sheet frame when no world wears it so landing clips + surface loop follow.
- dev/type-preview.test.mjs exercises parsing, real-galaxy picks (exact/reskin/determinism/filter), crafted ranking, and bad inputs in bare Node.
- Smoke boot registers SurfaceScene alongside GameScene since the landing flow launches it on top of the flight scene.
This commit is contained in:
Brian Fertig 2026-09-09 16:46:24 -06:00
parent 3b0c150971
commit c84e64c5b0
4 changed files with 808 additions and 4 deletions

View File

@ -29,6 +29,7 @@ import { config } from '../js/config/Config.js';
import { ConfigLoader } from '../js/config/ConfigLoader.js'; import { ConfigLoader } from '../js/config/ConfigLoader.js';
import { createGameConfig } from '../js/config/GameConfig.js'; import { createGameConfig } from '../js/config/GameConfig.js';
import { GameScene } from '../js/scenes/GameScene.js'; import { GameScene } from '../js/scenes/GameScene.js';
import { SurfaceScene } from '../js/scenes/SurfaceScene.js';
const data = await ConfigLoader.load(); const data = await ConfigLoader.load();
config.init(data); config.init(data);
@ -51,7 +52,11 @@ if (typeof window !== 'undefined') {
console.error = (...a) => { __errors.push(a.map(String).join(' ')); origErr(...a); }; console.error = (...a) => { __errors.push(a.map(String).join(' ')); origErr(...a); };
window.addEventListener('error', (e) => __errors.push(String(e.message))); 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); const game = new Phaser.Game(gameConfig);
window.game = game; window.game = game;
console.info('smoke: game booted into GameScene'); console.info('smoke: game booted into GameScene');

448
dev/type-preview.test.mjs Normal file
View File

@ -0,0 +1,448 @@
/**
* World preview test (dev tool, run with Node no browser needed):
*
* node dev/type-preview.test.mjs
*
* ?type=<spec> drops the player next to a landable world/station to
* judge its clips + surface loop (js/galaxy/TypeSystems.js):
*
* - parseTypeSpec: the spec syntax <class>[-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');

210
js/galaxy/TypeSystems.js Normal file
View File

@ -0,0 +1,210 @@
import { config } from '../config/Config.js';
import { rollSystemComposition, settlementDensity } from './SystemGenerator.js';
/**
* Picking a system for the WORLD PREVIEW (?type=<spec>) 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;
}

View File

@ -27,6 +27,7 @@ import { StormLayer } from '../visuals/StormLayer.js';
import { SystemEffects } from '../visuals/SystemEffects.js'; import { SystemEffects } from '../visuals/SystemEffects.js';
import { assignUi, assignWorld } from '../visuals/UiCameras.js'; import { assignUi, assignWorld } from '../visuals/UiCameras.js';
import { pickFxSystem } from '../galaxy/FxSystems.js'; import { pickFxSystem } from '../galaxy/FxSystems.js';
import { parseTypeSpec, pickPreviewSystem } from '../galaxy/TypeSystems.js';
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js'; import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
import { ActionBar } from '../ui/ActionBar.js'; import { ActionBar } from '../ui/ActionBar.js';
import { MineralHud } from '../ui/MineralHud.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 // CURRENT SYSTEM only (the seed and the galaxy are untouched), so
// everything downstream — the record, the contents, the worlds, // everything downstream — the record, the contents, the worlds,
// the gates, the tethers — builds for the demo system for free. // the gates, the tethers — builds for the demo system for free.
//
// THE WORLD PREVIEW (?type=<class>[-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(); 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); const rec = pickFxSystem(this.galaxy, this.fxSystemType);
if (rec) this.galaxy.currentSystemId = rec.id; if (rec) this.galaxy.currentSystemId = rec.id;
else { else {
@ -364,6 +403,23 @@ export class GameScene extends Phaser.Scene {
this._freshRun = this._pendingRestore === null; this._freshRun = this._pendingRestore === null;
this.systemRecord = this.galaxy.currentSystem(); this.systemRecord = this.galaxy.currentSystem();
this.systemContent = this.galaxy.ensureContent(this.systemRecord.id); this.systemContent = this.galaxy.ensureContent(this.systemRecord.id);
// WORLD PREVIEW RESKIN (?type=<class>-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: // DEPLETED FIELDS — asteroid clusters the run has mined to nothing:
// per-run world state, registry-backed like discovery/research // per-run world state, registry-backed like discovery/research
// (js/save/SaveData.js). The galaxy's content regenerates from the // (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 = new Ship(this, 0, 0);
this.ship.setDepth(10); this.ship.setDepth(10);
const spawn = this.planet // WORLD PREVIEW (?type=…): the ship starts beside the TARGET world
? this.planet.edgePoint( // (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), Rng.derive(this.galaxy.seed, 'spawn', 'ship').range(0, Math.PI * 2),
config.get('planets.spawnDistanceFromEdge', 150), config.get('planets.spawnDistanceFromEdge', 150),
this.ship.radius, this.ship.radius,
) )
: { x: 0, y: 0 }; : { x: 0, y: 0 };
this.ship.setPosition(spawn.x, spawn.y); 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. // Center the camera on the ship from the very first frame.
this.cameras.main.setScroll( this.cameras.main.setScroll(
@ -1056,6 +1129,19 @@ export class GameScene extends Phaser.Scene {
this.applyRestore(this._pendingRestore); this.applyRestore(this._pendingRestore);
this._pendingRestore = null; 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 // The starter quest — granted ONCE onto a fresh ledger (a loaded save's
// ledger already holds whatever was granted back then — and give() is // ledger already holds whatever was granted back then — and give() is
// idempotent, so a jump-cut restart that rebuilds this scene off the // 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 * Top-left HUD: the current system's dossier the system name, its
* identity ("<Type> system · star <class>"), and its standing: faction * identity ("<Type> system · star <class>"), and its standing: faction