Add stars as central bodies of non-home systems and fix home-system iden

- Introduce Star entity (procedural texture per spectral class) drawn at the origin of every non-home system; only the starting system's central body is the home world
- Freeze `homeSystemId` at roster build so lazily generated content for newly arrived systems no longer misreads as home and deals a second home world
- Anchor tether-l1 builds on newly discovered worlds (effects.tether in data/builds.json) and re-materialize installed tethers after jump cuts / scene rebuilds
- Discovery now reports the central body in every system ("Home World" vs. spectral-class star label); stars are solid but not comms targets
- Add dev tools: nav-reach-probe (soft-lock analysis), star unit tests, tether-newworld browser test; fix landing video references and Phaser stubs
This commit is contained in:
Brian Fertig 2026-09-06 19:34:49 -06:00
parent 507d00f818
commit 0950b143f5
19 changed files with 823 additions and 48 deletions

Binary file not shown.

Binary file not shown.

View File

@ -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"
}

View File

@ -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" }
]

View File

@ -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": {

View File

@ -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

View File

@ -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 {} },
},

132
dev/nav-reach-probe.mjs Normal file
View File

@ -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 YX 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(' ')}`);
}

179
dev/star.test.mjs Normal file
View File

@ -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-<cls>).
*/
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);

View File

@ -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: {

17
dev/tether-newworld.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev tether-l1 new-world build flow test</title>
<base href="../" />
<style>
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/tether-newworld.mjs"></script>
</body>
</html>

138
dev/tether-newworld.mjs Normal file
View File

@ -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);
}

View File

@ -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).

View File

@ -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',

148
js/entities/Star.js Normal file
View File

@ -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();
}
}

View File

@ -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)
// ------------------------------------------------------------------

View File

@ -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;

View File

@ -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 02), 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);

View File

@ -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);