diff --git a/assets/videos/rocky-land-01.mp4 b/assets/videos/rocky-land-01.mp4 new file mode 100644 index 0000000..f530492 Binary files /dev/null and b/assets/videos/rocky-land-01.mp4 differ diff --git a/assets/videos/rocky-takeoff-03.mp4 b/assets/videos/rocky-takeoff-03.mp4 new file mode 100644 index 0000000..5262e80 Binary files /dev/null and b/assets/videos/rocky-takeoff-03.mp4 differ diff --git a/data/builds.json b/data/builds.json index 1ef4f15..a494010 100644 --- a/data/builds.json +++ b/data/builds.json @@ -41,7 +41,7 @@ "builds": { "tether-l1": { "label": "Tether - Level 1", - "description": "The world's first tether: a level-1 field (range 5120 px) holding the ship's travel rim around the planet. Every world starts with its tether already in place.", + "description": "The world's first tether: a level-1 field (range 5120 px) anchoring the ship's travel rim around the planet. The home world ships with it already in place; on every other world this is the build that installs it.", "icon": "tether", "category": "planet", "targets": ["planet"], @@ -50,7 +50,12 @@ "requires": [], "repeatable": false, "starting": ["home"], - "effects": {}, + "effects": { + "tether": { + "level": 1, + "anchor": "target" + } + }, "theme": { "color": "#00e5ff" } diff --git a/data/landing.json b/data/landing.json index cba0580..6cf297b 100644 --- a/data/landing.json +++ b/data/landing.json @@ -48,8 +48,8 @@ { "land": "terran-land-03.mp4", "surface": "terran-surface-03.mp4", "takeoff": "terran-takeoff-03.mp4", "shop": "terran-shop-03.mp4" }, { "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "gasgiant-shop-01.mp4" }, { "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "gasgiant-shop-02.mp4" }, - { "land": "gasgiant-land-03.mp4", "surface": "terran-surface-03.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "gasgiant-shop-03.mp4" }, - { "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" }, + { "land": "gasgiant-land-03.mp4", "surface": "gasgiant-surface-03.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "gasgiant-shop-03.mp4" }, + { "land": "rocky-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" }, { "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "terran-shop-01.mp4" }, { "land": "rocky-land-03.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "terran-shop-01.mp4" } ] diff --git a/data/planets.json b/data/planets.json index d7776ce..d6645d0 100644 --- a/data/planets.json +++ b/data/planets.json @@ -1,5 +1,5 @@ { - "_comment": "Planet visuals + home-planet rules + the solar system's layout. texture is a spritesheet of frameWidth×frameHeight frames (frame 0 = top-left); frames maps a planet kind to the sheet frames it may be drawn as — the generator picks one per planet (seed-deterministic). homePlanet is the player's world — always present at the origin of the system they start in. scale = world pixels per sheet pixel (1.0 = 1:1, so a Terran world is 1024 px across on screen). A planet is a solid disc: the ship may approach to shipClearance px (edge-to-edge) from its rim but can never cross it. spawnDistanceFromEdge = how far (edge-to-edge) the ship starts from the home world's rim. classScale = size multiplier per planet class (1.0 = 1024 px across); classTint = optional canvas tint per class (multiplicative — the terran art stands in for every kind until real art lands). solarSystem = the top-down LAYOUT BAND (js/galaxy/SystemGenerator.js → layoutSystem): every PAIR of layout objects — planets, free-space stations, and the central body (the star, or the home world in the starting system — at the local origin) — sits at least minSpacing and at most the maximum apart, center to center. Normal systems: [minSpacing, maxSpacing] = 6400..15360 px. The home system: [minSpacing, homeMaxSpacing] = 6400..10240 px, and it is capped at 3 objects — 5 points (home world + 4) cannot sit 6400..10240 px apart: the tightest 5-point spacing needs a max/min ratio ≥ φ ≈ 1.618 > 1.6. Normal systems lay out as a regular N-gon ring; the home system as a regular (N+1)-polygon with the home world as one vertex. The rotation biases toward the jump-gate directions (data/gates.json). minSpacing/maxSpacing/homeMaxSpacing are center-to-center px (the same unit as the 1024 px world disc).", + "_comment": "Planet visuals + home-planet rules + star rules (the central body of every non-home system) + the solar system's layout. texture is a spritesheet of frameWidth×frameHeight frames (frame 0 = top-left); frames maps a planet kind to the sheet frames it may be drawn as — the generator picks one per planet (seed-deterministic). homePlanet is the player's world — present ONLY at the origin of the system they start in (the starting system's central body); every other system's central body is its star (`star` below). scale = world pixels per sheet pixel (1.0 = 1:1, so a Terran world is 1024 px across on screen). A planet is a solid disc: the ship may approach to shipClearance px (edge-to-edge) from its rim but can never cross it. spawnDistanceFromEdge = how far (edge-to-edge) the ship starts from the home world's rim. classScale = size multiplier per planet class (1.0 = 1024 px across); classTint = optional canvas tint per class (multiplicative — the terran art stands in for every kind until real art lands). solarSystem = the top-down LAYOUT BAND (js/galaxy/SystemGenerator.js → layoutSystem): every PAIR of layout objects — planets, free-space stations, and the central body (the star, or the home world in the starting system — at the local origin) — sits at least minSpacing and at most the maximum apart, center to center. Normal systems: [minSpacing, maxSpacing] = 6400..15360 px. The home system: [minSpacing, homeMaxSpacing] = 6400..10240 px, and it is capped at 3 objects — 5 points (home world + 4) cannot sit 6400..10240 px apart: the tightest 5-point spacing needs a max/min ratio ≥ φ ≈ 1.618 > 1.6. Normal systems lay out as a regular N-gon ring; the home system as a regular (N+1)-polygon with the home world as one vertex. The rotation biases toward the jump-gate directions (data/gates.json). minSpacing/maxSpacing/homeMaxSpacing are center-to-center px (the same unit as the 1024 px world disc).", "texture": "assets/images/planets.png", "frameWidth": 1024, "frameHeight": 1024, @@ -33,6 +33,21 @@ "homePlanet": "terran", "homeName": "Terra", "homeTypeLabel": "Home World", + "star": { + "_comment": "The central body of a NON-HOME system — the system's star (content.star from the generator; class = the spectral type G/K/M/…). The starting system's central body is the home world (homePlanet) and only there — a star is drawn in every other system. size = the SOLID disc's diameter px (1024 = a Terran world; the halo is visual only, outside the keep-out). classColor = the disc's hue per spectral class (a white core melts into it, js/entities/Star.js). glow.radiusFactor = halo radius as a multiple of the disc radius; glow.alpha = halo strength at the rim.", + "size": 1536, + "classColor": { "O": "#9db4ff", "B": "#9db4ff", "A": "#cfd8ff", "F": "#fff4e0", "G": "#ffe9b0", "K": "#ffc07a", "M": "#ff8a64" }, + "glow": { "radiusFactor": 1.55, "alpha": 0.5 } + }, + "starTypeLabels": { + "O": "O-type Star", + "B": "B-type Star", + "A": "A-type Star", + "F": "F-type Star", + "G": "G-type Star", + "K": "K-type Star", + "M": "M-type Star" + }, "shipClearance": 50, "spawnDistanceFromEdge": 150, "solarSystem": { diff --git a/dev/galaxy.test.mjs b/dev/galaxy.test.mjs index ba7626d..b91f4ba 100644 --- a/dev/galaxy.test.mjs +++ b/dev/galaxy.test.mjs @@ -407,6 +407,30 @@ let big; const nonHomeRec = big2.records.find((r) => r.id !== homeRec.id); check('other systems have no home world', !('homeName' in big2.ensureContent(nonHomeRec.id))); + // REGRESSION (the "Home World in a non-home system" bug): the player's + // position moves (jumps + saves write galaxy.currentSystemId = dest), + // and content is generated LAZILY on arrival — so the isHome rule must + // key off the frozen starting system (galaxy.homeSystemId), not the + // movable currentSystemId. Move the pointer to a non-home system and + // generate its content there: it must still have no home world, while + // the starting system keeps its home world. + const visited = big2.records.find((r) => r.id !== homeRec.id); + big2.currentSystemId = visited.id; // what prepareLoad()/a jump does + check( + 'homeSystemId froze the starting system before the pointer moved', + big2.homeSystemId === homeRec.id && big2.currentSystemId !== big2.homeSystemId, + ); + check( + 'a non-home system visited after the pointer moved still has no home world', + !('homeName' in big2.ensureContent(visited.id)) && !('homeFrame' in big2.ensureContent(visited.id)), + ); + check( + 'the starting system keeps its home world regardless of the pointer', + typeof big2.ensureContent(homeRec.id).homeName === 'string' && + big2.isHomeSystem(homeRec.id) === true && + big2.isHomeSystem(visited.id) === false, + ); + // The starting system always holds exactly three planets: the home world // (the origin, not a generated planet) + a gas giant + a rocky world — // both generated worlds settled (gas ⇒ cloud base, rocky ⇒ colony or diff --git a/dev/music.test.mjs b/dev/music.test.mjs index 6ae1e14..7ef63c7 100644 --- a/dev/music.test.mjs +++ b/dev/music.test.mjs @@ -23,7 +23,7 @@ const ClassStub = class {}; globalThis.window = { Phaser: { Scene: ClassStub, - GameObjects: { Sprite: ClassStub, Container: ClassStub }, + GameObjects: { Sprite: ClassStub, Container: ClassStub, Image: ClassStub }, Physics: { Arcade: { Sprite: ClassStub } }, Geom: { Rectangle: class {} }, }, diff --git a/dev/nav-reach-probe.mjs b/dev/nav-reach-probe.mjs new file mode 100644 index 0000000..86de4e4 --- /dev/null +++ b/dev/nav-reach-probe.mjs @@ -0,0 +1,132 @@ +/** + * dev/nav-reach-probe.mjs — soft-lock probe (path-aware): which systems' + * NAV charts can NEVER be completed under the game's actual progression? + * + * node dev/nav-reach-probe.mjs [seed ...] + * + * Model (mirrors GameScene + SystemCategory): + * - To jump Y→X the player must have RESEARCHED Y's gate tech, so every + * system jumped OUT of is researched; arrival at X from Y ⇒ Y (and + * everything before it on the path, back to home) is researched. + * - On entry, X's ACTIVE gates = the gates from X to any researched + * system (those return gates were flipped by the neighbor's tech). + * - The player's room to move = disc(star, 5120) ∪ disc(activeGate, 5120) + * for each active gate. A NAV point is discoverable iff that union + * comes within (radius + 540) of the point. + * - A system is clearable iff SOME researched neighbor lets it chart. + * Clear systems stay clear (they become researched and only add more + * active gates for their neighbors). + */ +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import fs from 'node:fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); + +const manifest = JSON.parse(fs.readFileSync(join(root, 'data', 'manifest.json'), 'utf8')); +const files = {}; +for (const f of manifest.files) files[f.split('/').pop().replace('.json', '')] = JSON.parse(fs.readFileSync(join(root, 'data', f), 'utf8')); +const { config } = await import(join(root, 'js', 'config', 'Config.js')); +config.init(files); + +const { Galaxy } = await import(join(root, 'js', 'galaxy', 'Galaxy.js')); +const { navPoints } = await import(join(root, 'js', 'research', 'SystemCategory.js')); + +const TETHER = config.get('tether.level1Radius', 5120); +const DISC = config.get('game.discovery.distance', 540); +const GATE_R = config.get('gates.size', 96); +const STAR_R = config.get('planets.star.size', 1536) / 2; +const homeTether = TETHER * Math.pow(config.get('tether.radiusGrowth', 2), Math.max(1, config.get('tether.homeLevel', 1)) - 1); + +function systemOf(galaxy, id) { + const content = galaxy.ensureContent(id); + const rec = galaxy.byId.get(id); + const pts = navPoints(content).map((p) => { + let pos = { x: 0, y: 0 }; + let r = STAR_R; + if (p.kind === 'planet') { + const q = content.planets.find((x) => x.name === p.id); + pos = q; r = (1024 * (config.get(`planets.classScale.${q.class}`, 1))) / 2; + } else if (p.kind === 'gate') { + pos = content.jumps.find((j) => j.id === p.id); r = GATE_R; + } else if (p.kind === 'station') { + pos = content.settlements.find((s) => s.id === p.id); r = 108; + } + return { id: p.id, kind: p.kind, x: pos.x, y: pos.y, r }; + }); + const gates = (content.jumps ?? []).filter((j) => typeof j.x === 'number'); + return { + id, + name: rec.name, + isHome: id === galaxy.homeSystemId, + planets: content.planets.length, + stations: (content.settlements ?? []).filter((s) => s.anchor?.type === 'space').length, + gates, + asteroids: (content.asteroids ?? []).length, + pts, + neighbors: gates.map((g) => g.to), + }; +} + +function chartable(sys, researched) { + const active = sys.gates.filter((g) => researched.has(g.to)); + const discs = [{ x: 0, y: 0, r: sys.isHome ? homeTether : TETHER }, ...active.map((g) => ({ x: g.x, y: g.y, r: TETHER }))]; + const missing = sys.pts.filter((p) => { + let best = Infinity; + for (const c of discs) { + const d = Math.hypot(p.x - c.x, p.y - c.y) - c.r; + if (d < best) best = d; + } + return best > p.r + DISC; + }); + return { chartable: missing.length === 0, missing, activeCount: active.length }; +} + +const seeds = process.argv.slice(2).length ? process.argv.slice(2) : ['probe1']; +for (const seed of seeds) { + const galaxy = Galaxy.create(seed, { systemCount: 200 }); + const all = [...galaxy.byId.keys()].map((id) => systemOf(galaxy, id)); + const byId = new Map(all.map((s) => [s.id, s])); + + // Progression: BFS of clearable systems, researching each as it clears. + const researched = new Set([galaxy.homeSystemId]); + let frontier = [galaxy.homeSystemId]; + while (frontier.length) { + const next = []; + for (const id of frontier) { + for (const s of all) { + if (s.isHome || researched.has(s.id)) continue; + if (!s.neighbors.includes(id)) continue; // entry neighbor + const r2 = new Set(researched); + if (!r2.has(id)) r2.add(id); + if (chartable(s, r2).chartable) { + researched.add(s.id); + next.push(s.id); + } + } + } + frontier = next; + } + + const stuck = all.filter((s) => !s.isHome && !researched.has(s.id)); + console.log(`\n=== seed ${seed}: ${researched.size - 1}/${all.length - 1} non-home systems clearable, ${stuck.length} stuck ===`); + for (const s of stuck.slice(0, 8)) { + // best explanation: via which neighbor, which points remain missing + let best = null; + for (const n of new Set(s.neighbors)) { + if (!byId.has(n)) continue; + const r2 = new Set(researched); + if (!r2.has(n)) r2.add(n); + const c = chartable(s, r2); + if (!best || c.missing.length < best.missing.length) best = { via: n, ...c }; + } + console.log( + ` ${s.id}: planets=${s.planets} stations=${s.stations} gates=${s.gates.length} asteroids=${s.asteroids}` + + ` → best entry via ${best?.via}: missing=[${best?.missing.map((m) => m.kind).join(',')}] activeGates=${best?.activeCount}` + ); + } + // Also: systems matching "1 planet, 3 gates" (the user's shape) + const one = all.filter((s) => !s.isHome && s.planets === 1 && s.stations === 0 && s.gates.length === 3); + console.log(` systems with 1 planet + 3 gates: ${one.map((s) => `${s.id}(stuck=${!researched.has(s.id)})`).join(' ')}`); +} diff --git a/dev/star.test.mjs b/dev/star.test.mjs new file mode 100644 index 0000000..644765f --- /dev/null +++ b/dev/star.test.mjs @@ -0,0 +1,179 @@ +/** + * Star test (dev tool, run with Node — no browser needed): + * + * node dev/star.test.mjs + * + * Stubs just enough of Phaser to construct the REAL Star from + * js/entities/Star.js against the real data/planets.json config, then + * asserts the central-body rules (the star is the central body of every + * NON-HOME system — see js/scenes/GameScene.js): + * - the solid rim is half the configured star.size (1024-class worlds, + * a bigger star: 1536 px across by default); + * - the ship can come to shipClearance px (edge-to-edge) from the rim, + * but never closer, and never moves through the star; + * - a graze keeps its tangential velocity (slides along the rim); + * - edgePoint() places a point exactly `gap` off the rim; + * - one texture per spectral class (generated once, keyed star-). + */ +import { pathToFileURL, fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// --- Phaser stub: Image + Graphics + Display ------------------------------ +class Image { + constructor(scene, x, y, key, frame) { + this.scene = scene; this.x = x; this.y = y; + this.key = key; this.frame = frame; + this.scaleX = 1; this.scaleY = 1; + this.scale = 1; + } + setScale(s) { this.scaleX = s; this.scaleY = s; this.scale = s; return this; } +} +const Sprite = class extends Image {}; +class Graphics { + fillStyle() { return this; } + fillCircle() { return this; } + generateTexture(key, w, h) { made.push({ key, w, h }); return this; } + destroy() { this.destroyed = true; } +} +function hexToRgbInt(v) { + const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i); + return m ? parseInt(m[1], 16) : 0xffffff; +} +const made = []; // generateTexture calls +globalThis.window = { + Phaser: { + GameObjects: { Image, Sprite }, + Display: { Color: { ValueToColor: (v) => ({ color: hexToRgbInt(v) }) } }, + }, +}; + +// --- Load the real config (data/*.json) into the config singleton -------- +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); +console.log('config loaded from data/:', Object.keys(configData).join(', ')); + +let failures = 0; +const check = (label, cond) => { + console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); + if (!cond) failures++; +}; + +const { Star } = await import(pathToFileURL(join(__dirname, '../js/entities/Star.js')).href); + +// A fake scene: textures.exists + make.graphics + add.existing. +const scene = { + add: { existing: (o) => o }, + textures: { exists: (k) => made.some((t) => t.key === k) }, + make: { graphics: ({ add } = {}) => new Graphics() }, +}; + +// --- 1. Real config: solid rim + keep-out distances ----------------------- +// Off-origin on purpose: proves nothing is baked to (0, 0). +const star = new Star(scene, 100, -50, { name: 'Kestrel', class: 'k' }); +const wantSize = config.get('planets.star.size', 1536); +const wantRim = wantSize / 2; + +check(`star rim is half star.size (got ${star.radius}, want ${wantRim})`, star.radius === wantRim); +check(`default star is 1536 px across (got ${star.size})`, star.size === 1536); +check(`texture key is per-class (got "${star.key}", want "star-k")`, star.key === 'star-k'); +check(`class is normalized to uppercase (got "${star.starClass}")`, star.starClass === 'K'); + +const shipRadius = (config.get('ship.size', 46) * config.get('ship.scale', 1)) / 2; +const clearance = star.clearance; +const minDist = star.minCenterDistance(shipRadius); +const ship = (x, y, vx = 0, vy = 0) => ({ x, y, body: { velocity: { x: vx, y: vy }, acceleration: { x: 0, y: 0 } } }); +const dist = (p) => Math.hypot(p.x - star.x, p.y - star.y); + +check(`keep-out = rim + clearance + ship radius (got ${minDist}, want ${wantRim + clearance + shipRadius})`, + minDist === wantRim + clearance + shipRadius); + +// --- 2. Outside the keep-out circle: untouched ---------------------------- +{ + const s = ship(star.x + minDist + 10, star.y); + star.constrainShip(s, shipRadius); + check('outside the keep-out circle: ship untouched', + s.x === star.x + minDist + 10 && s.y === star.y); +} + +// Exactly on the boundary: allowed. +{ + const s = ship(star.x + minDist, star.y, -100, 0); // on the line, moving in + star.constrainShip(s, shipRadius); + check('on the boundary with inward velocity: position held, inward velocity removed', + dist(s) === minDist && s.body.velocity.x >= 0); +} + +// Deep inside: pushed out to the rim line. +{ + const s = ship(star.x + 10, star.y, -200, 0); + star.constrainShip(s, shipRadius); + check('inside the keep-out circle: pushed out to the rim line', + Math.abs(dist(s) - minDist) < 1e-9); +} + +// A graze on the rim keeps its tangential velocity (slides along the rim), +// and the inward part of any velocity is stripped. +{ + const s = ship(star.x + minDist, star.y, -50, 300); // on the line, partly along the rim + star.constrainShip(s, shipRadius); + check('graze: tangential velocity kept, inward part stripped (got vx=' + s.body.velocity.x + ', vy=' + s.body.velocity.y + ')', + s.body.velocity.x === 0 && s.body.velocity.y === 300 && dist(s) === minDist); +} + +// Dead center: pushed out along +x (deterministic). +{ + const s = ship(star.x, star.y, 0, 0); + star.constrainShip(s, shipRadius); + check('dead center: pushed out along +x', s.y === star.y && s.x > star.x); +} + +// --- 3. edgePoint / aimPoint ---------------------------------------------- +{ + const p = star.edgePoint(0.7, 150, shipRadius); + check(`edgePoint sits rim + gap + ship off the rim (got ${dist(p).toFixed(3)})`, + Math.abs(dist(p) - (wantRim + 150 + shipRadius)) < 1e-9); +} +{ + const inside = { x: star.x + 10, y: star.y }; + const out = star.aimPoint(inside.x, inside.y, shipRadius); + check(`aimPoint projects inside points onto the rim (got ${dist(out).toFixed(3)})`, + Math.abs(dist(out) - minDist) < 1e-9); + const pas = star.aimPoint(star.x + minDist + 5, star.y, shipRadius); + check('aimPoint passes outside points through unchanged', + pas.x === star.x + minDist + 5 && pas.y === star.y); +} + +// --- 4. Textures: one per spectral class, generated once ------------------ +{ + const before = made.length; + new Star(scene, 0, 0, { name: 'Sol', class: 'G' }); // star-g: fresh + check('new class generates its texture (star-g present)', made.some((t) => t.key === 'star-g')); + new Star(scene, 0, 0, { name: 'Betelgeuse', class: 'M' }); // star-m: fresh + new Star(scene, 0, 0, { name: 'Kestrel', class: 'k' }); // star-k: already made + const k = made.filter((t) => t.key === 'star-k').length; + check('existing class reuses its texture (star-k generated exactly once, got ' + k + ')', k === 1); + check('texture size = rim × 2 × glowFactor', + made.filter((t) => t.key === 'star-m')[0].w === Math.ceil(wantRim * config.get('planets.star.glow.radiusFactor', 1.55)) * 2); + check(`texture generation calls total ${before + 2} (star-g + star-m)`, made.length === before + 2); +} + +// Unknown spectral class: no crash, its own texture with the G fallback hue. +{ + const s = new Star(scene, 0, 0, { name: 'X', class: 'zzz' }); + check('unknown class gets its own texture (star-zzz, G fallback hue)', + s.key === 'star-zzz' && made.some((t) => t.key === 'star-zzz')); + const s2 = new Star(scene, 0, 0, { name: 'Y', class: undefined }); + check('missing class defaults to G (got "' + s2.key + '")', s2.key === 'star-g'); +} + +console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`); +process.exit(failures === 0 ? 0 : 1); diff --git a/dev/system-hud.test.mjs b/dev/system-hud.test.mjs index 1589463..359a4b9 100644 --- a/dev/system-hud.test.mjs +++ b/dev/system-hud.test.mjs @@ -39,7 +39,7 @@ const ClassStub = class {}; const PhaserStub = { Scene: ClassStub, Physics: { Arcade: { Sprite: ClassStub } }, - GameObjects: { Sprite: ClassStub, Container: ClassStub }, + GameObjects: { Sprite: ClassStub, Container: ClassStub, Image: ClassStub }, Geom: { Rectangle: class {} }, Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } }, Math: { diff --git a/dev/tether-newworld.html b/dev/tether-newworld.html new file mode 100644 index 0000000..7babf5e --- /dev/null +++ b/dev/tether-newworld.html @@ -0,0 +1,17 @@ + + + + + Orbit — dev tether-l1 new-world build flow test + + + + + +
+ + + diff --git a/dev/tether-newworld.mjs b/dev/tether-newworld.mjs new file mode 100644 index 0000000..cad386a --- /dev/null +++ b/dev/tether-newworld.mjs @@ -0,0 +1,138 @@ +/** + * Dev-only: TETHER-L1 BUILD FLOW ON A NEWLY DISCOVERED WORLD — the bug + * report: land on a world with NO tether, build Tether - Level 1, and the + * world should actually GAIN a level-1 tether (range 5120 px anchored on + * its center), show "Tether Level 1", and then accept Tether - Level 2 + * (which upgrades the SAME tether to 10240 px). Also checks the + * re-materialization: a scene rebuild (jump cut / restart) that empties + * the field to its native set must re-anchor the installed tether on its + * world (js/scenes/GameScene.js → _rematerializeBuiltTethers). + * + * node dev/server.mjs 8080 + * node dev/cdp-firefox.mjs http://127.0.0.1:8080/dev/tether-newworld.html \ + * 'return window.__TETHER_NEWWORLD__;' 60000 + */ +import Phaser from '../js/vendor/phaser.js'; +import { config } from '../js/config/Config.js'; +import { ConfigLoader } from '../js/config/ConfigLoader.js'; +import { createGameConfig } from '../js/config/GameConfig.js'; +import { MenuScene } from '../js/scenes/MenuScene.js'; +import { GameScene } from '../js/scenes/GameScene.js'; +import { SurfaceScene } from '../js/scenes/SurfaceScene.js'; + +const data = await ConfigLoader.load(); +config.init(data); + +const errors = []; +const origWarn = console.warn.bind(console); +console.warn = (...a) => { errors.push(a.map(String).join(' ')); origWarn(...a); }; +const origErr = console.error.bind(console); +console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); }; +window.addEventListener('error', (e) => errors.push(String(e.message))); +window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`)); + +const game = new Phaser.Game({ ...createGameConfig(), scene: [MenuScene, GameScene, SurfaceScene] }); +window.game = game; + +const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms)); +async function waitFor(fn, tries = 200, everyMs = 100) { + for (let i = 0; i < tries; i++) { + const v = fn(); + if (v) return v; + await sleepMs(everyMs); + } + return null; +} + +const tetherFor = (gs, name) => + gs.tetherField?.tethers?.find((t) => t.label === name) ?? null; +const tetherInfo = (t) => (t ? { id: t.id, x: t.x, y: t.y, level: t.level, radius: t.radius } : null); + +try { + // 1) Menu → NEW GAME (the real entry). + const menu = await waitFor(() => game.scene.getScene('MenuScene')?.newGameBtn ? game.scene.getScene('MenuScene') : null); + if (!menu) throw new Error('menu never booted'); + menu.startNewGame(); + const gs = await waitFor(() => { const g = game.scene.getScene('GameScene'); return g?.ship && g?.buildState && g?.tetherField ? g : null; }, 300); + if (!gs) throw new Error('GameScene never booted'); + await sleepMs(500); + + // 2) Pick a NON-HOME world in the starting system (the bug's setting: + // a newly discovered world, which has no tether of its own). + const other = gs.systemPlanets?.[0]; + if (!other) throw new Error('starting system has no non-home planet to test on'); + const name = other.discoveryName; + const before = tetherFor(gs, name); + if (before) throw new Error(`world "${name}" unexpectedly has a tether at start`); + + // 3) Land on it the game's own way, wait for the surface stage. + gs.startLanding(gs.commsTargetFor(other)); + const ss = await waitFor(() => { const c = game.scene.getScene('SurfaceScene'); return c?.phase === 'surface' ? c : null; }, 150, 200); + if (!ss) throw new Error('surface stage never reached'); + await sleepMs(300); + + // 4) The build console's own rule pass agrees: L2 is gated on L1. + const l2GateBefore = gs.tetherLevelFor(name); + if (l2GateBefore !== 0) throw new Error(`tetherLevelFor("${name}") = ${l2GateBefore}, want 0 before the L1 build`); + + // 5) Build Tether - Level 1 (free + instant per data/builds.json). + const res1 = gs.beginBuild(ss.planetName, 'tether-l1', game.loop.now); + if (!res1.ok) throw new Error(`L1 build refused: ${res1.reason}`); + // The surface's own tick completes it (duration 0 → 1 ms) and applies + // the effect on the game scene; wait for the built record. + await waitFor(() => gs.buildState.isBuilt(ss.planetName, 'tether-l1'), 60, 50); + if (!gs.buildState.isBuilt(ss.planetName, 'tether-l1')) throw new Error('L1 build never completed'); + await sleepMs(1500); // HUD re-decode + window repaint settle + + const l1 = tetherFor(gs, name); + if (!l1) throw new Error(`no tether anchored on "${name}" after the L1 build (the bug)`); + if (l1.level !== 1) throw new Error(`L1 tether level = ${l1.level}, want 1`); + if (l1.radius !== 5120) throw new Error(`L1 tether radius = ${l1.radius}, want 5120`); + if (l1.x !== other.x || l1.y !== other.y) throw new Error(`L1 tether anchored at (${l1.x},${l1.y}), want the world's center (${other.x},${other.y})`); + if (gs.tetherLevelFor(name) !== 1) throw new Error(`tetherLevelFor("${name}") = ${gs.tetherLevelFor(name)}, want 1 after the L1 build`); + if (ss.tetherLevel !== 1) throw new Error(`surface HUD level = ${ss.tetherLevel}, want 1 after the L1 build`); + + // 6) Now L2 must be ACCEPTED on this world (research gate + minerals). + gs.researchState.unlock('exploration', 'tether_l2'); + gs.ship.setMinerals(200); + const res2 = gs.beginBuild(ss.planetName, 'tether-l2', game.loop.now); + if (!res2.ok) throw new Error(`L2 build refused after L1 installed: ${res2.reason}`); + const active2 = gs.buildState.getActive(); + if (active2) active2.startedAt -= active2.durationMs; // fast-forward the 20 s + await waitFor(() => gs.buildState.isBuilt(ss.planetName, 'tether-l2'), 60, 50); + if (!gs.buildState.isBuilt(ss.planetName, 'tether-l2')) throw new Error('L2 build never completed'); + await sleepMs(1500); + + const l2 = tetherFor(gs, name); + if (!l2) throw new Error('tether vanished after the L2 build'); + if (l2.id !== l1.id) throw new Error(`L2 upgraded a DIFFERENT tether (${l2.id} vs ${l1.id}) — want the same anchor strengthened`); + if (l2.level !== 2) throw new Error(`L2 tether level = ${l2.level}, want 2`); + if (l2.radius !== 10240) throw new Error(`L2 tether radius = ${l2.radius}, want 10240`); + if (gs.tetherLevelFor(name) !== 2) throw new Error(`tetherLevelFor("${name}") = ${gs.tetherLevelFor(name)}, want 2 after the L2 build`); + + // 7) Re-materialization: a scene rebuild empties the field to its + // native set (home + activated gates). The installed tether is world + // state — it must come back on its world at its installed level. + gs.tetherField.remove(l1.id); + if (tetherFor(gs, name)) throw new Error('removal failed (test premise)'); + gs._rematerializeBuiltTethers(); + const re = tetherFor(gs, name); + if (!re) throw new Error('re-materialization did NOT re-anchor the installed tether'); + if (re.level !== 2) throw new Error(`re-materialized tether level = ${re.level}, want 2 (the installed max)`); + if (re.x !== other.x || re.y !== other.y) throw new Error('re-materialized tether anchored on the wrong world'); + + const out = { + world: name, + l1: tetherInfo(l1), + l2: tetherInfo(l2), + rematerialized: tetherInfo(re), + hudLine: ss.hudLine ? ss.hudLine.text : null, + built: [gs.buildState.buildsOn(ss.planetName)], + errors: errors.slice(0, 5), + }; + window.__TETHER_NEWWORLD__ = out; + console.info('tether-newworld:', JSON.stringify(out)); +} catch (err) { + window.__TETHER_NEWWORLD__ = { fatal: String(err.message), errors: errors.slice(0, 5) }; + console.error('tether-newworld: fatal', err); +} diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index bf7ba84..42c5a4f 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -229,9 +229,12 @@ world** — solid, rendered, flyable-to. Rules and seams: testable, save-ready: `toJSON()`/`fromJSON()`). Rule: the ship within `game.discovery.distance` (data/game.json, default 540 px) of an object's *edge* (center distance ≤ radius + distance) discovers it, - once, per system. Home world is discovered at spawn (the ship starts - beside it). Feedback: rim ping + "DISCOVERED — NAME · TYPE" toast - (GameScene.celebrateDiscovery). + once, per system. The central body is discovered at spawn in EVERY + system: the home world (the ship starts beside it) in the starting + system — "Home World" exists in exactly one system — and the system's + STAR ("G-type Star" etc., `js/entities/Star.js`) in every other one; + both read the chart's central NAV point (id 'home'). Feedback: rim + ping + "DISCOVERED — NAME · TYPE" toast (GameScene.celebrateDiscovery). - **Compass** — `js/ui/DiscoveryCompass.js` (screen-space Container, scrollFactor 0). Every frame it refreshes the set of **discovered** objects that are **off-screen**, drawing a themed chevron arrow on the @@ -239,9 +242,11 @@ world** — solid, rendered, flyable-to. Rules and seams: Edge-anchor geometry (`edgeAnchor`, `circleInView`, `lerpAngle`) is exported pure for tests. Arrow texture is generated procedurally on first use (`__compass_arrow`). -- **World solids** — the ship collides with *every* system planet - (`GameScene.solidPlanets`), not just home: you can approach a rim, - never pass through. +- **World solids** — the ship collides with *every* solid in the system + (`GameScene.solids`): the central body (the home world in the starting + system, the star elsewhere — both the 'home' NAV point), all system + planets, asteroid clusters, stations and jump gates: you can approach + a rim, never pass through. - Verified: `dev/discovery.test.mjs` (rule, boundaries, per-system state, JSON round-trip, layout band + determinism, compass geometry). diff --git a/js/dev/NavDiag.js b/js/dev/NavDiag.js index ea86f77..b54dfb7 100644 --- a/js/dev/NavDiag.js +++ b/js/dev/NavDiag.js @@ -28,7 +28,7 @@ import { navChart } from '../research/SystemCategory.js'; export const NAV_DIAG_V = 1; const KIND_LABEL = { - home: 'home world', + home: 'central body', planet: 'planet', station: 'space station', gate: 'jump gate', diff --git a/js/entities/Star.js b/js/entities/Star.js new file mode 100644 index 0000000..8c64b48 --- /dev/null +++ b/js/entities/Star.js @@ -0,0 +1,148 @@ +import Phaser from '../vendor/phaser.js'; +import { config } from '../config/Config.js'; +import { toColor } from '../utils/Color.js'; +import { Planet } from './Planet.js'; + +/** + * A STAR — the central body of every NON-HOME system (content.star from + * the generator: name + spectral class). The starting system's central + * body is the player's home world (js/entities/Planet.js) and only there + * — "Home World" exists in exactly one system in the galaxy. + * + * Solid like a planet: the keep-out circle is the disc rim (half the + * configured size — the halo is visual only), the ship keeps + * `planets.shipClearance` px (edge-to-edge) off it and can never move + * through it (the same plain-circle rule, GameScene.solids). It is + * discoverable (the 'home' NAV point of the chart — every system has a + * central body) but NOT a comms target: there is no surface to land on. + * + * The look is procedural (data/planets.json → star): an opaque body — + * a white core melting into the spectral class's hue — plus a soft halo, + * band-filled with Graphics (same pattern as js/visuals/Starfield.js, so + * no canvas API and no art asset). One texture per spectral class, + * generated on first use. + */ +export class Star extends Phaser.GameObjects.Image { + /** + * @param {Phaser.Scene} scene + * @param {number} x — world x of the star's center + * @param {number} y — world y + * @param {object} [star={}] — content.star ({ name, class }) + */ + constructor(scene, x, y, star = {}) { + const cls = String(star?.class ?? 'G').toUpperCase(); + const key = `star-${cls.toLowerCase()}`; + if (!scene.textures.exists(key)) Star.makeTexture(scene, key, cls); + super(scene, x, y, key); + scene.add.existing(this); + + this.starClass = cls; + this.name = star?.name ?? ''; // display name (the scene stamps discoveryName) + + // The solid disc: diameter from data/planets.json → star.size (the + // halo extends past the rim but is not part of the keep-out). + this.size = Math.max(64, Number(config.get('planets.star.size', 1536)) || 1536); + this.setScale(1); + this.radius = this.size / 2; + // Edge-to-edge gap the ship may close in on the rim (never less). + this.clearance = config.get('planets.shipClearance', 50); + } + + /** Minimum allowed center-to-center distance for a ship of `shipRadius`. */ + minCenterDistance(shipRadius = 0) { + return this.radius + this.clearance + shipRadius; + } + + /** + * A world point `gap` px (edge-to-edge) off this star's rim at `angle` + * radians — e.g. a spawn or approach point. + */ + edgePoint(angle, gap, shipRadius = 0) { + const d = this.radius + gap + shipRadius; + return { x: this.x + Math.cos(angle) * d, y: this.y + Math.sin(angle) * d }; + } + + /** + * A world point the ship may be sent to: a point inside the keep-out + * circle (e.g. a click on the star itself, or through it) is projected + * out onto the rim, along the ray from the center — so the ship always + * has a reachable destination and is never told to go inside. + */ + aimPoint(wx, wy, shipRadius = 0) { + const minDist = this.minCenterDistance(shipRadius); + const dx = wx - this.x; + const dy = wy - this.y; + const dist = Math.hypot(dx, dy); + if (dist >= minDist) return { x: wx, y: wy }; + if (dist === 0) return { x: this.x + minDist, y: this.y }; // dead center: +x + return { x: this.x + (dx / dist) * minDist, y: this.y + (dy / dist) * minDist }; + } + + /** + * Keep a ship out of the star: same plain-circle rule as Planet — + * push the center out to `minCenterDistance`, strip the inward part of + * velocity and acceleration (the tangential part slides along the rim). + */ + constrainShip(ship, shipRadius = 0) { + const body = ship.body; + const r = Planet.resolve( + this.x, this.y, this.minCenterDistance(shipRadius), + ship.x, ship.y, + body.velocity.x, body.velocity.y, + body.acceleration ? body.acceleration.x : 0, + body.acceleration ? body.acceleration.y : 0, + ); + ship.x = r.x; + ship.y = r.y; + body.velocity.x = r.vx; + body.velocity.y = r.vy; + if (body.acceleration) { + body.acceleration.x = r.ax; + body.acceleration.y = r.ay; + } + } + + /** + * The star's texture for one spectral class: an opaque body (a white + * core melting into the class hue, data/planets.json → + * star.classColor) + a soft halo (star.glow). Band-filled circles, + * outer → inner, via Graphics — no canvas API, one texture per class. + */ + static makeTexture(scene, key, cls) { + const coreR = Math.max(32, Math.floor(config.get('planets.star.size', 1536) / 2)); + const glowFactor = Math.max(1, Number(config.get('planets.star.glow.radiusFactor', 1.55)) || 1.55); + const glowAlpha = Math.min(1, Math.max(0, Number(config.get('planets.star.glow.alpha', 0.5)) || 0)); + const color = toColor(config.get(`planets.star.classColor.${cls}`, '#ffe9b0')); + + const R = Math.ceil(coreR * glowFactor); + const T = Math.ceil(R * 2); + const c = T / 2; + + // Blend the class hue toward white (t = 1 → white). + const mix = (t) => { + const ch = (sh) => { + const v = (color >> sh) & 255; + return Math.round(v + (255 - v) * t); + }; + return (ch(16) << 16) | (ch(8) << 8) | ch(0); + }; + + const g = scene.make.graphics({ add: false }); + // Halo: rim → edge, alpha growing to the rim (0 at the edge). + const H = 14; + for (let i = 0; i < H; i++) { + const t = i / (H - 1); // 0 = edge, 1 = rim + g.fillStyle(color, glowAlpha * t * t); + g.fillCircle(c, c, R - (R - coreR) * t); + } + // Body: rim → core, opaque; the hue melts into a white core. + const B = 24; + for (let i = 0; i < B; i++) { + const t = i / (B - 1); // 0 = rim, 1 = core + g.fillStyle(mix(Math.pow(t, 1.4)), 1); + g.fillCircle(c, c, coreR * (1 - t * 0.999)); + } + g.generateTexture(key, T, T); + g.destroy(); + } +} diff --git a/js/galaxy/Galaxy.js b/js/galaxy/Galaxy.js index ab79828..fbf59c5 100644 --- a/js/galaxy/Galaxy.js +++ b/js/galaxy/Galaxy.js @@ -51,6 +51,14 @@ export class Galaxy { this.byId = new Map(); this.contentCache = new Map(); this.currentSystemId = null; + // The STARTING system — the player's home port. Set once at roster + // build and never moves: `currentSystemId` tracks where the player IS + // NOW (jumps + saves move it), but the home world always belongs to + // the starting system (SystemGenerator's isHome rule, GameScene's + // home/star split). Without this split, lazily generated content for + // a newly arrived system would compute isHome = true (currentSystemId + // had already moved there) and deal it a home world of its own. + this.homeSystemId = null; this.planetFrames = new Map(); // id → [frame, ...] — the frame-diversity pass this.homeWorldFrame = null; // the starting system's home world frame this.name = NameGenerator.galaxy(Rng.derive(seed, 'galaxy', 'name')); @@ -150,6 +158,9 @@ export class Galaxy { const policy = this.params.startingSystem?.policy ?? 'center'; this.currentSystemId = policy === 'random' ? `S${String(g.int(1, count)).padStart(6, '0')}` : (centerId ?? records[0]?.id); + // Frozen copy of the starting system — `currentSystemId` will track + // the player from here on (jumps, saves); the home rules must not. + this.homeSystemId = this.currentSystemId; // Spatial hash for fast neighbor queries (jump ranges, proximity rules, // the eventual star map). @@ -303,6 +314,13 @@ export class Galaxy { return this.byId.get(this.currentSystemId) ?? this.records[0]; } + /** True when the system is the player's STARTING one — the only system + * whose central body is the home world (a star, in every other). Stable + * across jumps, unlike a `currentSystemId` comparison. */ + isHomeSystem(id) { + return id === this.homeSystemId; + } + // ------------------------------------------------------------------ // Contents (level 2, lazy) // ------------------------------------------------------------------ diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js index b88571d..c51a430 100644 --- a/js/galaxy/SystemGenerator.js +++ b/js/galaxy/SystemGenerator.js @@ -134,7 +134,7 @@ export function generateSystemContent(galaxy, record, typeDefs = null) { // density gradient, ≤ 2), the planets fill the rest: N − stations. // The roll lives on dedicated forks (rollSystemComposition), so the // galaxy-wide frame pass can reproduce it exactly. - const isHome = record.id === galaxy?.currentSystemId; + const isHome = record.id === galaxy?.homeSystemId; const spec = attr.settlements ?? {}; const composition = rollSystemComposition( galaxy.seed, record, isHome, spec, settlementDensity(galaxy, record), attr, @@ -719,7 +719,7 @@ function generateAsteroidClusters(galaxy, record, planets, freeSpace, jumps = [] const clusterCfg = cfg.cluster ?? {}; const dist = cfg.distribution ?? {}; const placement = cfg.placement ?? {}; - const isHome = record.id === galaxy?.currentSystemId; + const isHome = record.id === galaxy?.homeSystemId; // --- How many clusters: the inverse of the planet count --------------- const target = dist.targetObjects ?? 9; diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index a63e31c..5599212 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -13,6 +13,7 @@ import { playSfxOn, sfxPlayingOn, stopSfxOn } from '../utils/Sfx.js'; import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js'; import { Ship } from '../entities/Ship.js'; import { Planet } from '../entities/Planet.js'; +import { Star } from '../entities/Star.js'; import { AsteroidCluster } from '../entities/AsteroidCluster.js'; import { Station } from '../entities/Station.js'; import { JumpGate } from '../entities/JumpGate.js'; @@ -47,7 +48,7 @@ import { } from '../research/SystemCategory.js'; import { BuildWindow } from '../ui/BuildWindow.js'; import { BuildState } from '../build/BuildState.js'; -import { startingPairs } from '../build/BuildModel.js'; +import { startingPairs, defById } from '../build/BuildModel.js'; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; const HEADER_FONT = () => fontStack('header', FONT_FALLBACK); @@ -72,9 +73,10 @@ const HUD_EXPAND_LEAD = 120; // ms before re-opened details start streaming in const HUD_AUTO_COLLAPSE_MS = 10000; /** - * The game world (v0.3: the current system — the player's home world at the - * origin plus the system's other worlds scattered around it, in open space). - * Click anywhere to fly there. + * The game world (v0.3: the current system — the central body at the + * origin (the player's home world in the starting system, the system's + * star in every other one) plus the system's other worlds scattered + * around it, in open space). Click anywhere to fly there. * * Discovery: come within discovery distance (data/game.json) of a world's * edge and it is DISCOVERED (state in this.discovery). Discovered worlds @@ -271,31 +273,54 @@ export class GameScene extends Phaser.Scene { for (const j of this.systemContent.jumps ?? []) { if (j && this.activatedGates.has(`${this.systemRecord.id}>${j.to}`)) j.active = true; } - // The home world's name — dealt from the planet name bank by the - // generator (the starting system is the only one with a home world), so - // it reads as a real place rather than a generic "Terra". Fallback keeps - // the old configured name if the bank is ever unavailable. - this.homeWorldName = this.systemContent.homeName || config.get('planets.homeName', 'Terra'); + // The CENTRAL BODY — one per system, always at the origin, always the + // system's 'home' NAV point (js/research/SystemCategory.js → navPoints): + // in the STARTING system it is the player's home world (the generator + // deals its name — content.homeName — so it reads as a real place + // rather than a generic "Terra"; the fallback keeps the old configured + // name if the bank is ever unavailable); in every OTHER system it is + // the system's STAR (content.star — name + spectral class). The home + // world is the ONLY central body that is a world: only here is it a + // comms target (settled, landable) and only here does it read "Home + // World" — a star is not a comms object, and never "home". + // NOTE: home is the STARTING system (galaxy.homeSystemId) — stable + // across jumps. Comparing against galaxy.currentSystemId would be + // wrong: that pointer tracks where the player IS NOW, so every + // freshly-arrived system would read as home. + this.isHomeSystem = this.galaxy.isHomeSystem(this.systemRecord.id); + this.homeWorldName = this.isHomeSystem + ? (this.systemContent.homeName || config.get('planets.homeName', 'Terra')) + : null; this.createSystemHud(); - // The home planet — the player's Terran world, always present in the - // system they start in. It sits at the world origin. Which Terran face - // it shows comes from the galaxy-wide frame pass (content.homeFrame — - // the same (class, face) spreading as the planets), falling back to a - // seed-deterministic pool pick, so the same galaxy always yields the - // same home world. - const homeName = config.get('planets.homePlanet', 'terran'); - const homeRng = Rng.derive(this.galaxy.seed, 'planet', 'home'); - const homeFrame = - typeof this.systemContent.homeFrame === 'number' - ? this.systemContent.homeFrame - : Planet.frameFor(homeName, homeRng); - this.planet = new Planet(this, 0, 0, homeFrame, homeName); + if (this.isHomeSystem) { + // The home planet — the player's Terran world, present ONLY in the + // system the player starts in. It sits at the world origin. Which + // Terran face it shows comes from the galaxy-wide frame pass + // (content.homeFrame — the same (class, face) spreading as the + // planets), falling back to a seed-deterministic pool pick, so the + // same galaxy always yields the same home world. + const homeName = config.get('planets.homePlanet', 'terran'); + const homeRng = Rng.derive(this.galaxy.seed, 'planet', 'home'); + const homeFrame = + typeof this.systemContent.homeFrame === 'number' + ? this.systemContent.homeFrame + : Planet.frameFor(homeName, homeRng); + this.planet = new Planet(this, 0, 0, homeFrame, homeName); + this.planet.discoveryName = this.homeWorldName; + } else { + // The system's star — the central body of every other system + // (content.star from the generator: name + spectral class). + const star = this.systemContent.star ?? {}; + this.planet = new Star(this, 0, 0, star); + this.planet.discoveryName = star.name ?? 'Star'; + } this.planet.setDepth(5); // above the starfield (depths 0–2), below the ship (10) // The rest of the solar system — the generated worlds, placed by the // generator (data/planets.json → solarSystem) on orbits around the - // home world. Same solid-disc rules as home: fly close, never through. + // central body. Same solid-disc rules as the worlds: fly close, + // never through. this.systemPlanets = []; if (config.get('planets.solarSystem.enabled', true)) { for (const rec of this.systemContent.planets ?? []) { @@ -369,8 +394,10 @@ export class GameScene extends Phaser.Scene { // Ship constraint, click-to-fly clamping and autopilot all run // against this list. this.solids = [this.planet, ...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations, ...this.systemGates]; + // The central body's discovery id is 'home' in EVERY system — it is + // the chart's central NAV point (SystemCategory.navPoints); its NAME + // differs: the home world in the starting system, the star elsewhere. this.planet.discoveryId = 'home'; - this.planet.discoveryName = this.homeWorldName; // The ship — a short hop (~150 px, edge-to-edge) from the home world's // rim, in a seed-derived direction: same galaxy ⇒ same start. @@ -410,9 +437,9 @@ export class GameScene extends Phaser.Scene { }); this.tetherField.add( config.get('tether.homeId', 'home'), - 0, 0, // home world's center (it sits at the system origin) + 0, 0, // the central body's center (home world or star — the system origin) config.get('tether.homeLevel', 1), - config.get('tether.homeLabel', '') || this.homeWorldName, + config.get('tether.homeLabel', '') || (this.isHomeSystem ? this.homeWorldName : this.planet.discoveryName), ); // Each activated gate anchors its tether (data/gates.json → ACTIVITY: // an active gate is a TETHER ANCHOR in its own right — the room to @@ -606,6 +633,12 @@ export class GameScene extends Phaser.Scene { this.applyRestore(this._pendingRestore); this._pendingRestore = null; } + // The field's native set (home + the activated gates) is above; the + // tethers the player INSTALLED on this system's other worlds are + // world state (built records) — re-anchor them on their worlds, since + // a jump cut / scene restart rebuilds the field to the native set + // only (idempotent: existing tethers are only ever strengthened). + this._rematerializeBuiltTethers(); // Entering a system charts it: the SYSTEM category's '{System} Map' // tech (duration 0) is owned on arrival — a new run gets the starting @@ -1303,13 +1336,18 @@ export class GameScene extends Phaser.Scene { /** The discoverable objects of the system, with compass metadata. */ discoverableObjects() { const out = []; + // The central body — the player's home world in the starting system, + // the system's star in every other one (discovery id 'home' either + // way: the chart's central NAV point). out.push({ id: 'home', x: this.planet.x, y: this.planet.y, radius: this.planet.radius, - typeLabel: config.get('planets.homeTypeLabel', 'Home World'), - name: this.homeWorldName, + typeLabel: this.isHomeSystem + ? config.get('planets.homeTypeLabel', 'Home World') + : config.get(`planets.starTypeLabels.${this.systemContent.star?.class}`, 'Star'), + name: this.planet.discoveryName, }); for (const p of this.systemPlanets) { out.push({ @@ -1471,7 +1509,9 @@ export class GameScene extends Phaser.Scene { worldObjectAt(wx, wy) { let best = null; let bestD = Infinity; - for (const s of [this.planet, ...this.systemPlanets, ...this.systemStations]) { + // The central body is a comms target only when it IS the home world — + // the star (every other system) is not a comms object (like a gate). + for (const s of [...(this.isHomeSystem ? [this.planet] : []), ...this.systemPlanets, ...this.systemStations]) { const r = s.radius + (s.clearance ?? 0); const d = Math.hypot(wx - s.x, wy - s.y); if (d <= r && d < bestD) { @@ -1711,7 +1751,7 @@ export class GameScene extends Phaser.Scene { let key = null; let settled = false; let kindLabel = ''; - if (obj === this.planet) { + if (obj === this.planet && this.isHomeSystem) { // The home world: standing pinned at the scale's top. key = HOME_KEY; settled = true; @@ -2318,8 +2358,20 @@ export class GameScene extends Phaser.Scene { glyphColor: toCss(themeColor('neon', 0x00e5ff)), }); } + } else if (obj && this.tetherField) { + // The world's FIRST tether (the tether-l1 build on a newly + // discovered world): anchor one on the world's center — the + // player-owned anchor model (the field is the player's, the + // world hosts it). Label = the world's name, so + // tetherLevelFor / the surface HUD / the build gates see it, + // and the save's tether list carries it with the run. + this.tetherField.add(`planet:${planetName}`, obj.x, obj.y, lvl, planetName); + this.consoleToast(`TETHER ANCHORED — LEVEL ${lvl}`, { + glyph: '⌖', + glyphColor: toCss(themeColor('neon', 0x00e5ff)), + }); } else { - console.warn(`[orbit] build: no tether on "${planetName}" to strengthen`, spec); + console.warn(`[orbit] build: world "${planetName}" not found — cannot anchor its tether`, spec); } } else if (type === 'capability' && typeof spec === 'string') { this.researchCapabilities = this.researchCapabilities ?? new Set(); @@ -2602,11 +2654,52 @@ export class GameScene extends Phaser.Scene { */ _seedStartingBuilds() { for (const [planet, build] of startingPairs()) { - const name = planet === 'home' ? this.planet?.discoveryName : planet; + // 'home' is the player's home world — the starting system's central + // body only. In every other system the central body is the star, + // whose origin tether is the scene's native set (added in create), + // not a build — so the seed does not apply there. + const name = planet === 'home' + ? (this.isHomeSystem ? this.planet?.discoveryName : null) + : planet; if (name) this.buildState.markBuilt(name, build); } } + /** + * Re-anchor the tethers the player has INSTALLED on this system's + * worlds (a build's `effects.tether`, data/builds.json) whenever the + * field is rebuilt to its native set — a jump cut stages an empty + * tether list (rec.tethers = []) and the scene restart re-forms home + + * the activated gates only, while the installed tethers (and their + * built records) outlive the cut. Idempotent: the installed level is + * the MAX of the world's tether builds (level-1 + level-2 ⇒ level 2); + * an existing tether is only ever strengthened, never created twice or + * downgraded. + */ + _rematerializeBuiltTethers() { + if (!this.buildState || !this.tetherField) return; + for (const [planetName, builds] of this.buildState.built.entries()) { + const world = + this.systemPlanets.find((p) => p.discoveryName === planetName) ?? + (planetName === this.planet?.discoveryName ? this.planet : null); + if (!world) continue; // another system's world — re-anchors on the way back + let level = 0; + for (const id of builds) { + const lvl = Number(defById(id)?.effects?.tether?.level); + if (Number.isFinite(lvl) && lvl > level) level = lvl; + } + if (!level) continue; + const t = this.tetherField.tethers.find( + (tt) => tt.label === planetName || (tt.x === world.x && tt.y === world.y), + ); + if (!t) { + this.tetherField.add(`planet:${planetName}`, world.x, world.y, level, planetName); + } else if (t.level < level) { + this.tetherField.setLevel(t.id, level); + } + } + } + applyRestore(r) { if (r.ship) { this.ship.setPosition(Number(r.ship.x) || 0, Number(r.ship.y) || 0); diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index 513da19..ed2cb06 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -879,6 +879,7 @@ export class SurfaceScene extends Phaser.Scene { this.consoleNote(`BUILD COMPLETE — ${String(def?.label ?? done[0].build).toUpperCase()}`); this.playSfx('discovery'); // the 'something new is here' voice this.refreshHudTetherLevel(time); // the HUD's level line is a landing snapshot + this.buildWindow?.refresh(); // rows repaint — the world's tether level changed, so the next tier's gate may be open now } } this.buildWindow?.update(time);