Add faction worldgen layer: territories, capitals, bridges, and arid pla

- Assign each jump-tree branch a faction (apex/mindweave/zenith) with seeded
  shuffle; territory = depth 2..maxDepth-1 per branch, ring + tips stay neutral
- Designate four capital worlds (neutral + one per faction) as the outermost
  planet of their system, with fixed frames 18-21 and faction-owned settlements
- Add cross-branch bridge gates in the middle belt so factions can reach each
  other without routing through the trunk; Dijkstra picks the shorter route
- Introduce arid planet class (always habitable, settles as colony) across all
  system types with rebalanced class weights
- Wire Trestle's last brief to auto-set destination to the neutral capital on
  acknowledgment (CommsHub onEnd → GameScene._commEnded)
- Render faction ring layer + capital seat marks on the galaxy plate; style
  bridge lanes distinctly from trunk edges
- Populate settlement owner from system faction (reputation seam now live);
  SystemReport reads faction label/color
- Add dev/factions.test.mjs covering config, branches, capitals, territory,
  arid, bridges, determinism; update existing tests for new invariants
This commit is contained in:
Brian Fertig 2026-09-10 21:45:33 -06:00
parent 3137a800bf
commit e2878da59c
26 changed files with 1312 additions and 146 deletions

Binary file not shown.

View File

@ -207,7 +207,7 @@
"delayMs": 0 "delayMs": 0
}, },
{ {
"$comment": "Briefing beat 3 of 3 (trigger 'trestleBrief') — the last of the sequence: the rendezvous (Trestle is in the home-NEIGHBOR system — 'the neighboring system' of the quest's final check) and the way to find it (the orange markers on the compass/map). With all three heard, the quest's 'Talk to Trestle' check is complete (the played-ledger witness above).", "$comment": "Briefing beat 3 of 3 (trigger 'trestleBrief') — the last of the sequence: the rendezvous (Trestle is in the home-NEIGHBOR system — 'the neighboring system' of the quest's final check) and the way to find it (the orange markers on the compass/map). The 'star cluster's capital' is the NEUTRAL CAPITAL (data/factions.json — the roster's faction pass, js/galaxy/Galaxy.js → _assignFactions): the scene acts on the acknowledgment — the moment the player clicks ACKNOWLEDGE, its destination is set to that system's capital world (the system's outermost planet) — 'sending the coordinates to your computer right now' (js/comms/CommsHub.js → onEnd, js/scenes/GameScene.js → _commEnded → setDestination — the route, the orange compass marker, and the map's route lane all follow from it). With all three heard, the quest's 'Talk to Trestle' check is complete (the played-ledger witness above).",
"id": "trestle-brief-3", "id": "trestle-brief-3",
"from": "trestle", "from": "trestle",
"type": "ack", "type": "ack",

9
data/factions.json Normal file
View File

@ -0,0 +1,9 @@
{
"_comment": "FACTIONS — the three powers + neutral space. WORLDGEN LAYER (js/galaxy/Galaxy.js → the faction pass, at roster build): the jump tree's top-level BRANCHES (subtrees rooted at the home system's gate neighbors — home has ≤ maxGates gates, so ≤ 3 branches) each become ONE FACTION's territory. TERRITORY rule: a branch's systems at depth 2 … (maxDepth−1) (home = depth 0, its gate neighbors = depth 1) — the ring right outside home and every branch's dead-end tips stay NEUTRAL WILD SPACE, and anything not in a territory is neutral. CAPITALS: the NEUTRAL CAPITAL is a seeded pick of the home system's direct gate neighbors (ALWAYS); its system stays faction-neutral even though it roots a faction's branch (a neutral hub at the edge of that corridor). Each FACTION CAPITAL (apex / mindweave / zenith) is a seeded pick inside its branch's territory, preferred in the middle band (d in 0.37..0.63 — data/galaxy.json → distribution.zones' middle slice), on a non-barren system (it must host a planet); the branch↔faction assignment is a SEEDED SHUFFLE — new galaxy ⇒ new order/placement, same seed ⇒ identical (the determinism contract). Each capital's system grows its capital planet in content generation (js/galaxy/SystemGenerator.js: class capitalNeutral / capitalApex / capitalMindweave / capitalZenith>, planets.png frames 18..21 per data/planets.json → capitals, the 'capital' settlement per data/settlements.json — the faction ones owner:<faction>, the neutral one owner-less; that is the reputation faction seam, js/reputation/Reputation.js → factionStanding()). BRIDGES (data/gates.json → bridges) link the branches across the middle belt so the factions can reach — and fight — each other without routing through the trunk. REACHABILITY / COMBAT / TRADE off the territories is the NEXT layer (the reserved reputation standing + the map's territory-region pass): `color` = the faction's map/diplomacy color (the galaxy plate's star-ring layer, js/ui/GalaxyView.js); the capital planet's ART carries the same hue (frames 18..21).",
"factions": {
"neutral": { "label": "Neutral", "color": "#b9c4d6" },
"apex": { "label": "Apex", "color": "#ff4d4d" },
"mindweave": { "label": "MindWeave", "color": "#4d7dff" },
"zenith": { "label": "Zenith", "color": "#ffd24d" }
}
}

View File

@ -1,10 +1,17 @@
{ {
"_comment": "JUMP GATES — the galaxy's highway layer (js/galaxy/JumpNetwork.js for the network, js/galaxy/SystemGenerator.js → layoutGates for the in-system placement). NETWORK: every system holds between minGates and maxGates jump gates; every gate jumps to one of the system's `neighborPool` nearest stars on the 2-D map (the roster's x/y plane). The network is a degree-limited spanning tree of that neighbor graph (tree edges run BOTH ways); `shortcuts` is OFF — no mesh, no one-way links, no closed loops: the tree has EXACTLY ONE route between any two systems, so the galaxy reads as a MAZE of dead ends and long hauls. Strong connectivity still holds by construction (a bidirected tree — from any star you can reach any other, no closed systems, no trapped sets), and every jump is to a nearby star with a RETURN gate (the destination's gate pointing back — the player can always jump back the way they came). No system ever holds more than maxGates gates. A BARREN system (objectCount → 0, no planets/stations) is a DEAD-END LEAF of the tree — exactly one gate, in and out the same way (JumpNetwork takes the barren set from the per-system composition roll and never lets a barren node adopt children; the repair pass prefers non-barren attach targets). PLACEMENT (anchored systems): each gate sits EXACTLY one of the radius steps spanning `anchorMinDistance`..`anchorMaxDistance` (default 3500–4608 px) — CAPPED by the level-`anchorTetherLevel` tether range — from an ANCHOR — a PLANET, or the home world in the starting system. The band keeps the gate CLEARLY inside the level-1 tether of data/tether.json — ≤ 5120 − edgeMargin (512 px rim margin, so no gate sits at the edge of a tether region), hence STRICTLY < 5000 px from the anchor (Rule 1); a per-gate preferred step (a seeded fork) makes the distance VARY between gates — clearance/gap pressure only ever pushes a gate outward from its preference; all hard by construction. Free-space stations are deliberately NOT anchors: the build console lives on a WORLD, so every gate must be tether-reachable from a planet the player can build and expand out from (the composition roll demotes a station to a planet when a system's budget would otherwise run out, guaranteeing the anchor exists). Candidates are tried in facing quality (ray-circle intersection → circle point aimed at the star → ±75° scan) so each gate is on the target side of its anchor (within 90° of the system→star bearing) — the direction rule is soft, so a gate reads as 'facing that star' without the exact ray always being free. PLACEMENT (barren systems — no anchor, data/systems.json → objectCount.barren): the single gate sits ON the ray from the center toward its destination, `barrenDistance` px out (stepped outward within the minRadius..maxRadius band if the gap forces it), facing its destination; its payload is 1–2 asteroid clusters drifting inside the gate's level-1 tether (data/asteroids.json → barren). ACTIVITY: every gate record carries `active` (DEFAULT FALSE — the activation mechanic is future work). When a gate is activated, a level-1 tether (5120 px, data/tether.json) attaches to it: an active gate is a TETHER ANCHOR in its own right. In a barren system that tether is the player's entire room to move (the player arrives AT the gate — the center is empty: the star is invisible flavor, never rendered); in an anchored system it simply adds another anchor circle. A gate keeps `size` + `clearance` (px, center-to-center) from any solid disc (planets + free-space stations + the home world), 2·size + `gateGap` from any other gate, and stays between `minRadius` and `maxRadius` from the center. size = the gate's keepout radius (px). shipClearance = the ship's keepout from the gate (the solid rule, like planets). theme.color = the HUD/compass color (an inactive gate renders dimmed). typeLabel = the discovery/compass label. texture/frameWidth/frameHeight = the spritesheet (assets/images/jumpgate.png, 256×256 frames): frame 0 = the gate body (ring + pylons, drawn static), frame 1 = the active swirl (see swirl); sprite scale = (size×2)/frameWidth puts the ring's outer edge on the keepout disc. A missing sheet falls back to the built-in procedural gate (console note).", "_comment": "JUMP GATES — the galaxy's highway layer (js/galaxy/JumpNetwork.js for the network, js/galaxy/SystemGenerator.js → layoutGates for the in-system placement). NETWORK: every system holds between minGates and maxGates jump gates; every gate jumps to one of the system's `neighborPool` nearest stars on the 2-D map (the roster's x/y plane). The network is a degree-limited spanning tree of that neighbor graph (tree edges run BOTH ways); `shortcuts` is OFF — no mesh, no one-way links, no closed loops: the tree has EXACTLY ONE route between any two systems, so the galaxy reads as a MAZE of dead ends and long hauls. The deliberate exception is `bridges` (below): cross-branch middle-belt links that open loops in the MIDDLE of the galaxy so one branch doesn't have to trek back to the trunk (home) to reach another (the faction design — data/factions.json). Strong connectivity still holds by construction (a bidirected tree — from any star you can reach any other, no closed systems, no trapped sets), and every jump is to a nearby star with a RETURN gate (the destination's gate pointing back — the player can always jump back the way they came). No system ever holds more than maxGates gates. A BARREN system (objectCount → 0, no planets/stations) is a DEAD-END LEAF of the tree — exactly one gate, in and out the same way (JumpNetwork takes the barren set from the per-system composition roll and never lets a barren node adopt children; the repair pass prefers non-barren attach targets). PLACEMENT (anchored systems): each gate sits EXACTLY one of the radius steps spanning `anchorMinDistance`..`anchorMaxDistance` (default 3500–4608 px) — CAPPED by the level-`anchorTetherLevel` tether range — from an ANCHOR — a PLANET, or the home world in the starting system. The band keeps the gate CLEARLY inside the level-1 tether of data/tether.json — ≤ 5120 − edgeMargin (512 px rim margin, so no gate sits at the edge of a tether region), hence STRICTLY < 5000 px from the anchor (Rule 1); a per-gate preferred step (a seeded fork) makes the distance VARY between gates — clearance/gap pressure only ever pushes a gate outward from its preference; all hard by construction. Free-space stations are deliberately NOT anchors: the build console lives on a WORLD, so every gate must be tether-reachable from a planet the player can build and expand out from (the composition roll demotes a station to a planet when a system's budget would otherwise run out, guaranteeing the anchor exists). Candidates are tried in facing quality (ray-circle intersection → circle point aimed at the star → ±75° scan) so each gate is on the target side of its anchor (within 90° of the system→star bearing) — the direction rule is soft, so a gate reads as 'facing that star' without the exact ray always being free. PLACEMENT (barren systems — no anchor, data/systems.json → objectCount.barren): the single gate sits ON the ray from the center toward its destination, `barrenDistance` px out (stepped outward within the minRadius..maxRadius band if the gap forces it), facing its destination; its payload is 1–2 asteroid clusters drifting inside the gate's level-1 tether (data/asteroids.json → barren). ACTIVITY: every gate record carries `active` (DEFAULT FALSE — the activation mechanic is future work). When a gate is activated, a level-1 tether (5120 px, data/tether.json) attaches to it: an active gate is a TETHER ANCHOR in its own right. In a barren system that tether is the player's entire room to move (the player arrives AT the gate — the center is empty: the star is invisible flavor, never rendered); in an anchored system it simply adds another anchor circle. A gate keeps `size` + `clearance` (px, center-to-center) from any solid disc (planets + free-space stations + the home world), 2·size + `gateGap` from any other gate, and stays between `minRadius` and `maxRadius` from the center. size = the gate's keepout radius (px). shipClearance = the ship's keepout from the gate (the solid rule, like planets). theme.color = the HUD/compass color (an inactive gate renders dimmed). typeLabel = the discovery/compass label. texture/frameWidth/frameHeight = the spritesheet (assets/images/jumpgate.png, 256×256 frames): frame 0 = the gate body (ring + pylons, drawn static), frame 1 = the active swirl (see swirl); sprite scale = (size×2)/frameWidth puts the ring's outer edge on the keepout disc. A missing sheet falls back to the built-in procedural gate (console note).",
"enabled": true, "enabled": true,
"minGates": 1, "minGates": 1,
"maxGates": 3, "maxGates": 3,
"neighborPool": 8, "neighborPool": 8,
"shortcuts": false, "shortcuts": false,
"bridges": {
"_comment": "BRIDGES — the cross-branch links (js/galaxy/JumpNetwork.js, the bridge pass after the spanning tree). With shortcuts OFF the tree is a pure maze: branch-to-branch travel (a branch = a top-level subtree of the home system) means ALL the way back to the trunk. Bridges cut that: for each PAIR of distinct branches, `perPair` bidirectional gate-pairs join two systems of the middle belt (d in dMin..dMax — the home→far diagonal's middle slice, data/galaxy.json → distribution.zones), so the branches interconnect around the middle of the galaxy (a triangle for three branches) and a branch-to-branch trip is one extra hop, not a trek to the trunk — the faction design's \"reach each other without routing through home\" (data/factions.json). HARD RULES: both ends must sit in each other's `neighborPool` symmetric union (the LOCAL rule — a bridge is still a gate to a NEAR star, like any other), both ends non-barren (a dead-end leaf keeps its single gate), and both ends keep degree ≤ maxGates (the cap never breaks). SOFT: the middle-belt preference — an out-of-band pool pair is the fallback, and a pair with NO pool candidate degrades (a console note; the maze still connects the pair). Each bridge runs BOTH ways (a return gate at each end — the \"every jump has a return gate\" rule); planRoute's Dijkstra then prefers the shorter of bridge vs trunk automatically. Deterministic: same roster ⇒ same bridges (index tiebreaks).",
"enabled": true,
"perPair": 2,
"dMin": 0.37,
"dMax": 0.63
},
"size": 96, "size": 96,
"texture": "assets/images/jumpgate.png", "texture": "assets/images/jumpgate.png",
"frameWidth": 256, "frameWidth": 256,

View File

@ -10,6 +10,7 @@
"galaxy.json", "galaxy.json",
"systems.json", "systems.json",
"settlements.json", "settlements.json",
"factions.json",
"stations.json", "stations.json",
"gates.json", "gates.json",
"reputation.json", "reputation.json",

View File

@ -173,10 +173,11 @@
"void": { "speed": 0.2, "amp": 0.3 } "void": { "speed": 0.2, "amp": 0.3 }
}, },
"edges": { "edges": {
"_comment": "The jump-lane web — three reads of the same thin line. used = a lane the run TRAVELED (bright glow pass + the flow packet); frontier = exactly one end charted (the next step); unexplored = the faint maze. route = a lane on the active SET DESTINATION path (the \"where am I going\" line, data/gates.json → route.compassColor orange) — drawn above used/frontier so the course reads clearly. width = plate-px at 1× (0.75–1.25× with zoom).", "_comment": "The jump-lane web — three reads of the same thin line. used = a lane the run TRAVELED (bright glow pass + the flow packet); frontier = exactly one end charted (the next step); unexplored = the faint maze. route = a lane on the active SET DESTINATION path (the \"where am I going\" line, data/gates.json → route.compassColor orange) — drawn above used/frontier so the course reads clearly. bridge = a cross-branch middle-belt lane (data/gates.json → bridges — the inter-branch links the factions use to reach each other without the trunk, data/factions.json) — drawn above unexplored/frontier (below used/route) as the warm inter-branch thread. width = plate-px at 1× (0.75–1.25× with zoom).",
"route": { "color": "#ff8c1a", "width": 2.4, "alpha": 0.95 }, "route": { "color": "#ff8c1a", "width": 2.4, "alpha": 0.95 },
"used": { "color": "#8df3ff", "width": 1.8, "alpha": 0.95 }, "used": { "color": "#8df3ff", "width": 1.8, "alpha": 0.95 },
"frontier": { "color": "#3fb9d8", "width": 1.1, "alpha": 0.62 }, "frontier": { "color": "#3fb9d8", "width": 1.1, "alpha": 0.62 },
"bridge": { "color": "#d8b45a", "width": 1.0, "alpha": 0.42 },
"unexplored": { "color": "#2a4f70", "width": 0.8, "alpha": 0.3 } "unexplored": { "color": "#2a4f70", "width": 0.8, "alpha": 0.3 }
}, },
"destination": { "destination": {
@ -187,13 +188,23 @@
"alpha": 0.9 "alpha": 0.9
}, },
"hull": { "hull": {
"_comment": "The CHARTED REGION — the convex hull of the visited systems, inflated by `padding` (a fraction of the plate width at 1× zoom — stable under zoom). A soft fill + a subtle double outline (the discovered-area shading). FACTIONS (planned): per-faction territories reuse this exact pass, colored per faction.", "_comment": "The CHARTED REGION — the convex hull of the visited systems, inflated by `padding` (a fraction of the plate width at 1× zoom — stable under zoom). A soft fill + a subtle double outline (the discovered-area shading). FACTIONS: the per-faction RING layer lives in `factions` below (the plate's faction color layer — the worldgen layer is live, data/factions.json); a per-faction territory-region FILL reusing this hull pass is the next layer.",
"enabled": true, "enabled": true,
"color": "#00e5ff", "color": "#00e5ff",
"padding": 0.055, "padding": 0.055,
"fillAlpha": 0.05, "fillAlpha": 0.05,
"outlineAlpha": 0.26 "outlineAlpha": 0.26
}, },
"factions": {
"_comment": "The FACTION LAYER on the plate (data/factions.json — the roster's faction pass, js/galaxy/Galaxy.js). Every system that sits in a faction territory gets a RING in that faction's color (factions.<id>.color — neutral silver for the wild space's edge), and every CAPITAL gets the double-ring seat mark (the neutral capital wears neutral silver). The ring radius = the star's glow dot × ringMul, width = plate-px (both plate-stable under zoom — the dot floor keeps the ring readable even at 1×). alpha = the ring's opacity (territory rings run a touch dimmer than the capital rings — the seat reads first). enabled = false turns the whole layer off (the plate falls back to plain archetype colors). The per-faction territory-region FILL (this file's hull pass, colored per faction) + the relation tiers are the next layer.",
"enabled": true,
"alpha": 0.62,
"ringMul": 1.9,
"width": 1.2,
"capitalWidth": 1.8,
"capitalAlpha": 0.9,
"capitalOuter": 2.8
},
"flow": { "flow": {
"_comment": "Flow packets drifting along the TRAVELED lanes — the routes the player flew (the lane's history). size = plate-px; cycleMs = one traversal (a per-lane seeded stagger desyncs them).", "_comment": "Flow packets drifting along the TRAVELED lanes — the routes the player flew (the lane's history). size = plate-px; cycleMs = one traversal (a per-lane seeded stagger desyncs them).",
"enabled": true, "enabled": true,

View File

@ -9,19 +9,43 @@
"rocky": [6, 7, 8], "rocky": [6, 7, 8],
"gas": [3, 4, 5], "gas": [3, 4, 5],
"ice": [9, 10, 11], "ice": [9, 10, 11],
"lava": [12, 13, 14] "lava": [12, 13, 14],
"arid": [15, 16, 17],
"capitalNeutral": [18],
"capitalApex": [19],
"capitalMindweave": [20],
"capitalZenith": [21]
},
"capitals": {
"_comment": "CAPITAL WORLDS (data/factions.json, the faction pass in js/galaxy/Galaxy.js): the four designated capitals are NOT composition planets — the roster picks the SYSTEM (neutral: a seeded pick of home's gate neighbors; apex/mindweave/zenith: one per branch, middle band, seeded) and SystemGenerator converts the system's OUTERMOST world into the capital world (replacing it — the object budget + arc layout + settlement count stay exactly as rolled). frames = the planets.png frame per capital id (18 neutral, 19 Apex, 20 MindWeave, 21 Zenith — fixed per faction, never a random pool); the art carries each faction's color, so classTint is white (no tint) for the capital classes and the renderer's classScale sizes them (planets.classScale.capital*). All four are habitable cities (a capital has population by definition) and host the 'capital' settlement (data/settlements.json → kinds.capital); the neutral capital's settlement is owner-less, the faction ones are owner: <faction> (the reputation faction seam).",
"frames": {
"neutral": 18,
"apex": 19,
"mindweave": 20,
"zenith": 21
}
}, },
"classScale": { "classScale": {
"rocky": 1.0, "rocky": 1.0,
"gas": 1.45, "gas": 1.45,
"ice": 0.85, "ice": 0.85,
"lava": 0.95 "lava": 0.95,
"arid": 0.9,
"capitalNeutral": 1.2,
"capitalApex": 1.2,
"capitalMindweave": 1.2,
"capitalZenith": 1.2
}, },
"classTint": { "classTint": {
"rocky": "#e8d9b8", "rocky": "#e8d9b8",
"gas": "#ffb066", "gas": "#ffb066",
"ice": "#cfeaff", "ice": "#cfeaff",
"lava": "#ff8a5c" "lava": "#ff8a5c",
"arid": "#e2a25f",
"capitalNeutral": "#ffffff",
"capitalApex": "#ffffff",
"capitalMindweave": "#ffffff",
"capitalZenith": "#ffffff"
}, },
"compassColor": "#3dff88", "compassColor": "#3dff88",
"typeLabels": { "typeLabels": {
@ -29,7 +53,12 @@
"rocky": "Rocky World", "rocky": "Rocky World",
"gas": "Gas Giant", "gas": "Gas Giant",
"ice": "Ice World", "ice": "Ice World",
"lava": "Lava World" "lava": "Lava World",
"arid": "Arid World",
"capitalNeutral": "Neutral Capital",
"capitalApex": "Apex Capital",
"capitalMindweave": "MindWeave Capital",
"capitalZenith": "Zenith Capital"
}, },
"homePlanet": "terran", "homePlanet": "terran",
"homeName": "Terra", "homeName": "Terra",

View File

@ -1,11 +1,16 @@
{ {
"_comment": "The galaxy is already LIVED IN: it was settled long before the player. For now EVERY planet hosts a settlement (allPlanetsSettled): each world gets the one kind that fits its class (settledKindByClass — a habitable rocky world earns a colony, every other world gets its mining outfit, gas giants ride cloud bases). Flip allPlanetsSettled to false to return to the old probabilistic layer (per-type chance + needs in data/systems.json). The free-space kinds (deepSpaceStation, waypoint) still roll per type. kinds = the shared vocabulary (label, theme, population range, anchor type). Core→rim density gradient lives in data/galaxy.json (settlements.gradient).", "_comment": "The galaxy is already LIVED IN: it was settled long before the player. For now EVERY planet hosts a settlement (allPlanetsSettled): each world gets the one kind that fits its class (settledKindByClass — a habitable rocky OR arid world earns a colony, every other world gets its mining outfit, gas giants ride cloud bases, and the four CAPITAL worlds host the capital kind). Arid worlds are habitable by rule (data/systems.json doesn't roll it — js/galaxy/SystemGenerator.js sets habitable=true for every arid), so they settle as colonies like rocky habitables. The CAPITALS (data/factions.json — neutral/apex/mindweave/zenith) are designated systems whose outermost world is a class-`capital*` planet (data/planets.json → capitals); that world hosts the capital kind and its settlement carries the faction's `owner` (the reputation faction seam) — the neutral capital's owner stays null. Flip allPlanetsSettled to false to return to the old probabilistic layer (per-type chance + needs in data/systems.json). The free-space kinds (deepSpaceStation, waypoint) still roll per type. kinds = the shared vocabulary (label, theme, population range, anchor type). Core→rim density gradient lives in data/galaxy.json (settlements.gradient).",
"allPlanetsSettled": true, "allPlanetsSettled": true,
"settledKindByClass": { "settledKindByClass": {
"rocky": { "habitable": "colony", "default": "miningStation" }, "rocky": { "habitable": "colony", "default": "miningStation" },
"arid": "colony",
"gas": "cloudBase", "gas": "cloudBase",
"ice": "miningStation", "ice": "miningStation",
"lava": "miningStation" "lava": "miningStation",
"capitalNeutral": "capital",
"capitalApex": "capital",
"capitalMindweave": "capital",
"capitalZenith": "capital"
}, },
"kinds": { "kinds": {
"colony": { "colony": {
@ -29,6 +34,13 @@
"population": { "min": 500, "max": 120000 }, "population": { "min": 500, "max": 120000 },
"anchor": "planet" "anchor": "planet"
}, },
"capital": {
"label": "Capital",
"description": "A faction's seat — the administrative capital of a controlled branch of the galaxy.",
"theme": { "color": "#b9c4d6" },
"population": { "min": 5000000, "max": 200000000 },
"anchor": "planet"
},
"deepSpaceStation": { "deepSpaceStation": {
"label": "Deep-Space Station", "label": "Deep-Space Station",
"description": "A free-floating station adrift in open system space — refinery, gate, or trading post.", "description": "A free-floating station adrift in open system space — refinery, gate, or trading post.",

View File

@ -18,7 +18,7 @@
"mass": { "G": [0.8, 1.4], "K": [0.5, 0.8], "M": [0.1, 0.5] } "mass": { "G": [0.8, 1.4], "K": [0.5, 0.8], "M": [0.1, 0.5] }
}, },
"binaryChance": 0.05, "binaryChance": 0.05,
"planetClasses": { "rocky": 45, "gas": 25, "ice": 18, "lava": 12 }, "planetClasses": { "rocky": 38, "gas": 25, "ice": 18, "lava": 12, "arid": 7 },
"moonChance": 0.3, "moonChance": 0.3,
"beltChance": 0.35, "beltChance": 0.35,
"habitability": 0.1, "habitability": 0.1,
@ -47,7 +47,7 @@
"mass": { "M": [0.08, 0.35], "K": [0.5, 0.8] } "mass": { "M": [0.08, 0.35], "K": [0.5, 0.8] }
}, },
"binaryChance": 0.12, "binaryChance": 0.12,
"planetClasses": { "rocky": 50, "gas": 15, "ice": 28, "lava": 7 }, "planetClasses": { "rocky": 42, "gas": 15, "ice": 28, "lava": 7, "arid": 8 },
"moonChance": 0.45, "moonChance": 0.45,
"beltChance": 0.3, "beltChance": 0.3,
"habitability": 0.22, "habitability": 0.22,
@ -75,7 +75,7 @@
"mass": { "F": [1.0, 1.6], "G": [0.8, 1.4], "K": [0.5, 0.8] } "mass": { "F": [1.0, 1.6], "G": [0.8, 1.4], "K": [0.5, 0.8] }
}, },
"binaryChance": 1.0, "binaryChance": 1.0,
"planetClasses": { "rocky": 30, "gas": 40, "ice": 20, "lava": 10 }, "planetClasses": { "rocky": 26, "gas": 40, "ice": 20, "lava": 10, "arid": 4 },
"moonChance": 0.5, "moonChance": 0.5,
"beltChance": 0.6, "beltChance": 0.6,
"habitability": 0.06, "habitability": 0.06,
@ -113,7 +113,7 @@
"mass": { "G": [0.85, 1.3], "K": [0.6, 0.85] } "mass": { "G": [0.85, 1.3], "K": [0.6, 0.85] }
}, },
"binaryChance": 0.03, "binaryChance": 0.03,
"planetClasses": { "rocky": 62, "gas": 18, "ice": 14, "lava": 6 }, "planetClasses": { "rocky": 52, "gas": 18, "ice": 14, "lava": 6, "arid": 10 },
"moonChance": 0.35, "moonChance": 0.35,
"beltChance": 0.25, "beltChance": 0.25,
"habitability": 0.5, "habitability": 0.5,
@ -138,7 +138,7 @@
"mass": { "A": [1.4, 2.1], "F": [1.0, 1.6], "G": [0.8, 1.4] } "mass": { "A": [1.4, 2.1], "F": [1.0, 1.6], "G": [0.8, 1.4] }
}, },
"binaryChance": 0.1, "binaryChance": 0.1,
"planetClasses": { "rocky": 20, "gas": 25, "ice": 15, "lava": 40 }, "planetClasses": { "rocky": 17, "gas": 25, "ice": 15, "lava": 40, "arid": 3 },
"moonChance": 0.2, "moonChance": 0.2,
"beltChance": 0.8, "beltChance": 0.8,
"habitability": 0.02, "habitability": 0.02,
@ -161,7 +161,7 @@
"mass": { "M": [0.08, 0.3], "K": [0.4, 0.6] } "mass": { "M": [0.08, 0.3], "K": [0.4, 0.6] }
}, },
"binaryChance": 0.08, "binaryChance": 0.08,
"planetClasses": { "rocky": 20, "gas": 10, "ice": 55, "lava": 15 }, "planetClasses": { "rocky": 17, "gas": 10, "ice": 55, "lava": 15, "arid": 3 },
"moonChance": 0.1, "moonChance": 0.1,
"beltChance": 0.2, "beltChance": 0.2,
"habitability": 0.02, "habitability": 0.02,

387
dev/factions.test.mjs Normal file
View File

@ -0,0 +1,387 @@
/**
* FACTIONS test (dev tool, run with Node — no browser needed):
*
* node dev/factions.test.mjs
*
* The faction worldgen layer (data/factions.json + data/gates.json →
* bridges; js/galaxy/Galaxy.js → _assignFactions; js/galaxy/JumpNetwork.js
* → bridge pass; js/galaxy/SystemGenerator.js → capital worlds + owners) —
* see docs/PROJECT_NOTES.md, "FACTIONS". Asserts:
* - the CONFIG: four factions (neutral/apex/mindweave/zenith), each with
* a label + hex color; bridges enabled with perPair/dMin/dMax;
* - the BRANCHES: g.factions.branches = the top-level roots of the jump
* tree (home's gate neighbors), and branchFaction maps EVERY branch to
* one of the three player factions (a seeded order);
* - the NEUTRAL CAPITAL: a direct gate neighbor of home, its system stays
* neutral (faction null) and hosts the neutral capital world
* (class capitalNeutral, frame 18, the 'capital' settlement,
* owner-less); g.neutralCapitalTarget() names that system + its
* OUTERMOST planet (the capital world) as the 'Leaving the Nest'
* last-brief's auto destination (js/scenes/GameScene.js → _commEnded);
* - the FACTION CAPITALS: exactly three, one per faction, each in a
* non-barren system of its branch, each system carrying the capital
* world (class capital<faction>, frame 19/20/21) + the 'capital'
* settlement owned by the faction;
* - the TERRITORY RULE: every system of a faction's branch at depth 2 …
* (branch maxDepth − 1) carries that branch's faction; the ring right
* outside home (depth 1) and the branch tips (maxDepth) stay neutral —
* except a faction capital demoted onto a branch root (only when the
* branch has no territory at all);
* - ARID: every arid planet is ALWAYS habitable (no roll) and hosts a
* COLONY (data/settlements.json → settledKindByClass); arid worlds
* show up in the roster (rebalanced planetClasses);
* - the BRIDGES: cross-branch (the two endpoints sit in DIFFERENT
* branches), local (each endpoint in the other's neighbor pool),
* within the gate budget (≤ maxGates per system), on non-barren
* systems, mid-band (dMin..dMax) when the branch pair affords it;
* the snapshot edges carry `bridge: true` for exactly those lanes;
* - the MAP SNAPSHOT: every system carries faction/capital (null-safe);
* - DETERMINISM: same seed ⇒ identical factions, capitals, territories,
* bridges, owners; different seed ⇒ different assignment (spot check).
*/
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const { buildGalaxySnapshot, edgeKey } = await import(pathToFileURL(join(__dirname, '../js/galaxy/GalaxyChart.js')).href);
let failures = 0;
const check = (label, cond, extra = '') => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}${cond ? '' : ' — ' + extra}`);
if (!cond) failures++;
};
const FACS = config.get('factions.factions', {});
const FIDS = Object.keys(FACS).filter((k) => k !== 'neutral');
const BRC = config.get('gates.bridges', {});
const MAX_GATES = config.get('gates.maxGates', 3);
const POOL = Math.max(1, config.get('gates.neighborPool', 8));
// ----------------------------------------------------------------------
// 0. The config
// ----------------------------------------------------------------------
{
check('factions.json: neutral + apex + mindweave + zenith, each labeled + colored',
['neutral', 'apex', 'mindweave', 'zenith'].every((k) => typeof FACS[k]?.label === 'string' && /^#[0-9a-f]{6}$/i.test(FACS[k]?.color ?? '')));
check('bridges config: enabled, perPair ≥ 1, 0 < dMin < dMax < 1',
BRC.enabled === true && Number(BRC.perPair) >= 1 && Number(BRC.dMin) > 0 && Number(BRC.dMax) < 1 && Number(BRC.dMin) < Number(BRC.dMax));
check('capital planet classes + frames 18..21 (planets.json → capitals)',
FIDS.concat('neutral').every((fid) => {
const cls = `capital${fid.charAt(0).toUpperCase()}${fid.slice(1)}`;
return Array.isArray(config.get(`planets.frames.${cls}`)) && config.get(`planets.capitals.frames.${fid}`, -1) >= 18;
}));
check("the 'capital' settlement kind + arid→colony mapping exist",
typeof config.get('settlements.kinds.capital') === 'object' && config.get('settlements.settledKindByClass.arid') === 'colony' && FIDS.every((f) => config.get(`settlements.settledKindByClass.capital${f.charAt(0).toUpperCase()}${f.slice(1)}`) === 'capital'));
}
// ----------------------------------------------------------------------
// Shared walks: tree depth (home = 0) + per-system branch root.
// ----------------------------------------------------------------------
const SEED = 'factions-test-seed';
const g = Galaxy.create(SEED);
const HOME = g.currentSystemId;
const recOf = new Map(g.records.map((r) => [r.id, r]));
// The BRANCH STRUCTURE comes from the jump network's own spanning tree
// (js/galaxy/JumpNetwork.js exposes branches/branchOf/depth — the exact
// tree the bridge pass used; a BFS over the final gate list — tree +
// bridges — can re-parent systems across branches, which is NOT the
// branch structure the faction pass assigns).
const branchOf = g.jumpNetwork.branchOf; // Map id → branch root id
const depth = g.jumpNetwork.depth; // Map id → tree depth (home = 0)
const treeBranches = [...new Set([...branchOf.values()])];
const isBarren = (id) => {
if (id === HOME) return false;
const c = g.ensureContent(id);
const spaceCount = (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length;
return c.planets.length === 0 && spaceCount === 0;
};
// ----------------------------------------------------------------------
// 1. Branches, neutral capital, faction capitals, territory, owners
// ----------------------------------------------------------------------
{
check('factions present on the galaxy (neutral capital + 3 capitals + branches + mapping)',
!!g.factions && typeof g.factions.neutralCapital === 'string'
&& (Object.keys(g.factions.capitals ?? {}).length === 3)
&& Array.isArray(g.factions.branches) && g.factions.branches.length >= 2
&& typeof g.factions.branchFaction === 'object');
check('branches = the tree\u2019s top-level roots (home\u2019s gate neighbors)',
JSON.stringify([...g.factions.branches].sort()) === JSON.stringify([...new Set(treeBranches)].sort())
&& JSON.stringify([...g.factions.branches].sort()) === JSON.stringify(g.jumpNetwork.branches),
`${g.factions.branches.length} vs ${new Set(treeBranches).size}`);
check('branchFaction: every branch mapped to one of the 3 player factions',
g.factions.branches.every((b) => FIDS.includes(g.factions.branchFaction[b])));
// The NEUTRAL CAPITAL: a direct gate neighbor of home; its system stays
// neutral (faction null) + hosts the neutral capital world.
const nc = g.factions.neutralCapital;
const ncRec = recOf.get(nc);
check('neutral capital: a direct gate neighbor of home', ncRec && g.jumpGatesFor(HOME).some((t) => t.id === nc), nc);
check('neutral capital: its system stays neutral (faction null) but is designated',
!!ncRec && ncRec.faction === null && ncRec.capital === 'neutral',
ncRec ? `${ncRec.faction}/${ncRec.capital}` : 'missing record');
const ncContent = g.ensureContent(nc);
const ncPlanet = ncContent.planets.find((p) => p.capital);
check('neutral capital: hosts the capital world (class capitalNeutral, frame 18, habitable)',
!!ncPlanet && ncPlanet.class === 'capitalNeutral' && ncPlanet.frame === 18 && ncPlanet.habitable === true,
ncPlanet ? `${ncPlanet.class}/f${ncPlanet.frame}` : 'no capital planet');
const ncSettle = ncContent.settlements.find((s) => s.kind === 'capital');
check('neutral capital: the capital world hosts an owner-less capital settlement',
!!ncSettle && ncSettle.owner === null && ncSettle.anchor?.ordinal === ncPlanet?.ordinal,
ncSettle ? `${ncSettle.kind} owner=${ncSettle.owner}` : 'no capital settlement');
// The destination target (the 'Leaving the Nest' last brief — Trestle
// "sends the coordinates"; js/scenes/GameScene.js → _commEnded hands
// this to setDestination on the acknowledgment). The object id is the
// map's discoveryId vocabulary (the planet's NAME — what MapWindow's
// SET DESTINATION passes + the arrival/compass/plate lookups read).
const ncTarget = g.neutralCapitalTarget();
const ncOuter = ncContent.planets[ncContent.planets.length - 1];
check('neutral capital target: system = the designated capital + the capital world is the OUTERMOST planet (object id = its name)',
ncTarget?.systemId === nc
&& ncTarget?.objectId === ncOuter?.name
&& ncOuter?.capital === true && ncOuter?.class === 'capitalNeutral',
ncTarget ? `${ncTarget.systemId} / ${ncTarget.objectId}` : 'null target');
// The FACTION CAPITALS: one per faction, non-barren, capital world of
// the faction's class + frame, settlement owned by the faction.
const capFrames = { apex: 19, mindweave: 20, zenith: 21 };
const caps = Object.entries(g.factions.capitals ?? {});
check('exactly 3 faction capitals, one per faction, distinct systems',
caps.length === 3 && new Set(caps.map(([, id]) => id)).size === 3
&& caps.every(([fid, id]) => FIDS.includes(fid) && recOf.has(id) && recOf.get(id).faction === fid && recOf.get(id).capital === fid));
let capWorlds = true;
let capWhy = '';
for (const [fid, id] of caps) {
const cls = `capital${fid.charAt(0).toUpperCase()}${fid.slice(1)}`;
const c = g.ensureContent(id);
const p = c.planets.find((pl) => pl.capital);
const s = c.settlements.find((st) => st.kind === 'capital');
if (!p || p.class !== cls || p.frame !== capFrames[fid] || p.habitable !== true) { capWorlds = false; capWhy = `${id}: planet ${p ? `${p.class}/f${p.frame}` : 'none'}`; break; }
if (!s || s.owner !== fid) { capWorlds = false; capWhy = `${id}: settlement owner ${s?.owner ?? 'none'}`; break; }
}
check('faction capitals: capital world (class + frame + habitable) + faction-owned settlement', capWorlds, capWhy);
// TERRITORY: depth 2 … (branch maxDepth − 1) of each branch = that
// branch's faction; the depth-1 ring + the branch tips stay neutral
// (except a faction capital demoted onto a branch root — only when the
// branch has no territory at all).
const branchMax = new Map(g.factions.branches.map((b) => [b, 0]));
for (const [id, d] of depth) if (id !== HOME && branchOf.has(id)) branchMax.set(branchOf.get(id), Math.max(branchMax.get(branchOf.get(id)), d));
let terrOk = true;
let terrWhy = '';
for (const r of g.records) {
if (r.id === HOME || !branchOf.has(r.id)) continue;
const d = depth.get(r.id);
const b = branchOf.get(r.id);
const fid = g.factions.branchFaction[b];
const maxD = branchMax.get(b);
const expectFaction = d >= 2 && d <= maxD - 1 ? fid : null;
const got = r.faction === fid ? fid : null;
const demoted = g.factions.capitals[fid] === r.id && expectFaction === null;
if (got !== expectFaction && !(demoted && got === fid)) {
terrOk = false;
terrWhy = `${r.id}: d=${d} maxD=${maxD} expect=${expectFaction} got=${got}`;
break;
}
}
check('territory rule: depth 2…(maxDepth−1) per branch = the branch\u2019s faction; ring + tips neutral', terrOk, terrWhy);
// OWNERS: every settlement in a faction-controlled system is owned by
// that faction (neutral systems: owner null) — the reputation seam is
// live, not a reserved null anymore.
let ownOk = true;
let ownWhy = '';
for (const r of g.records) {
const c = g.ensureContent(r.id);
for (const s of c.settlements) {
if (s.owner !== r.faction) { ownOk = false; ownWhy = `${r.id}: settlement ${s.kind} owner=${s.owner} system faction=${r.faction}`; break; }
}
if (!ownOk) break;
}
check('settlement owners: faction systems own their settlements; neutral space does not', ownOk, ownWhy);
}
// ----------------------------------------------------------------------
// 2. Arid — the common habitable class (frames 15..17)
// ----------------------------------------------------------------------
{
const ARID = config.get('planets.frames.arid');
check('arid frames are 15..17 (planets.json)', JSON.stringify(ARID) === '[15,16,17]', JSON.stringify(ARID));
let seenArid = 0, aridHabit = true, aridColony = true;
let whyHabit = '', whyColony = '';
for (const r of g.records) {
const c = g.ensureContent(r.id);
for (const p of c.planets) {
if (p.class !== 'arid') continue;
seenArid++;
if (p.habitable !== true) { aridHabit = false; whyHabit = `${r.id}/${p.name}: arid not habitable`; }
const s = c.settlements.find((st) => st.anchor?.type === 'planet' && st.anchor?.ordinal === p.ordinal && st.kind === 'colony');
if (!s) { aridColony = false; whyColony = `${r.id}/${p.name}: no colony on arid`; }
}
}
check(`arid worlds appear in the roster (${seenArid} found)`, seenArid > 0, 'none found');
check('every arid world is habitable (the always-habitable class)', aridHabit, whyHabit);
check('every arid world hosts a colony', aridColony, whyColony);
}
// ----------------------------------------------------------------------
// 3. Bridges — the inter-branch middle-belt links
// ----------------------------------------------------------------------
{
const bridges = g.jumpNetwork?.bridges ?? [];
const maxBridges = BRC.enabled === false ? 0 : BRC.perPair * (g.factions.branches.length * (g.factions.branches.length - 1) / 2);
check(`bridges placed: ${bridges.length} (> 0, ≤ ${maxBridges})`,
bridges.length > 0 && bridges.length <= maxBridges,
`${bridges.length}`);
let span = true, local = true, budget = true, barren = true;
let spanWhy = '', localWhy = '', budgetWhy = '', barrenWhy = '';
for (const [a, b] of bridges) {
const ba = branchOf.get(a), bb = branchOf.get(b);
if (a === b) { span = false; spanWhy = `${a} self-loop`; continue; }
if (ba === undefined || bb === undefined || ba === bb) { span = false; spanWhy = `${a}(branch ${ba ?? '?'})↔${b}(branch ${bb ?? '?'})`; continue; }
const inPool = g.neighborsOf(a, POOL).some((x) => x.id === b) || g.neighborsOf(b, POOL).some((x) => x.id === a);
if (!inPool) { local = false; localWhy = `${a}↔${b} outside the ${POOL}-nearest pool`; }
if (g.jumpGatesFor(a).length > MAX_GATES || g.jumpGatesFor(b).length > MAX_GATES) { budget = false; budgetWhy = `${a}(${g.jumpGatesFor(a).length})/${b}(${g.jumpGatesFor(b).length}) > ${MAX_GATES}`; }
if (isBarren(a) || isBarren(b)) { barren = false; barrenWhy = `${a} or ${b} is barren`; }
}
check('every bridge spans two different branches (on the TREE branch map)', span, spanWhy);
check('every bridge is local (endpoints in each other\u2019s neighbor pool)', local, localWhy);
check('bridges respect the gate budget (≤ maxGates per system)', budget, budgetWhy);
check('bridges never touch barren systems', barren, barrenWhy);
// THE PREFERENCE, verified by re-simulating the bridge pass (it is a
// pure function of the roster + pools + budget + config): a faithful
// replay must reproduce the EXACT placed bridges, in order — which
// subsumes the middle-belt preference (band pairs sort first), the
// distinct-endpoint preference (pass 0), and the per-pair count.
const k = (a, b) => (a < b ? a : b) + '\u0000' + (a < b ? b : a);
const bridgeKeys = new Set(bridges.map(([a, b]) => k(a, b)));
const n = g.records.length;
const idx = new Map(g.records.map((r, i) => [r.id, i]));
const root = idx.get(HOME);
const out = g.records.map((r) => g.neighborsOf(r.id, POOL).map((x) => idx.get(x.id)));
const rev = Array.from({ length: n }, () => []);
for (let i = 0; i < n; i++) for (const j of out[i]) rev[j].push(i);
const nbr = g.records.map((_, i) => {
const seen = new Set(out[i]);
const arr = out[i].slice();
for (const j of rev[i]) if (!seen.has(j)) { seen.add(j); arr.push(j); }
return arr;
});
// Starting degrees: the tree degree (the gate list minus the bridge
// targets it carries — shortcuts are OFF in the config, so the gate
// list is tree edges + bridge gates). jumpGatesFor returns RECORD
// objects — map to ids first.
const deg = g.records.map((r) => g.jumpGatesFor(r.id).map((t) => t.id).filter((t) => !bridgeKeys.has(k(r.id, t))).length);
const dMin = Number(BRC.dMin), dMax = Number(BRC.dMax);
const dOf = (i) => Number(g.records[i].d);
const inBand = (i) => dOf(i) >= dMin && dOf(i) <= dMax;
const bOf = (i) => {
const b = branchOf.get(g.records[i].id); // branch root id (string)
return b !== undefined ? idx.get(b) : -1;
};
const dist2 = (a, b) => {
const dx = g.records[a].x - g.records[b].x;
const dy = g.records[a].y - g.records[b].y;
return dx * dx + dy * dy;
};
const simulated = [];
const branchesSorted = g.jumpNetwork.branches.map((id) => idx.get(id)).sort((a, b) => a - b);
for (let bi = 0; bi < branchesSorted.length; bi++) {
for (let bj = bi + 1; bj < branchesSorted.length; bj++) {
const A = branchesSorted[bi], B = branchesSorted[bj];
const cands = [];
for (let u = 0; u < n; u++) {
if (bOf(u) !== A || isBarren(g.records[u].id) || u === root) continue;
for (const v of nbr[u]) {
if (bOf(v) !== B || isBarren(g.records[v].id) || v === root) continue;
cands.push({ u, v, band: inBand(u) && inBand(v), d: dist2(u, v) });
}
}
cands.sort((p, q) =>
(p.band === q.band ? 0 : p.band ? -1 : 1) ||
p.d - q.d || p.u - q.u || p.v - q.v);
const usedEnds = new Set();
let added = 0;
for (let pass = 0; pass < 2 && added < BRC.perPair; pass++) {
for (const c of cands) {
if (added >= BRC.perPair || c.added) continue;
if (pass === 0 && (usedEnds.has(c.u) || usedEnds.has(c.v))) continue;
if (deg[c.u] >= MAX_GATES || deg[c.v] >= MAX_GATES) continue;
c.added = true;
deg[c.u]++;
deg[c.v]++;
usedEnds.add(c.u);
usedEnds.add(c.v);
simulated.push([g.records[c.u].id, g.records[c.v].id]);
added++;
}
}
}
}
const simOk = JSON.stringify(simulated) === JSON.stringify(bridges);
check('bridge pass replay: a faithful re-simulation reproduces the exact placed bridges (in order — locality, budget, band + distinct-endpoint preference)',
simOk,
simOk ? '' : `got ${JSON.stringify(bridges)} vs simulated ${JSON.stringify(simulated)}`);
// The snapshot: edges.bridge flags exactly the bridge lanes (and the
// systems carry faction/capital).
const snap = buildGalaxySnapshot({ galaxy: g });
const snapBridge = new Set();
for (const e of snap.edges) if (e.bridge) snapBridge.add(e.key);
const wantBridge = new Set(bridges.map(([a, b]) => edgeKey(a, b)));
check('snapshot edges carry bridge:true for exactly the placed bridges',
snapBridge.size === wantBridge.size && [...wantBridge].every((x) => snapBridge.has(x)),
`${snapBridge.size} vs ${wantBridge.size}`);
const sysOk = snap.systems.every((s) => s.faction === null || FIDS.includes(s.faction))
&& snap.systems.every((s) => s.capital === null || ['neutral', ...FIDS].includes(s.capital));
check('snapshot systems carry faction (null | faction id) + capital (null | designation)', sysOk);
}
// ----------------------------------------------------------------------
// 4. Determinism — same seed ⇒ identical; different seed ⇒ different
// ----------------------------------------------------------------------
{
const g2 = Galaxy.create(SEED);
const sameF = JSON.stringify(g.factions) === JSON.stringify(g2.factions);
let sameTerr = true;
for (let i = 0; i < g.records.length && sameTerr; i++) {
if (g.records[i].faction !== g2.records[i].faction || g.records[i].capital !== g2.records[i].capital) sameTerr = false;
}
let sameBridges = JSON.stringify(g.jumpNetwork?.bridges) === JSON.stringify(g2.jumpNetwork?.bridges);
check('same seed ⇒ identical factions (capitals + branches + mapping)', sameF);
check('same seed ⇒ identical per-system faction/capital designations', sameTerr);
check('same seed ⇒ identical bridges', sameBridges);
const g3 = Galaxy.create('factions-test-OTHER');
let diff = false;
for (let i = 0; i < 60 && !diff; i++) {
const a = g.records[i], b = g3.records[i];
if (a.id !== b.id) continue;
if (a.faction !== b.faction || a.capital !== b.capital) diff = true;
}
check('different seed ⇒ different faction assignment (spot check)', diff);
}
console.log(failures === 0 ? '\nAll faction tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -158,16 +158,24 @@ const poolFor = (cls) => {
check('same seed ⇒ same (class, frame) assignment galaxy-wide', same); check('same seed ⇒ same (class, frame) assignment galaxy-wide', same);
check('same seed ⇒ same home-world frame', g.homeWorldFrame === g2.homeWorldFrame); check('same seed ⇒ same home-world frame', g.homeWorldFrame === g2.homeWorldFrame);
// Content frames === galaxy frames (the stamping contract). // Content frames === galaxy frames (the stamping contract). CAPITAL
// WORLDS are exempt: their frame is fixed per faction (planets.json →
// capitals — the art carries the faction's color), NOT stamped by the
// pass (they replace the outermost composition world — see
// js/galaxy/SystemGenerator.js), so the pass's stamp for that slot is
// intentionally overridden in content.
let stamped = true; let stamped = true;
for (const rec of g.records) { for (const rec of g.records) {
const c = g.ensureContent(rec.id); const c = g.ensureContent(rec.id);
if (JSON.stringify(c.planets.map((p) => p.frame)) !== JSON.stringify(g.planetFrames.get(rec.id))) { const pass = g.planetFrames.get(rec.id) ?? [];
const frames = c.planets.map((p) => (p.capital ? null : p.frame));
const passFrames = pass.map((f, i) => (c.planets[i]?.capital ? null : f));
if (JSON.stringify(frames) !== JSON.stringify(passFrames)) {
stamped = false; stamped = false;
break; break;
} }
} }
check('content planet frames === the galaxy pass (lazy === eager stamping)', stamped); check('content planet frames === the galaxy pass (lazy === eager stamping; capitals wear their fixed faction face)', stamped);
const g3 = Galaxy.create('frames-test-OTHER'); const g3 = Galaxy.create('frames-test-OTHER');
let diff = false; let diff = false;

View File

@ -93,11 +93,21 @@ const b = others[1];
check('snap: home/current flags', recA.isHome === false && recA.isCurrent === false); check('snap: home/current flags', recA.isHome === false && recA.isCurrent === false);
check('snap: gate counts from the jump network', check('snap: gate counts from the jump network',
recA.gates === (galaxy.jumpNetwork.gates.get(a.id)?.length ?? 0)); recA.gates === (galaxy.jumpNetwork.gates.get(a.id)?.length ?? 0));
check('snap: FACTIONS seam present (faction: null, not yet implemented)', const FIDS = new Set(Object.keys(config.get('factions.factions', {})));
snap.systems.every((s) => 'faction' in s && s.faction === null)); check('snap: FACTIONS live (faction null|id + capital null|designation; the neutral capital is designated)',
snap.systems.every((s) => 'faction' in s && (s.faction === null || FIDS.has(s.faction)))
&& snap.systems.every((s) => 'capital' in s && (s.capital === null || ['neutral', ...FIDS].includes(s.capital)))
&& snap.systems.some((s) => s.capital === 'neutral')
&& snap.systems.some((s) => s.capital === null && s.faction === null));
check('snap: a spanning tree = n−1 lanes (dedup’d)', // The lane web = the spanning TREE (n−1 undirected lanes) PLUS the
galaxy.records.length === 9 ? snap.edges.length === 8 : snap.edges.length === galaxy.records.length - 1); // BRIDGES (data/gates.json → bridges — the cross-branch middle-belt
// links, each one dedup'd lane).
const nBridges = (galaxy.jumpNetwork?.bridges ?? []).length;
check('snap: lanes = n−1 tree lanes + the bridges (dedup’d)',
snap.edges.length === galaxy.records.length - 1 + nBridges);
check('snap: the bridge lanes are marked (bridge: true, exactly the placed bridges)',
snap.edges.filter((e) => e.bridge).length === nBridges);
const homeA = snap.edges.find((e) => edgeKey(e.a, e.b) === lane); const homeA = snap.edges.find((e) => edgeKey(e.a, e.b) === lane);
check('snap: the traveled lane is used', homeA?.used === true); check('snap: the traveled lane is used', homeA?.used === true);
check('snap: the live lane out of the current system is live', homeA?.live === true); check('snap: the live lane out of the current system is live', homeA?.live === true);

View File

@ -332,6 +332,11 @@ let big;
const kinds = config.get('settlements.kinds', {}); const kinds = config.get('settlements.kinds', {});
check('settlement kinds defined with theme + population range', Object.keys(kinds).length >= 4 && Object.values(kinds).every((k) => typeof k.theme?.color === 'string' && k.population?.min >= 0 && k.population?.max >= k.population?.min)); check('settlement kinds defined with theme + population range', Object.keys(kinds).length >= 4 && Object.values(kinds).every((k) => typeof k.theme?.color === 'string' && k.population?.min >= 0 && k.population?.max >= k.population?.min));
// FACTIONS (data/factions.json — the roster's faction pass, js/galaxy/
// Galaxy.js → _assignFactions): a settlement's `owner` is null (neutral
// wild space) or one of the faction ids — never anything else.
const FIDS = new Set(Object.keys(config.get('factions.factions', {})));
// Structure across a big sample of the full galaxy. // Structure across a big sample of the full galaxy.
const big2 = Galaxy.create(SEED); const big2 = Galaxy.create(SEED);
const sample = big2.records.slice(0, 3000); const sample = big2.records.slice(0, 3000);
@ -346,12 +351,12 @@ let big;
} else if (s.anchor.type !== 'space') structOk = false; } else if (s.anchor.type !== 'space') structOk = false;
const pop = kinds[s.kind].population; const pop = kinds[s.kind].population;
if (s.population < pop.min || s.population > pop.max) structOk = false; if (s.population < pop.min || s.population > pop.max) structOk = false;
if (s.owner !== null) structOk = false; // reserved seam, unused for now if (s.owner !== null && !FIDS.has(s.owner)) structOk = false; // faction seam — the roster's faction pass fills it
if (!s.name) structOk = false; if (!s.name) structOk = false;
} }
if (!structOk) break; if (!structOk) break;
} }
check('settlement structure: known kinds, valid anchors, population in range, owner=null (faction seam)', structOk); check('settlement structure: known kinds, valid anchors, population in range, owner = null or a faction id', structOk);
// The lived-in mix actually shows up across the sample. // The lived-in mix actually shows up across the sample.
const seen = new Set(); const seen = new Set();
@ -363,15 +368,17 @@ let big;
const p = c.planets.find((pl) => pl.ordinal === s.anchor.ordinal); const p = c.planets.find((pl) => pl.ordinal === s.anchor.ordinal);
if (s.kind === 'colony' && !p?.habitable) anchorRulesOk = false; if (s.kind === 'colony' && !p?.habitable) anchorRulesOk = false;
if (s.kind === 'cloudBase' && p?.class !== 'gas') anchorRulesOk = false; if (s.kind === 'cloudBase' && p?.class !== 'gas') anchorRulesOk = false;
if (s.kind === 'capital' && !String(p?.class ?? '').startsWith('capital')) anchorRulesOk = false;
} }
} }
check('the lived-in mix appears (colonies, miners, cloud bases, stations, beacons)', ['colony', 'miningStation', 'cloudBase', 'deepSpaceStation', 'waypoint'].every((k) => seen.has(k))); check('the lived-in mix appears (colonies, miners, cloud bases, stations, beacons)', ['colony', 'miningStation', 'cloudBase', 'deepSpaceStation', 'waypoint'].every((k) => seen.has(k)));
check('colonies sit on habitable worlds; cloud bases ride gas giants', anchorRulesOk); check('colonies sit on habitable worlds; cloud bases ride gas giants; capitals sit on capital worlds', anchorRulesOk);
// The all-planets-settled rule (for now): EVERY world hosts a settlement, // The all-planets-settled rule (for now): EVERY world hosts a settlement,
// the kind that fits its class (data/settlements.json → settledKindByClass // the kind that fits its class (data/settlements.json → settledKindByClass
// — habitable rocky ⇒ colony, other rocky/ice/lava ⇒ mining station, // — habitable rocky ⇒ colony, ARID ⇒ colony (always habitable),
// gas ⇒ cloud base). // other rocky/ice/lava ⇒ mining station, gas ⇒ cloud base,
// capital worlds ⇒ capital).
let allSettledOk = true; let allSettledOk = true;
for (const r of sample) { for (const r of sample) {
const c = big2.ensureContent(r.id); const c = big2.ensureContent(r.id);
@ -381,15 +388,17 @@ let big;
); );
const kindOk = c.settlements.filter( const kindOk = c.settlements.filter(
(s) => s.anchor?.type === 'planet' && s.anchor?.ordinal === p.ordinal, (s) => s.anchor?.type === 'planet' && s.anchor?.ordinal === p.ordinal,
).every((s) => ).every((s) => {
(p.class === 'gas' && s.kind === 'cloudBase') || if (p.class === 'gas') return s.kind === 'cloudBase';
(p.class !== 'gas' && (s.kind === 'colony' || s.kind === 'miningStation')) if (String(p.class).startsWith('capital')) return s.kind === 'capital';
); if (p.class === 'arid') return s.kind === 'colony';
return s.kind === 'colony' || s.kind === 'miningStation';
});
if (!anchored || !kindOk) { allSettledOk = false; break; } if (!anchored || !kindOk) { allSettledOk = false; break; }
} }
if (!allSettledOk) break; if (!allSettledOk) break;
} }
check('every planet is settled with a class-fitting kind (colonies only on habitable rocky worlds)', allSettledOk); check('every planet is settled with a class-fitting kind (colonies on habitable rocky + arid worlds, capitals on capital worlds)', allSettledOk);
// OBJECT COMPOSITION: the BARREN systems (objectCount → 0) are the only // OBJECT COMPOSITION: the BARREN systems (objectCount → 0) are the only
// unsettled ones — the gate-only dead-end leaves of the jump network // unsettled ones — the gate-only dead-end leaves of the jump network

View File

@ -11,10 +11,12 @@
* - the network is STRONGLY CONNECTED: from the home system every * - the network is STRONGLY CONNECTED: from the home system every
* other system is reachable (forward BFS) AND every system can * other system is reachable (forward BFS) AND every system can
* reach home (reverse BFS) — no closed systems, no trapped sets; * reach home (reverse BFS) — no closed systems, no trapped sets;
* - the network is a PURE SPANNING TREE (shortcuts OFF in the config): * - the network is a SPANNING TREE of N−1 undirected tree edges
* exactly N−1 undirected edges — no loops, one route between any two * (shortcuts OFF in the config) — the maze's single path per branch
* systems (the maze) — and every BARREN system (objectCount → 0) is * pair — PLUS the BRIDGES: the cross-branch middle-belt links
* a LEAF: exactly one gate, in and out the same way (the dead end); * (data/gates.json → bridges), each a local bidirectional lane, and
* every BARREN system (objectCount → 0) is a LEAF: exactly one gate,
* in and out the same way (the dead end);
* *
* the IN-SYSTEM PLACEMENT (js/galaxy/SystemGenerator.js → layoutGates): * the IN-SYSTEM PLACEMENT (js/galaxy/SystemGenerator.js → layoutGates):
* - content.jumps matches the network (count + destinations); * - content.jumps matches the network (count + destinations);
@ -179,16 +181,48 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
} }
check('every system can reach home (no closed systems, no trapped sets)', rev.size === g.records.length, `${rev.size}/${g.records.length}`); check('every system can reach home (no closed systems, no trapped sets)', rev.size === g.records.length, `${rev.size}/${g.records.length}`);
// TREE (shortcuts OFF in data/gates.json): exactly N−1 undirected // TREE + BRIDGES (data/gates.json): the tree is exactly N−1 undirected
// edges — no loops, no redundant routes (the maze's single path). // tree edges (shortcuts OFF — the maze's single path per branch pair),
// plus the BRIDGES — the cross-branch middle-belt links (data/gates.json
// → bridges — the faction design's "don't go back to the trunk" routes,
// data/factions.json). Each bridge is ONE undirected lane (both ends'
// gate lists carry it) between two systems of DIFFERENT branches,
// local (the locality check above already covers it) and within the
// gate budget (the 1–maxGates check above already covers it).
const BRC = config.get('gates.bridges', {});
const BR_PER_PAIR = BRC.enabled === false ? 0 : Math.max(0, Math.floor(Number(BRC.perPair) || 0));
const pairs = new Set(); const pairs = new Set();
let treeEdges = 0;
for (const r of g.records) for (const t of g.jumpGatesFor(r.id)) { for (const r of g.records) for (const t of g.jumpGatesFor(r.id)) {
const key = [r.id, t.id].sort().join('\u0000'); pairs.add([r.id, t.id].sort().join('\u0000'));
if (!pairs.has(key)) pairs.add(key), treeEdges++; }
const bridges = g.jumpNetwork?.bridges ?? [];
const bridgeKeys = new Set(bridges.map(([a, b]) => [a, b].sort().join('\u0000')));
const treeEdges = [...pairs].filter((k) => !bridgeKeys.has(k)).length;
check(`spanning tree: exactly ${g.records.length - 1} tree edges (no loops)`,
treeEdges === g.records.length - 1, `${treeEdges} tree edges over ${g.records.length} systems`);
if (BR_PER_PAIR > 0) {
// The branches are the tree's top-level subtrees (js/galaxy/JumpNetwork.js
// exposes branchOf — the exact spanning tree the bridge pass used; a BFS
// over the final gate list can re-parent systems across branches,
// since bridges are loops). Every bridge must span two DIFFERENT
// branches (that's the whole point: branch-to-branch without the
// trunk), and the count stays within perPair per pair (degraded only
// when the budget/pool ran dry).
const bOf = g.jumpNetwork?.branchOf ?? new Map();
let spanOk = true;
let spanWhy = '';
for (const [a, b] of bridges) {
const fa = bOf.get(a), fb = bOf.get(b);
if (fa === undefined || fb === undefined || fa === fb) {
spanOk = false;
spanWhy = `${a}↔${b} (branch ${fa ?? '?'} / ${fb ?? '?'})`;
break;
}
}
check('every bridge spans two DIFFERENT branches (the inter-branch links)', spanOk, spanWhy);
check(`bridges: ${bridges.length} cross-branch lane(s), ≤ ${BR_PER_PAIR} per pair`,
bridges.length > 0 && bridges.length <= BR_PER_PAIR * 3 * 2, `${bridges.length} bridges (want > 0, ≤ ${BR_PER_PAIR * 6})`);
} }
check(`spanning tree: exactly ${g.records.length - 1} undirected edges (no loops)`,
treeEdges === g.records.length - 1, `${treeEdges} edges over ${g.records.length} systems`);
// MAZE DEAD ENDS: every barren system (objectCount → 0) is a LEAF — // MAZE DEAD ENDS: every barren system (objectCount → 0) is a LEAF —
// exactly one gate, in and out the same way. (Home is exempt — it // exactly one gate, in and out the same way. (Home is exempt — it

View File

@ -190,9 +190,14 @@ branch is now the normal case, not a defensive fallback. Model & seams:
`floor`). Each record carries `d` (0 = home corner, 1 = far corner) and `floor`). Each record carries `d` (0 = home corner, 1 = far corner) and
its `zone` so density — and later faction strength, hazard, and trade its `zone` so density — and later faction strength, hazard, and trade
value — is per-system, not global. value — is per-system, not global.
- **Reserved seam: `owner`** — every settlement has `owner: null`. That's - **Faction ownership (LIVE)** — the roster's faction pass
where **factions and pirates** will plug in later (claim, flag, (`js/galaxy/Galaxy.js → _assignFactions`, data/factions.json) assigns
relations). Deliberately absent for now — no factions yet. each system a `faction` (or null = neutral wild space) and designates
the four capitals; every settlement's `owner` is populated from the
system's territory (a capital world's settlement is owned by its
faction; neutral/wild space stays owner-less — pirates are still the
future layer). The dossier (js/galaxy/SystemReport.js) reads the
faction's label + color.
- **Pure report formatter** — `js/galaxy/SystemReport.js` - **Pure report formatter** — `js/galaxy/SystemReport.js`
(`formatSystemReport(content)` → title/subtitle/faction/population/ (`formatSystemReport(content)` → title/subtitle/faction/population/
status). Compact by design: the HUD shows the system name, identity status). Compact by design: the HUD shows the system name, identity
@ -452,19 +457,43 @@ before `captureState()`, so the save carries the footprint; `SaveData`
captures/restores/resets both, and a save that predates the tab captures/restores/resets both, and a save that predates the tab
defaults to "current system charted, nothing traveled" (old saves load). defaults to "current system charted, nothing traveled" (old saves load).
**FACTIONS (planned — not yet implemented):** the snapshot already **FACTIONS (the worldgen layer is LIVE — data/factions.json):** the
reserves the seam — every system carries `faction: null`, and roster's faction pass (`js/galaxy/Galaxy.js → _assignFactions`) assigns
`GalaxyView`'s header documents the two reserved rendering passes for **territories** — each top-level BRANCH of the jump tree (a subtree
when they ship: a **faction color layer** over the stars (a ring/tint on rooted at one of home's gate neighbors) is one faction's territory,
top of the archetype color) and a **territory region fill** (the exact starting one system into the branch and stopping one before its
hull/fill recipe of the charted region, per faction color + relation dead-end tips (the ring right outside home + the branch tips stay
alpha). The settlements' `owner: null` seam (above) + neutral wild space) — and designates the **four capitals**: the NEUTRAL
`Reputation.factionStanding()` are the data-side counterparts. capital (always a direct gate neighbor of home, its system stays
neutral) + one faction capital per faction (apex / mindweave / zenith,
middle band, seeded). The branch↔faction order is a SEEDED SHUFFLE — a
new galaxy reshuffles who sits where; same seed ⇒ identical. Each
capital's system grows its capital planet (class `capital*`,
planets.png frames 18..21 — data/planets.json → capitals; the
`capital` settlement — data/settlements.json; `owner` = the faction —
the reputation seam). The plate's FACTION LAYER (js/ui/GalaxyView.js →
`_redrawFactions`, data/map.json → galaxy.factions) rings every
territory system in its faction's color and marks the capitals with the
double-ring seat. The 'Leaving the Nest' quest rides it: Trestle's last
brief (data/comms.json: trestle-brief-3 — "this star cluster's capital")
sets the player's destination to the neutral capital's seat on the
acknowledgment (js/comms/CommsHub.js → onEnd, js/scenes/GameScene.js →
_commEnded, js/galaxy/Galaxy.js → neutralCapitalTarget()). The
cross-branch BRIDGES (data/gates.json → bridges; the bridge pass in
js/galaxy/JumpNetwork.js) link the territories through the middle belt
so the factions can reach — and fight — each other without routing
through the trunk.
The next layer: the
per-faction territory-region FILL (this file's hull pass, colored per
faction) + the relation tiers (`Reputation.factionStanding()` —
the standing data) + reachability / combat / trade off the territories.
`Reputation.factionStanding()` is the data-side counterpart.
- **Tuning** — all of it in `data/map.json → galaxy` (stars, pulse per - **Tuning** — all of it in `data/map.json → galaxy` (stars, pulse per
archetype, edges, hull, flow, dust, labels, zoom, reveal, dialog copy). archetype, edges, hull, flow, dust, labels, zoom, reveal, dialog copy).
- **Tests** — `dev/galaxy-map.test.mjs` (edgeKey symmetry, the snapshot's - **Tests** — `dev/galaxy-map.test.mjs` (edgeKey symmetry, the snapshot's
systems/lanes/flags + the faction seam, hull/padding geometry incl. systems/lanes/flags (incl. the faction data + bridge lanes), hull/padding geometry incl.
the parallel-polygon corners, the pulse's bounds + determinism, the the parallel-polygon corners, the pulse's bounds + determinism, the
archetype colors, and the save round-trip incl. the legacy default). archetype colors, and the save round-trip incl. the legacy default).
@ -995,7 +1024,7 @@ incl. remaining-time preservation), `dev/research-builds.test.mjs`
the build carries it), `dev/saves.test.mjs` (the builds field rides the the build carries it), `dev/saves.test.mjs` (the builds field rides the
pending restore; legacy records load clean). pending restore; legacy records load clean).
## Reputation — standing on planets & space stations (data layer; factions later) ## Reputation — standing on planets & space stations (data layer)
The player holds a REPUTATION (standing) on each planet and space station: The player holds a REPUTATION (standing) on each planet and space station:
- **Scale** — `data/reputation.json`: `min…max` (−20…+20, max = best), - **Scale** — `data/reputation.json`: `min…max` (−20…+20, max = best),
@ -1004,13 +1033,17 @@ The player holds a REPUTATION (standing) on each planet and space station:
- **The home-world exception** — the player's home world is ALWAYS +20: - **The home-world exception** — the player's home world is ALWAYS +20:
pinned. Nothing sets, changes, or clears it (it's not even storable — pinned. Nothing sets, changes, or clears it (it's not even storable —
the key is derived from the scale itself). the key is derived from the scale itself).
- **The faction check (placeholder)** — `Reputation.standingFor(place)` - **The faction check** — `Reputation.standingFor(place)` resolves:
resolves: **home → stored standing → the place's `owner` → faction **home → stored standing → the place's `owner` → faction
standing → neutral**. `owner` is the settlements' reserved seam (always standing → neutral**. `owner` is now POPULATED by the worldgen layer
`null` until factions exist) and `Reputation.factionStanding(id)` is a (data/factions.json — the roster's faction pass sets a settlement's
marked `TODO(factions)` stub returning null — so today the whole galaxy owner to its system's faction; neutral/wild space stays owner-less) so
reads neutral (home: +20). When factions land: populate `owner` in faction-controlled places resolve through the check; the per-faction
generation + fill `factionStanding()`; the resolution order is final. standing itself is still a marked `TODO(factions)` stub —
`Reputation.factionStanding(id)` returns null (no standing with any
faction yet — the player's real relations land there; the resolution
order is final), so today a faction-controlled place still reads
neutral (home: +20).
- **The mutation seams** — `set(key, value)` / `change(key, delta)`, both - **The mutation seams** — `set(key, value)` / `change(key, delta)`, both
clamped to the scale; "ways to influence reputation" land on these. clamped to the scale; "ways to influence reputation" land on these.
- **Place identity** — every planet and settlement now carries a stable - **Place identity** — every planet and settlement now carries a stable

View File

@ -73,6 +73,10 @@ export class CommsHub {
* is on the run's played-ledger * is on the run's played-ledger
* already — scene bookkeeping * already — scene bookkeeping
* that rides on a comm starting) * that rides on a comm starting)
* onEnd?: (comm: object) => void, the comm ENDED (for 'ack' comms
* the ACKNOWLEDGE press itself —
* scene bookkeeping that rides on
* a comm being acknowledged)
* }} [o] * }} [o]
*/ */
constructor(scene, o = {}) { constructor(scene, o = {}) {
@ -83,6 +87,7 @@ export class CommsHub {
this.anchor = typeof o.anchor === 'function' ? o.anchor : null; this.anchor = typeof o.anchor === 'function' ? o.anchor : null;
this.onPause = typeof o.onPause === 'function' ? o.onPause : null; this.onPause = typeof o.onPause === 'function' ? o.onPause : null;
this.onStart = typeof o.onStart === 'function' ? o.onStart : null; this.onStart = typeof o.onStart === 'function' ? o.onStart : null;
this.onEnd = typeof o.onEnd === 'function' ? o.onEnd : null;
this.queue = []; // { def, dueAt, assets, loadingDone } this.queue = []; // { def, dueAt, assets, loadingDone }
this.current = null; // the comm in play (its def) this.current = null; // the comm in play (its def)
@ -350,6 +355,7 @@ export class CommsHub {
/** The comm is over: tear down, unpause, hand the next one its turn. */ /** The comm is over: tear down, unpause, hand the next one its turn. */
_finish() { _finish() {
if (!this.current) return; if (!this.current) return;
const def = this.current;
this._stopVoice(); this._stopVoice();
this._duck(false); this._duck(false);
this._setPaused(false); this._setPaused(false);
@ -359,6 +365,17 @@ export class CommsHub {
this.current = null; this.current = null;
this._voiceDone = true; this._voiceDone = true;
if (box) box.close(); // the fade-out; the hub moves on (the next pump) if (box) box.close(); // the fade-out; the hub moves on (the next pump)
// The scene's bookkeeping for the ENDED comm (for 'ack' comms this
// fires on the ACKNOWLEDGE press — GameScene rides it: Trestle's
// last brief sets the player's destination). A failing hook must
// not hold the queue.
if (this.onEnd) {
try {
this.onEnd(def);
} catch (e) {
console.warn('[comms] onEnd hook failed', e);
}
}
} }
/** Tear everything down (scene shutdown / destroy). Idempotent. */ /** Tear everything down (scene shutdown / destroy). Idempotent. */

View File

@ -55,9 +55,13 @@ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
* in any order of generation. Dev/test tools rely on this. * in any order of generation. Dev/test tools rely on this.
* *
* Extension points for later world rules (see docs/PROJECT_NOTES.md): * Extension points for later world rules (see docs/PROJECT_NOTES.md):
* - FACTIONS: assign each system a faction id (or null = wild space) * - FACTIONS: ASSIGNED AT ROSTER TIME by the faction pass (_assignFactions,
* at roster time — the record is the seam (reputation's `owner` * data/factions.json) — each system's record carries `faction` (or
* resolution and the galaxy plate's `faction: null` are reserved). * null = neutral wild space) + `capital` (the four designated
* capitals: neutral + apex/mindweave/zenith). The reputation seam
* (js/reputation/Reputation.js → factionStanding) and the galaxy
* plate's faction ring layer (js/ui/GalaxyView.js) read them; reachability
* / combat / trade off the territories is the next layer.
* - COMBAT / TRADE: read difficulty and price levels off `record.d` * - COMBAT / TRADE: read difficulty and price levels off `record.d`
* and `record.zone` (the middle zone is where the zone borders cross * and `record.zone` (the middle zone is where the zone borders cross
* — the planned contested space + transit trade hubs). * — the planned contested space + transit trade hubs).
@ -181,8 +185,11 @@ export class Galaxy {
// never leak into it. d (0 = home corner, 1 = far corner) and zone // never leak into it. d (0 = home corner, 1 = far corner) and zone
// stay on the record: the settlement generator uses d (home→far // stay on the record: the settlement generator uses d (home→far
// density), and factions/trade/combat will read difficulty off both. // density), and factions/trade/combat will read difficulty off both.
// faction/capital default to null (neutral wild space, no capital)
// — the faction pass (_assignFactions) stamps the territories +
// capital designations after the jump network exists.
const name = NameGenerator.star(Rng.derive(this.seed, 'name', id)); const name = NameGenerator.star(Rng.derive(this.seed, 'name', id));
const rec = { id, name, type, x: p.x, y: p.y, d, zone }; const rec = { id, name, type, x: p.x, y: p.y, d, zone, faction: null, capital: null };
records.push(rec); records.push(rec);
this.byId.set(id, rec); this.byId.set(id, rec);
} }
@ -253,14 +260,18 @@ export class Galaxy {
} }
// The JUMP NETWORK (data/gates.json): which star each system's jump // The JUMP NETWORK (data/gates.json): which star each system's jump
// gates reach. A PURE SPANNING TREE of the nearest-star graph (no // gates reach. A degree-limited spanning TREE of the nearest-star
// shortcuts — data/gates.json → shortcuts:false) — the galaxy reads as // graph — the galaxy reads as a MAZE: (nearly) one route between any
// a MAZE: exactly one route between any two systems, no closed loops. // two systems, dead ends at the leaves — and every barren system is
// Tree edges run BOTH ways, so from any system the player can reach // a dead-end LEAF (exactly one gate, in and out the same way). The
// any other and every jump has a RETURN gate (no trapped sets). Each // deliberate exception: the BRIDGES (data/gates.json → bridges)
// system holds 1–maxGates gates (its tree degree); a BARREN system is // cross-link the branches' middle belts, so one branch doesn't have
// a dead-end LEAF — exactly one gate (in and out the same way). // to trek back to the trunk (home) to reach another (the faction
// Deterministic: same roster ⇒ same network. // design — data/factions.json). Tree edges run BOTH ways, so from any
// system the player can reach any other and every jump has a RETURN
// gate (no trapped sets). Each system holds 1–maxGates gates (its
// degree, bridges included). Deterministic: same roster ⇒ same
// network.
const pool = Math.max(1, Math.min(count - 1, (config.get('gates.neighborPool', 8) | 0))); const pool = Math.max(1, Math.min(count - 1, (config.get('gates.neighborPool', 8) | 0)));
this.jumpNetwork = buildJumpNetwork({ this.jumpNetwork = buildJumpNetwork({
records, records,
@ -270,6 +281,7 @@ export class Galaxy {
shortcuts: config.get('gates.shortcuts', true) === true, shortcuts: config.get('gates.shortcuts', true) === true,
barren, barren,
rootId: this.currentSystemId, rootId: this.currentSystemId,
bridges: config.get('gates.bridges', null),
}); });
// Defensive repairs that had to fire (0 on a healthy kNN graph). // Defensive repairs that had to fire (0 on a healthy kNN graph).
this.jumpNetworkRepaired = this.jumpNetwork.repaired; this.jumpNetworkRepaired = this.jumpNetwork.repaired;
@ -277,6 +289,15 @@ export class Galaxy {
console.warn(`[orbit] jump network: ${this.jumpNetworkRepaired} system(s) needed a repair attach`); console.warn(`[orbit] jump network: ${this.jumpNetworkRepaired} system(s) needed a repair attach`);
} }
// FACTIONS (data/factions.json — the faction pass): branch
// territories + the four CAPITAL designations (neutral + apex /
// mindweave / zenith), stamped on the roster records
// (record.faction / record.capital). Runs after the jump network
// (it needs the branches the tree defines) and before the frame
// pass (independent of it — the capital planet is not a
// composition planet, so the frame pass never sees it).
this._assignFactions(barren);
// FRAME DIVERSITY (js/galaxy/PlanetFrames.js): the galaxy-wide // FRAME DIVERSITY (js/galaxy/PlanetFrames.js): the galaxy-wide
// (class, frame) assignment — each planet's sheet frame avoids what // (class, frame) assignment — each planet's sheet frame avoids what
// the NEAREST stars already wear for that class, so the same face is // the NEAREST stars already wear for that class, so the same face is
@ -312,6 +333,170 @@ export class Galaxy {
this.stationFrames = stationFrames; // Map id → frame (station-bearing systems) this.stationFrames = stationFrames; // Map id → frame (station-bearing systems)
} }
/**
* The FACTION PASS — branch territories + the four capital designations
* (data/factions.json; see docs/PROJECT_NOTES.md "Factions"). Runs at
* roster build, before any content generation, so the stamp is pure
* function of the seed (lazy === eager: content generation only READS
* the record fields it leaves behind):
*
* BRANCHES — the jump tree's top-level subtrees, rooted at the home
* system's gate neighbors (home has ≤ maxGates gates, so ≤ 3).
* NEUTRAL CAPITAL — a seeded pick among home's gate neighbors (the
* "always connected to the home star" rule); its system stays
* faction-NEUTRAL even though it roots one faction's branch
* (a neutral hub at the edge of that corridor).
* FACTION CAPITALS — one per faction (apex / mindweave / zenith): a
* SEEDED pick inside that faction's branch, preferred in the middle
* band (d in 0.37..0.63 — the home→far diagonal's middle slice),
* on a non-barren system (it must be able to host a planet). The
* branch↔faction assignment is a SEEDED SHUFFLE — a new galaxy
* reshuffles who sits where (never a fixed order), same seed ⇒
* identical placement.
* TERRITORY — a branch's systems at depth 2 … (maxDepth−1) (home =
* depth 0, its gate neighbors = depth 1): the ring right outside
* home and every branch's dead-end tips stay NEUTRAL WILD SPACE;
* anything not in a territory is neutral. A faction capital always
* sits in its faction's territory (fallbacks only when the branch
* is too short to have one).
*
* @param {Set<string>|null} barren the gate-only dead ends (excluded from
* capital candidacy — they can't host a world)
*/
_assignFactions(barren) {
const FACTIONS = ['apex', 'mindweave', 'zenith'];
const dMin = 0.37, dMax = 0.63; // the middle band (data/galaxy.json zones)
const homeId = this.homeSystemId;
// Branch + depth of every system — from the JUMP NETWORK's own
// spanning tree (js/galaxy/JumpNetwork.js exposes branches/branchOf/
// depth): the bridge pass and the faction pass must agree on the SAME
// branch structure. (A BFS over the final gate list — tree + bridges
// — can re-parent systems across branches, since the bridges are
// loops; that is NOT the tree's branch structure.) The fallback below
// is only for a network that lacks the export (defensive).
const netBranchOf = this.jumpNetwork?.branchOf instanceof Map ? this.jumpNetwork.branchOf : null;
const netDepth = this.jumpNetwork?.depth instanceof Map ? this.jumpNetwork.depth : null;
let branchOf, depth;
if (netBranchOf && netDepth) {
branchOf = netBranchOf;
depth = netDepth;
} else {
const gates = this.jumpNetwork?.gates ?? new Map();
depth = new Map([[homeId, 0]]);
branchOf = new Map(); // id → its branch-root id (a home neighbor)
const q = [homeId];
while (q.length) {
const u = q.shift();
const du = depth.get(u);
for (const v of gates.get(u) ?? []) {
if (depth.has(v)) continue;
depth.set(v, du + 1);
branchOf.set(v, du === 0 ? v : branchOf.get(u));
q.push(v);
}
}
}
const branchRoots = (this.jumpNetwork?.branches ?? [...new Set(branchOf.values())]).slice().sort();
if (branchRoots.length === 0) {
// One-system galaxy (nothing connected to home) — no branches,
// no capitals, no territories. The galaxy still generates.
this.factions = null;
return;
}
// Per-branch node lists + the branch's max depth (territory bounds).
const nodesOf = new Map(branchRoots.map((b) => [b, []]));
for (const [id, b] of branchOf) nodesOf.get(b).push(id);
const maxDepthOf = new Map();
for (const b of branchRoots) {
let m = 1;
for (const id of nodesOf.get(b)) m = Math.max(m, depth.get(id) ?? 1);
maxDepthOf.set(b, m);
}
// Territory = the branch's middle systems (depth 2 … maxDepth−1):
// the depth-1 ring at home and the branch tips stay neutral.
const inTerritory = (id, b) => {
const d = depth.get(id) ?? 0;
return d >= 2 && d <= (maxDepthOf.get(b) ?? 1) - 1;
};
const inBand = (id) => {
const rec = this.byId.get(id);
return !!rec && rec.d >= dMin && rec.d <= dMax;
};
const isBarren = (id) => barren instanceof Set && barren.has(id);
// NEUTRAL CAPITAL — one of home's gate neighbors (seeded pick).
const neutralId = Rng.derive(this.seed, 'factions', 'neutral').pick(branchRoots);
// Branch↔faction: a SEEDED SHUFFLE of the three factions across the
// branches (fewer than three branches ⇒ factions share one — the
// "nice to have" relaxation; their capitals still differ).
const assign = Rng.derive(this.seed, 'factions', 'assign').shuffle(FACTIONS);
const branchFaction = new Map();
branchRoots.forEach((b, i) => branchFaction.set(b, assign[i % assign.length]));
// FACTION CAPITALS — one per faction, inside its branch (seeded).
const taken = new Set([neutralId]);
const capitals = {};
for (const f of FACTIONS) {
const b = branchRoots.find((x) => branchFaction.get(x) === f);
if (!b) continue;
const nodes = nodesOf.get(b) ?? [];
const free = (ids) => ids.filter((id) => !taken.has(id) && !isBarren(id));
// Tiered candidacy: middle-band territory → any territory → any
// free branch system (tiny-branch fallback — a working capital
// beats no capital).
let cands = free(nodes).filter((id) => inTerritory(id, b) && inBand(id));
if (cands.length === 0) cands = free(nodes).filter((id) => inTerritory(id, b));
if (cands.length === 0) cands = free(nodes);
if (cands.length === 0) cands = branchRoots.filter((id) => id !== neutralId);
const capitalId = Rng.derive(this.seed, 'factions', 'capital', f).pick(cands);
if (!capitalId) continue;
const rec = this.byId.get(capitalId);
if (rec) {
rec.capital = f;
rec.faction = f; // its seat — even on a fallback outside the territory
}
taken.add(capitalId);
capitals[f] = capitalId;
}
// The neutral capital's stamp (its system stays faction-neutral).
{
const rec = this.byId.get(neutralId);
if (rec) {
rec.capital = 'neutral';
rec.faction = null;
}
}
// TERRITORIES — the branch's middle systems take its faction;
// the neutral hub is explicitly exempt, everything else untouched
// (neutral wild space).
for (const b of branchRoots) {
const f = branchFaction.get(b);
for (const id of nodesOf.get(b) ?? []) {
if (id === neutralId) continue;
if (inTerritory(id, b)) {
const rec = this.byId.get(id);
if (rec && !rec.capital) rec.faction = f; // a capital keeps its own
}
}
}
// The pass's output (UI/tests read this; the records carry the rest).
this.factions = {
neutralCapital: neutralId,
capitals,
branches: branchRoots,
branchFaction: Object.fromEntries(branchFaction),
};
const missing = FACTIONS.filter((f) => !capitals[f]);
if (missing.length > 0) {
console.warn(`[orbit] factions: no capital placed for ${missing.join(', ')} (degenerate branch layout)`);
}
}
/** /**
* The home→far zones (data/galaxy.json → distribution.zones), * The home→far zones (data/galaxy.json → distribution.zones),
* validated and sorted by `d`. A single fallback zone when the config * validated and sorted by `d`. A single fallback zone when the config
@ -541,6 +726,30 @@ export class Galaxy {
return this.contentCache.size; return this.contentCache.size;
} }
/**
* The NEUTRAL CAPITAL as a destination target (data/factions.json —
* the faction pass's designation): the system id + the capital world's
* object id. The OBJECT vocabulary is the map's discoveryId (js/scenes/
* GameScene.js: planets carry `discoveryId = rec.name`) — the same id
* MapWindow's SET DESTINATION passes and the arrival/compass/plate
* lookups read, so the capital world is the final stop the same way a
* clicked planet would be. The capital world is the system's OUTERMOST
* planet (js/galaxy/SystemGenerator.js converts it: `p.capital`). The
* 'Leaving the Nest' quest's last brief (data/comms.json:
* trestle-brief-3 — Trestle "sends the coordinates") hands this to the
* scene's setDestination on acknowledgment (js/scenes/GameScene.js →
* _commEnded).
* @returns {{systemId: string, objectId: (string|null)}|null} null when
* the faction pass never designated one (degenerate one-system galaxy)
*/
neutralCapitalTarget() {
const sysId = this.factions?.neutralCapital;
if (!sysId || !this.byId.has(sysId)) return null;
const content = this.ensureContent(sysId);
const cap = (content.planets ?? []).find((p) => p.capital) ?? null;
return { systemId: sysId, objectId: cap ? cap.name : null };
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
/** Debug/console summary. */ /** Debug/console summary. */
@ -557,6 +766,17 @@ export class Galaxy {
byZone, byZone,
generated: this.generatedCount, generated: this.generatedCount,
currentSystemId: this.currentSystemId, currentSystemId: this.currentSystemId,
// FACTIONS (the faction pass — data/factions.json): branch map +
// the four capital system ids (a null capital = placement failed
// for that faction, logged at roster build).
factions: this.factions ? {
neutralCapital: this.factions.neutralCapital,
capitals: this.factions.capitals,
branches: this.factions.branches,
branchFaction: this.factions.branchFaction,
} : null,
jumpNetworkEdges: this.jumpNetwork ? [...this.jumpNetwork.gates.values()].reduce((n, t) => n + t.length, 0) : null,
jumpNetworkBridges: this.jumpNetwork?.bridges ?? [],
}; };
} }
} }

View File

@ -6,13 +6,17 @@
* joined with '<' (save-stable: the same lane * joined with '<' (save-stable: the same lane
* from either end) * from either end)
* buildGalaxySnapshot(o) the live GALAXY plate data — the systems * buildGalaxySnapshot(o) the live GALAXY plate data — the systems
* (position, type, visited), the dedup'd jump * (position, type, visited, faction, capital),
* lanes (used / frontier / live), and the * the dedup'd jump lanes (used / frontier /
* readout stats. Per-system `faction: null` * live / bridge), and the readout stats.
* is the FACTIONS seam (planned — see * Per-system `faction` / `capital` are the
* docs/PROJECT_NOTES.md "Factions"): the * FACTIONS layer (data/factions.json — the
* plate already reserves a faction color * roster's faction pass, js/galaxy/Galaxy.js):
* layer + a territory-region pass for it. * the plate's faction ring layer + capital
* rings read them (js/ui/GalaxyView.js);
* each edge's `bridge` marks the cross-branch
* middle-belt lanes (data/gates.json →
* bridges) for the plate's lane styling.
* convexHull(points) monotone-chain hull (interior + collinear * convexHull(points) monotone-chain hull (interior + collinear
* points dropped) * points dropped)
* paddedHullPolygon(p, pad) the hull inflated by `pad` world-px (the * paddedHullPolygon(p, pad) the hull inflated by `pad` world-px (the
@ -83,17 +87,30 @@ export function buildGalaxySnapshot({ galaxy, visited = null, used = null, live
// it (GalaxyView) so the "where am I going" target is unmistakable. // it (GalaxyView) so the "where am I going" target is unmistakable.
isDestination: s.id === destinationId, isDestination: s.id === destinationId,
gates: network?.gates?.get?.(s.id)?.length ?? 0, gates: network?.gates?.get?.(s.id)?.length ?? 0,
// FACTIONS (planned — not yet implemented): the system's faction // FACTIONS (data/factions.json — the roster's faction pass,
// id + the player's relation tier (Neutral / Friendly / Aligned / // js/galaxy/Galaxy.js): the system's faction id (null = neutral wild
// Hostile) will land here. The galaxy plate reserves a faction // space) + its CAPITAL designation (neutral / apex / mindweave /
// color layer on the stars + a territory-region fill (the same // zenith — null = not a capital). The plate's faction ring layer
// hull pass as the charted region) for when it ships. // (faction color from data/factions.json → factions) + the capital
faction: null, // rings (js/ui/GalaxyView.js) read them; the relation tier
// (Neutral / Friendly / Aligned / Hostile) is the next layer —
// the reputation seam (js/reputation/Reputation.js →
// factionStanding).
faction: s.faction ?? null,
capital: s.capital ?? null,
}); });
} }
const ids = new Set(systems.map((s) => s.id)); const ids = new Set(systems.map((s) => s.id));
const seen = new Set(); const seen = new Set();
// BRIDGES (data/gates.json → bridges — the cross-branch middle-belt
// lanes): the set of bridge lane keys, for the edges' `bridge` mark
// below (the plate's lane styling distinguishes the trunk maze from
// the inter-branch links).
const bridgeKeys = new Set();
for (const br of network?.bridges ?? []) {
if (br && br[0] && br[1]) bridgeKeys.add(edgeKey(br[0], br[1]));
}
const edges = []; const edges = [];
for (const [a, dests] of network?.gates ?? []) { for (const [a, dests] of network?.gates ?? []) {
if (!ids.has(a) || !Array.isArray(dests)) continue; if (!ids.has(a) || !Array.isArray(dests)) continue;
@ -123,6 +140,10 @@ export function buildGalaxySnapshot({ galaxy, visited = null, used = null, live
live: live:
currentSystemId != null && currentSystemId != null &&
(liveSet.has(`${currentSystemId}>${lo}`) || liveSet.has(`${currentSystemId}>${hi}`)), (liveSet.has(`${currentSystemId}>${lo}`) || liveSet.has(`${currentSystemId}>${hi}`)),
// BRIDGE — a cross-branch middle-belt lane (data/gates.json →
// bridges): the "don't go back to the trunk" links between
// faction territories (data/factions.json).
bridge: bridgeKeys.has(key),
}); });
} }
} }

View File

@ -21,6 +21,30 @@
* galaxy reads as a maze of dead ends and long hauls — and * galaxy reads as a maze of dead ends and long hauls — and
* every barren system (the `barren` set, gate-only dead ends) * every barren system (the `barren` set, gate-only dead ends)
* is a LEAF: exactly one gate, in and out the same way. * is a LEAF: exactly one gate, in and out the same way.
* BRIDGES — the deliberate exception to the maze (data/gates.json →
* bridges): cross-branch links in the MIDDLE belt of the
* galaxy. A branch is a top-level subtree of the tree (a
* subtree rooted at one of the home system's gate
* neighbors); without bridges, branch-to-branch travel means
* ALL the way back to the trunk (home). For each pair of
* branches, `perPair` bidirectional gate-pairs join two
* systems of the middle belt (record.d in dMin..dMax), so
* the branches interconnect around the middle of the galaxy
* (a triangle, for three branches) and a branch-to-branch
* trip is ONE EXTRA HOP, not a trek to the trunk — the
* faction design's "reach each other without routing through
* home" (data/factions.json). Hard rules: both ends in each
* other's neighbor pool (the LOCAL rule — a bridge is still
* a gate to a NEAR star, like any other), both ends
* non-barren (a dead end keeps its single gate), both ends
* within the maxGates budget (the cap never breaks). Soft
* rule: the middle-belt preference — an out-of-band pool
* pair is the fallback; a pair with NO pool candidate
* degrades (a console note — the maze still connects them).
* Each bridge runs BOTH ways (a return gate at each end —
* the "every jump has a return gate" rule), so planRoute's
* Dijkstra simply prefers the shorter of bridge vs trunk —
* the maze gains loops by design, nothing else changes.
* PURE — no Math.random: every tie is broken by index. Same seed * PURE — no Math.random: every tie is broken by index. Same seed
* ⇒ same network (deterministic across machines and runs). * ⇒ same network (deterministic across machines and runs).
* *
@ -46,6 +70,12 @@
* budget (nodes under maxGates) buys extra one-way local edges — the * budget (nodes under maxGates) buys extra one-way local edges — the
* web, not just the roads. Adding edges never removes reachability, * web, not just the roads. Adding edges never removes reachability,
* so the strong connectivity survives. * so the strong connectivity survives.
* 5. Optional BRIDGES (ON in the current config): the cross-branch
* middle-belt links described above — the "don't go back to the
* trunk" routes. They add loops to the tree; everything downstream
* (planRoute's Dijkstra, the strong-connectivity proof, the gate
* counts) already copes, because a bridge is just two more local
* gates.
* *
* The repair pass (below) is defensive: it fires only if the neighbor * The repair pass (below) is defensive: it fires only if the neighbor
* graph is disconnected (effectively impossible at this scale), and it * graph is disconnected (effectively impossible at this scale), and it
@ -69,13 +99,33 @@
* (objectCount → 0, from the composition roll): a barren node never * (objectCount → 0, from the composition roll): a barren node never
* adopts children, so it ends up a tree LEAF — exactly one gate, in and * adopts children, so it ends up a tree LEAF — exactly one gate, in and
* out the same way (the maze's dead ends). The repair pass prefers to * out the same way (the maze's dead ends). The repair pass prefers to
* attach leftovers to non-barren nodes. * attach leftovers to non-barren nodes, and a barren system is never a
* bridge endpoint.
* @param {string|null} [o.rootId] — grow the tree from this system (the home * @param {string|null} [o.rootId] — grow the tree from this system (the home
* system) — it then keeps the lowest possible degree. * system) — it then keeps the lowest possible degree. Branches (for the
* @returns {{ gates: Map<string, string[]>, repaired: number }} * bridge pass) are the subtrees rooted at this system's gate neighbors.
* @param {object|null} [o.bridges] — the bridge config (data/gates.json →
* bridges): { enabled, perPair, dMin, dMax }. When enabled, perPair
* bidirectional gate-pairs link each pair of branches through the
* middle belt (record.d in dMin..dMax), under the LOCAL + BUDGET rules.
* @returns {{ gates: Map<string, string[]>, repaired: number, bridges: Array<[string, string]>, branches: string[], branchOf: Map<string, string>, depth: Map<string, number> }}
* gates: system id → ordered list of gate destinations (parent edge * gates: system id → ordered list of gate destinations (parent edge
* first — "the road home" — then children/shortcuts). * first — "the road home" — then children/shortcuts, then the bridge
* gates).
* repaired: systems that needed the defensive attach (0 on real data). * repaired: systems that needed the defensive attach (0 on real data).
* bridges: the bridge pairs actually placed (each pair = one gate on
* each end; both ends' gate lists carry the other).
* branches: the tree's top-level branch roots (the home system's gate
* neighbors), sorted — the faction pass's branch vocabulary
* (data/factions.json).
* branchOf: system id → its branch root id (the depth-1 ancestor on the
* SPANNING TREE — the exact tree the bridge pass used, NOT a BFS over
* the final gate list: the bridges add loops, and a BFS over the final
* graph can re-parent systems across branches, which is NOT the
* branch structure the faction pass assigns).
* depth: system id → tree depth from home (0 = home, 1 = the ring
* outside home) — the territory rule's bounds (depth 2 … maxDepth−1)
* are measured on THIS tree.
*/ */
export function buildJumpNetwork({ export function buildJumpNetwork({
records, records,
@ -85,6 +135,7 @@ export function buildJumpNetwork({
shortcuts = true, shortcuts = true,
barren = null, barren = null,
rootId = null, rootId = null,
bridges = null,
}) { }) {
const n = records.length; const n = records.length;
const isBarren = (i) => barren instanceof Set && barren.has(records[i].id); const isBarren = (i) => barren instanceof Set && barren.has(records[i].id);
@ -119,7 +170,7 @@ export function buildJumpNetwork({
if (n <= 1) { if (n <= 1) {
const gates = new Map(); const gates = new Map();
for (const r of records) gates.set(r.id, []); for (const r of records) gates.set(r.id, []);
return { gates, repaired: 0 }; return { gates, repaired: 0, bridges: [], branches: [], branchOf: new Map(), depth: new Map() };
} }
const root = rootId ? iOf(rootId) : 0; const root = rootId ? iOf(rootId) : 0;
@ -276,14 +327,123 @@ export function buildJumpNetwork({
} }
} }
// --- Branches + tree depth (the faction pass's vocabulary) ----------
// A BRANCH is a top-level subtree of the spanning tree: the subtree
// rooted at one of the home system's gate neighbors (depth 1). Walk the
// parent chain up to the root's child: the tree is acyclic, so this is
// exact. Exposed (branches / branchOf / depth below) because the
// faction pass (js/galaxy/Galaxy.js → _assignFactions) + the bridge
// pass must agree on the SAME branch structure — a BFS over the FINAL
// gate list (tree + bridges) can re-parent systems across branches
// (the bridges are loops), which is not the tree's branch structure.
const branchOfArr = new Int32Array(n).fill(-1);
const depthArr = new Int32Array(n).fill(-1);
depthArr[root] = 0;
for (let i = 0; i < n; i++) {
if (i === root || parent[i] < 0) continue;
let b = i;
while (parent[b] >= 0 && parent[b] !== root) b = parent[b];
if (parent[b] === root) branchOfArr[i] = b;
}
for (let i = 0; i < n; i++) {
if (i === root) continue;
let d = 0;
let cur = i;
while (parent[cur] >= 0) { d++; cur = parent[cur]; }
if (cur === root) depthArr[i] = d;
}
const branchList = [];
{
const seen = new Set();
for (let i = 0; i < n; i++) {
const b = branchOfArr[i];
if (b !== -1 && !seen.has(b)) {
seen.add(b);
branchList.push(b);
}
}
}
branchList.sort((a, b) => a - b);
// --- Bridges: the cross-branch middle-belt links (data/gates.json →
// bridges — see the BRIDGES constraint above) --------------------------
// Two hard constraints, both preserved: LOCAL (both ends sit in each
// other's neighbor pool — `nbr`, the symmetric union: a bridge is still
// a gate to a NEAR star, like any other) and BUDGET (both ends keep
// degree ≤ maxGates — the cap never breaks; a barren dead end is never
// an endpoint — it keeps its single gate). Soft: both ends in the
// middle belt (record.d in dMin..dMax); an out-of-band pool pair is the
// fallback, and a pair with NO pool candidate degrades (a console note).
// The bridge list drives the gate assembly below; each bridge runs BOTH
// ways (the return-gate rule) — planRoute's Dijkstra picks the shorter
// of bridge vs trunk automatically, so nothing else changes.
const bridgesPlaced = [];
const bCfg = bridges && bridges.enabled !== false ? bridges : null;
const perPair = bCfg ? Math.max(0, Math.floor(Number(bCfg.perPair) || 0)) : 0;
if (perPair > 0 && n > 1 && root >= 0) {
const dOf = (i) => (Number.isFinite(Number(records[i].d)) ? Number(records[i].d) : 0.5);
const dMin = Number(bCfg.dMin ?? 0.37);
const dMax = Number(bCfg.dMax ?? 0.63);
const inBand = (i) => dOf(i) >= dMin && dOf(i) <= dMax;
for (let bi = 0; bi < branchList.length; bi++) {
for (let bj = bi + 1; bj < branchList.length; bj++) {
const A = branchList[bi], B = branchList[bj];
// Candidate pool: every (u in branch A, v in branch B) whose ends
// are LOCAL (v ∈ nbr[u]) — non-barren, never the home root. Sort:
// middle-belt pairs first (the soft preference), then nearest,
// then indices (determinism).
const cands = [];
for (let u = 0; u < n; u++) {
if (branchOfArr[u] !== A || isBarren(u) || u === root) continue;
for (const v of nbr[u]) {
if (branchOfArr[v] !== B || isBarren(v) || v === root) continue;
cands.push({ u, v, band: inBand(u) && inBand(v), d: dist2(u, v) });
}
}
cands.sort((p, q) =>
(p.band === q.band ? 0 : p.band ? -1 : 1) ||
p.d - q.d ||
p.u - q.u ||
p.v - q.v);
// Prefer two DISTINCT endpoints (two real alternative routes);
// only reuse an endpoint on the second pass (budget left over —
// the "two bridges per pair" redundancy degrades gracefully).
const usedEnds = new Set();
let added = 0;
for (let pass = 0; pass < 2 && added < perPair; pass++) {
for (const c of cands) {
if (added >= perPair || c.added) continue;
if (pass === 0 && (usedEnds.has(c.u) || usedEnds.has(c.v))) continue;
if (deg[c.u] >= maxGates || deg[c.v] >= maxGates) continue; // BUDGET
c.added = true;
deg[c.u]++;
deg[c.v]++;
usedEnds.add(c.u);
usedEnds.add(c.v);
bridgesPlaced.push([records[c.u].id, records[c.v].id]);
added++;
}
}
if (added < perPair && cands.length === 0) {
// No LOCAL candidate at all for this pair — the pair degrades
// to the maze (the tree still connects the two branches via the
// trunk). A budget shortfall (cands exist, none fit) is the
// normal, designed degradation and stays quiet.
console.warn(`[orbit] jump network: no bridge pool between branches ${records[A].id} and ${records[B].id}`);
}
}
}
}
// --- Assemble ---------------------------------------------------------- // --- Assemble ----------------------------------------------------------
// Each node's gates = the tree edges touching it — its parent first ("the // Each node's gates = the tree edges touching it — its parent first ("the
// road home"), then its tree children in index order — plus its // road home"), then its tree children in index order — plus its
// shortcuts. A bidirected spanning tree is strongly connected by // shortcuts, then its bridge gates. A bidirected spanning tree is strongly
// construction (the unique tree path between any two systems is walkable // connected by construction (the unique tree path between any two systems
// in both directions), so every system both reaches and is reachable; // is walkable in both directions), so every system both reaches and is
// every node's gate count is its tree degree (≥ 1 for n > 1, ≤ maxGates) // reachable; the bridges add loops on top (Dijkstra then prefers the
// plus any shortcuts it bought. // shorter of the two routes); every node's gate count is its tree degree
// (≥ 1 for n > 1, ≤ maxGates) plus any shortcuts/bridges it bought.
const children = Array.from({ length: n }, () => []); const children = Array.from({ length: n }, () => []);
for (let i = 0; i < n; i++) if (parent[i] >= 0) children[parent[i]].push(i); for (let i = 0; i < n; i++) if (parent[i] >= 0) children[parent[i]].push(i);
const gates = new Map(); const gates = new Map();
@ -292,13 +452,30 @@ export function buildJumpNetwork({
if (parent[i] >= 0) targets.push(records[parent[i]].id); if (parent[i] >= 0) targets.push(records[parent[i]].id);
for (const c of children[i].sort((a, b) => a - b)) targets.push(records[c].id); for (const c of children[i].sort((a, b) => a - b)) targets.push(records[c].id);
for (const v of shortcutsOf[i]) targets.push(records[v].id); for (const v of shortcutsOf[i]) targets.push(records[v].id);
for (const br of bridgesPlaced) {
if (br[0] === records[i].id) targets.push(br[1]);
else if (br[1] === records[i].id) targets.push(br[0]);
}
if (targets.length < minGates && n > 1) { if (targets.length < minGates && n > 1) {
// Can't happen (tree degree ≥ 1), but never emit a closed system. // Can't happen (tree degree ≥ 1), but never emit a closed system.
throw new Error(`Jump network left system ${records[i].id} with ${targets.length} gate(s) < minGates ${minGates}`); throw new Error(`Jump network left system ${records[i].id} with ${targets.length} gate(s) < minGates ${minGates}`);
} }
gates.set(records[i].id, targets); gates.set(records[i].id, targets);
} }
return { gates, repaired }; const branchOfMap = new Map();
const depthMap = new Map();
for (let i = 0; i < n; i++) {
if (branchOfArr[i] !== -1) branchOfMap.set(records[i].id, records[branchOfArr[i]].id);
if (depthArr[i] >= 0) depthMap.set(records[i].id, depthArr[i]);
}
return {
gates,
repaired,
bridges: bridgesPlaced,
branches: branchList.map((i) => records[i].id),
branchOf: branchOfMap,
depth: depthMap,
};
} }
const key = (a, b) => (a < b ? a : b) + '\u0000' + (a < b ? b : a); const key = (a, b) => (a < b ? a : b) + '\u0000' + (a < b ? b : a);

View File

@ -40,13 +40,16 @@ const DEG = Math.PI / 180;
* BARREN systems (objectCount → 0) are the deliberate exception: jump * BARREN systems (objectCount → 0) are the deliberate exception: jump
* gates — and, in most, an asteroid cluster drifting inside the gate's * gates — and, in most, an asteroid cluster drifting inside the gate's
* tether to mine at the stop — and nothing else. They are the DEAD-END * tether to mine at the stop — and nothing else. They are the DEAD-END
* LEAVES of the jump network (a pure spanning tree, no loops — the maze's * LEAVES of the jump network (the maze's dead ends: one gate, in and out
* dead ends: one gate, in and out the same way), where the player's room * the same way), where the player's room to move is the activated gate's
* to move is the activated gate's own level-1 tether (data/gates.json → * own level-1 tether (data/gates.json → ACTIVITY: every gate carries
* ACTIVITY: every gate carries `active`, default false; the activation * `active`, default false; the activation mechanic is future work). The
* mechanic is future work). Nothing here is hostile yet: `owner` on every * galaxy is settled by FACTION (data/factions.json — the roster's
* settlement is a reserved seam for the factions and pirates we'll * faction pass, js/galaxy/Galaxy.js → _assignFactions): each branch's
* introduce later. * territory systems carry `record.faction`, and the `owner` seam on
* every settlement is populated from it (a capital world's settlement is
* owned by its faction — the neutral capital's stays owner-less); null
* = neutral wild space (pirates are still future work).
* *
* The STARTING system is special: the player's home world sits at the * The STARTING system is special: the player's home world sits at the
* origin (not a generated planet, fixed key 'home'), and the system always * origin (not a generated planet, fixed key 'home'), and the system always
@ -92,9 +95,11 @@ const DEG = Math.PI / 180;
* on the ray toward the destination, `barrenDistance` from the center. * on the ray toward the destination, `barrenDistance` from the center.
* Every gate record carries `active` (default false — the activation * Every gate record carries `active` (default false — the activation
* mechanic is future work; an activated gate anchors a level-1 tether). * mechanic is future work; an activated gate anchors a level-1 tether).
* The galaxy-wide network is a pure spanning tree (no loops — the maze): * The galaxy-wide network is the degree-limited spanning tree plus the
* from any system the player can reach any other, and every jump has a * cross-branch BRIDGES (data/gates.json → bridges — the middle-belt
* return gate. * links that let one branch reach another without the trunk): from any
* system the player can reach any other, and every jump has a return
* gate.
* *
* FRAME DIVERSITY (js/galaxy/PlanetFrames.js): each planet's spritesheet * FRAME DIVERSITY (js/galaxy/PlanetFrames.js): each planet's spritesheet
* frame is assigned by a galaxy-wide pass — the system's (class, frame) * frame is assigned by a galaxy-wide pass — the system's (class, frame)
@ -198,7 +203,11 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
ordinal: i, ordinal: i,
class: pclass, class: pclass,
moons, moons,
habitable: pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1), // ARID worlds are ALWAYS habitable — a dry terrestrial world is
// still a world you can live on (they settle as colonies, like
// rocky habitables — data/settlements.json → settledKindByClass);
// rocky worlds roll the per-type chance.
habitable: pclass === 'arid' || (pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1)),
}); });
} }
// FRAME DIVERSITY (js/galaxy/PlanetFrames.js — the galaxy-wide // FRAME DIVERSITY (js/galaxy/PlanetFrames.js — the galaxy-wide
@ -210,6 +219,32 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
for (let i = 0; i < planets.length; i++) planets[i].frame = frames[i]; for (let i = 0; i < planets.length; i++) planets[i].frame = frames[i];
} }
// CAPITALS (data/factions.json — the roster's faction pass, js/galaxy/
// Galaxy.js → _assignFactions): when this system is a designated
// capital (record.capital), its OUTERMOST world becomes the capital
// world — the faction's seat takes the outer slot of the world list:
// class `capital*` (planets.png frames 18..21 per data/planets.json →
// capitals, fixed per faction — the art carries the faction's color,
// so classTint stays white), always habitable (a capital is a city),
// and it hosts the 'capital' settlement below (owner: its faction —
// the reputation seam). Replacing the outer slot (rather than adding a
// world) keeps the system's object budget (objectCount) + the arc
// layout + the settlement count exactly as rolled — the capital is
// the world the faction built where the outermost world used to be.
// It is not a composition planet: the frame pass never saw it (its
// frame is fixed by the faction, not spread).
if (record.capital && !isHome && planets.length > 0) {
const cap = String(record.capital);
const capitalClass = `capital${cap.charAt(0).toUpperCase()}${cap.slice(1)}`;
const outer = planets[planets.length - 1];
outer.class = capitalClass;
outer.capital = true;
outer.faction = cap === 'neutral' ? null : cap; // the owner seam (capital world)
outer.frame = config.get(`planets.capitals.frames.${cap}`, 18);
outer.habitable = true; // a capital is a city — population by definition
outer.moons = 0;
}
// --- Settlements (the lived-in layer) --------------------------------- // --- Settlements (the lived-in layer) ---------------------------------
const settlements = generateSettlements({ const settlements = generateSettlements({
rng, rng,
@ -223,6 +258,11 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
density: settlementDensity(galaxy, record), density: settlementDensity(galaxy, record),
stations: { deepSpace: composition.deepSpace, waypoint: composition.waypoint }, stations: { deepSpace: composition.deepSpace, waypoint: composition.waypoint },
isHome, isHome,
// FACTION ownership (data/factions.json — the roster's faction pass):
// the system's territory (null = neutral wild space) claims its
// settlements' `owner` (a planet's own faction wins over the
// system's — the capital world's settlement is its faction's seat).
systemFaction: record.faction ?? null,
}); });
// STATION VARIANTS (js/galaxy/StationFrames.js — the galaxy-wide // STATION VARIANTS (js/galaxy/StationFrames.js — the galaxy-wide
@ -317,6 +357,12 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
jumps, // the system's jump gates (1–3; [] for a one-system galaxy) jumps, // the system's jump gates (1–3; [] for a one-system galaxy)
atmosphere: { color: atmosphereColor }, // nebula gas color (rendered on nebula systems) atmosphere: { color: atmosphereColor }, // nebula gas color (rendered on nebula systems)
fx, // per-system effect variation (flare phase / orbit / split / wanderer) fx, // per-system effect variation (flare phase / orbit / split / wanderer)
// FACTIONS (data/factions.json — the roster's faction pass):
// the system's faction id (null = neutral wild space) + its capital
// designation (null = not a capital) — the dossier
// (js/galaxy/SystemReport.js → "Faction: …") + the HUD read them.
faction: record.faction ?? null,
capital: record.capital ?? null,
}; };
if (isHome) { if (isHome) {
content.homeName = homeName; // the player's home world (starting system only) content.homeName = homeName; // the player's home world (starting system only)
@ -1365,21 +1411,26 @@ export function settlementDensity(galaxy, record) {
* settlements in orbital order, then free-floating (deep-space station, * settlements in orbital order, then free-floating (deep-space station,
* waypoint). Every roll goes through the system's own stream. Each * waypoint). Every roll goes through the system's own stream. Each
* settlement gets a stable `id` (`<systemId>-s<n>`, n = its position in * settlement gets a stable `id` (`<systemId>-s<n>`, n = its position in
* this order) — the reputation/factions key. * this order) — the reputation/factions key. `owner` is the FACTION
* SEAM (data/factions.json): the system's territory (systemFaction, from
* the roster's faction pass) claims the settlement — a capital world's
* own faction wins over the system's (its seat); null = neutral wild
* space (pirates are still future work).
* *
* The planet-bound layer is a RULE, not a roll (for now): the galaxy is * The planet-bound layer is a RULE, not a roll (for now): the galaxy is
* fully settled, so EVERY planet hosts the one kind that fits its class * fully settled, so EVERY planet hosts the one kind that fits its class
* (data/settlements.json → allPlanetsSettled + settledKindByClass — a * (data/settlements.json → allPlanetsSettled + settledKindByClass — a
* habitable rocky world earns a colony, every other world gets its mining * habitable rocky OR ARID world earns a colony, every other world gets
* outfit, gas giants ride cloud bases). Flip allPlanetsSettled off and * its mining outfit, gas giants ride cloud bases, and the four capital
* worlds host the capital kind). Flip allPlanetsSettled off and
* the old per-type odds (spec.chance + needs) take over again. The * the old per-type odds (spec.chance + needs) take over again. The
* free-space kinds still roll per type (home→far scaled). * free-space kinds still roll per type (home→far scaled).
*/ */
function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDeck, density, stations, isHome = false }) { function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDeck, density, stations, isHome = false, systemFaction = null }) {
const out = []; const out = [];
let nameIndex = 0; // next station name from the system's deck (no repeats) let nameIndex = 0; // next station name from the system's deck (no repeats)
const make = (kind, anchor) => { const make = (kind, anchor, owner = null) => {
const def = kindDefs[kind] ?? {}; const def = kindDefs[kind] ?? {};
out.push({ out.push({
id: `${systemId}-s${out.length + 1}`, // reputation/factions key (stable per seed) id: `${systemId}-s${out.length + 1}`, // reputation/factions key (stable per seed)
@ -1387,7 +1438,7 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe
name: stationDeck[nameIndex++ % stationDeck.length], name: stationDeck[nameIndex++ % stationDeck.length],
anchor, anchor,
population: logPopulation(rng, def.population), population: logPopulation(rng, def.population),
owner: null, // reserved: factions / pirates claim settlements later owner, // the faction seam (null = neutral wild space / unclaimed)
}); });
}; };
@ -1397,21 +1448,22 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe
const byClass = config.get('settlements.settledKindByClass', {}); const byClass = config.get('settlements.settledKindByClass', {});
for (const p of planets) { for (const p of planets) {
const kind = settledKindFor(p, byClass, kindDefs); const kind = settledKindFor(p, byClass, kindDefs);
if (kind) make(kind, { type: 'planet', ordinal: p.ordinal }); if (kind) make(kind, { type: 'planet', ordinal: p.ordinal }, p.faction ?? systemFaction ?? null);
} }
} else { } else {
// The old probabilistic layer (per-type chances + class needs). // The old probabilistic layer (per-type chances + class needs).
for (const p of planets) { for (const p of planets) {
const owner = p.faction ?? systemFaction ?? null;
if (p.habitable && roll(rng, spec.colony?.chance ?? 0.3, density)) { if (p.habitable && roll(rng, spec.colony?.chance ?? 0.3, density)) {
make('colony', { type: 'planet', ordinal: p.ordinal }); make('colony', { type: 'planet', ordinal: p.ordinal }, owner);
} }
if (needs(p, spec.miningStation?.needs, ['rocky', 'lava', 'ice']) && if (needs(p, spec.miningStation?.needs, ['rocky', 'lava', 'ice']) &&
roll(rng, spec.miningStation?.chance ?? 0.2, density)) { roll(rng, spec.miningStation?.chance ?? 0.2, density)) {
make('miningStation', { type: 'planet', ordinal: p.ordinal }); make('miningStation', { type: 'planet', ordinal: p.ordinal }, owner);
} }
if (needs(p, spec.cloudBase?.needs, ['gas']) && if (needs(p, spec.cloudBase?.needs, ['gas']) &&
roll(rng, spec.cloudBase?.chance ?? 0.15, density)) { roll(rng, spec.cloudBase?.chance ?? 0.15, density)) {
make('cloudBase', { type: 'planet', ordinal: p.ordinal }); make('cloudBase', { type: 'planet', ordinal: p.ordinal }, owner);
} }
} }
} }
@ -1421,8 +1473,8 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe
// rolls a waypoint — its band can't hold 5 points). A BARREN system // rolls a waypoint — its band can't hold 5 points). A BARREN system
// (objectCount → 0) rolled no stations — it is a jump-gate-only stop, // (objectCount → 0) rolled no stations — it is a jump-gate-only stop,
// deliberately. // deliberately.
if (stations?.deepSpace) make('deepSpaceStation', { type: 'space' }); if (stations?.deepSpace) make('deepSpaceStation', { type: 'space' }, systemFaction);
if (stations?.waypoint) make('waypoint', { type: 'space' }); if (stations?.waypoint) make('waypoint', { type: 'space' }, systemFaction);
return out; return out;
} }

View File

@ -17,7 +17,10 @@ import { config } from '../config/Config.js';
* // { title, subtitle, faction, population, status, statusColor } * // { title, subtitle, faction, population, status, statusColor }
* // title — the system name * // title — the system name
* // subtitle — "Main Sequence system · star G" * // subtitle — "Main Sequence system · star G"
* // status — "Faction: Neutral · Pop ~1.2M" (or "… · Unclaimed") * // status — "Faction: Apex · Pop ~1.2M" (or "… · Unclaimed")
* // The FACTION (data/factions.json — the roster's faction pass, on
* // content.faction): the controlling faction's label + color, or
* // Neutral (light gray) for the wild space + the neutral ring.
*/ */
export function formatSystemReport(content, typeDefs = null) { export function formatSystemReport(content, typeDefs = null) {
const types = typeDefs ?? config.get('systems.types', {}); const types = typeDefs ?? config.get('systems.types', {});
@ -28,11 +31,15 @@ export function formatSystemReport(content, typeDefs = null) {
0, 0,
); );
// Faction control — every system is Neutral for now. When factions // FACTION CONTROL (data/factions.json — the roster's faction pass):
// land (the `owner` seam on settlements), this is where the faction's // the system's faction (content.faction — null = neutral wild space)
// name + theme color will plug in (Neutral = light gray). // names the power + its color; a content without the field (old
const faction = 'Neutral'; // saves / hand-built content) reads Neutral, as before.
const statusColor = '#c4cad8'; const FACTIONS = config.get('factions.factions', {});
const fid = content.faction ?? null;
const fdef = fid ? FACTIONS[fid] : null;
const faction = fdef?.label ?? 'Neutral';
const statusColor = fdef?.color ?? '#c4cad8';
return { return {
title: content.name, title: content.name,

View File

@ -174,6 +174,20 @@ export function pickPreviewSystem(galaxy, spec, systemType = null) {
// world (starting system only — a terran-class body by rule). // world (starting system only — a terran-class body by rule).
const candidates = classes.map((cls, i) => ({ cls, frame: frames ? frames[i] : null, ordinal: i + 1 })); const candidates = classes.map((cls, i) => ({ cls, frame: frames ? frames[i] : null, ordinal: i + 1 }));
if (isHome) candidates.push({ cls: homeCls, frame: galaxy?.homeWorldFrame ?? null, ordinal: 0 }); if (isHome) candidates.push({ cls: homeCls, frame: galaxy?.homeWorldFrame ?? null, ordinal: 0 });
// CAPITAL WORLDS (data/factions.json — the roster's faction pass):
// a capital's seat is NOT a composition planet (its frame is fixed
// per faction — planets.json → capitals — not stamped by the frame
// pass). It REPLACES the system's outermost world (content ordinal
// = the composition count), so it joins the candidates as that
// outer slot — ?type=capitalneutral & co. preview the real seat.
if (rec.capital) {
const capCls = `capital${rec.capital.charAt(0).toUpperCase()}${rec.capital.slice(1)}`;
candidates.push({
cls: capCls,
frame: config.get(`planets.capitals.frames.${rec.capital}`, null),
ordinal: classes.length,
});
}
let exact = null; let exact = null;
for (const c of candidates) { for (const c of candidates) {

View File

@ -11,15 +11,17 @@
* - the ONE exception: the player's home world is ALWAYS +20 (the * - the ONE exception: the player's home world is ALWAYS +20 (the
* `home` value) — pinned. Nothing sets, changes, or clears it. * `home` value) — pinned. Nothing sets, changes, or clears it.
* *
* The FACTION CHECK (placeholder — factions come later): every settlement * The FACTION CHECK: every settlement carries an `owner` (a faction id,
* carries the reserved `owner` seam (js/galaxy/SystemGenerator.js — * or null = neutral/wild space — js/galaxy/SystemGenerator.js populates
* `owner: null` for now). standingFor() resolves it: a place owned by a * it from the system's territory, the roster's faction pass in
* faction falls back to the player's standing WITH THAT FACTION * js/galaxy/Galaxy.js, data/factions.json). standingFor() resolves it:
* (factionStanding()) before settling on neutral. Until factions exist * a place owned by a faction falls back to the player's standing WITH
* every owner is null and factionStanding() is a stub — so the whole * THAT FACTION (factionStanding()) before settling on neutral. The
* galaxy reads neutral (home world: +20). When factions land, only * per-faction standing itself is still a marked `TODO(factions)` stub
* (a) populate `owner` in generation and (b) fill factionStanding() * (factionStanding() returns null — the player's real relations with
* remain; the resolution order below is already the final one. * apex/mindweave/zenith land there), so a faction-controlled place
* reads neutral today (home world: +20); the resolution order below is
* already the final one.
* *
* PLACE IDENTITY (the reputation keys — stable per seed, from the * PLACE IDENTITY (the reputation keys — stable per seed, from the
* generator): planets `<systemId>-p<ordinal>`, settlements * generator): planets `<systemId>-p<ordinal>`, settlements

View File

@ -920,6 +920,12 @@ export class GameScene extends Phaser.Scene {
onStart: (def) => { onStart: (def) => {
if (def && def.from === 'trestle') this._questRefresh(); if (def && def.from === 'trestle') this._questRefresh();
}, },
// The comm ENDED (for 'ack' comms: the ACKNOWLEDGE press): the
// scene's ack-side bookkeeping — Trestle's last brief ('trestle
// sends the coordinates to your computer right now') sets the
// player's destination to the neutral capital on acknowledgment
// (GameScene._commEnded).
onEnd: (def) => this._commEnded(def),
}); });
// The run's FIRST rock field (the 'firstCluster' comm beat — see // The run's FIRST rock field (the 'firstCluster' comm beat — see
@ -3592,6 +3598,36 @@ export class GameScene extends Phaser.Scene {
} }
} }
/**
* A comm ENDED (the CommsHub's onEnd hook — for 'ack' comms this is
* the ACKNOWLEDGE press): the scene's ack-side bookkeeping.
*
* TRESTLE'S LAST BRIEF (data/comms.json: trestle-brief-3) — he is
* "in the system right next to you in this star cluster's capital" and
* "sending the coordinates to your computer right now": the moment the
* player acknowledges it, the NEUTRAL CAPITAL (the roster's faction
* pass — js/galaxy/Galaxy.js → _assignFactions; its seat is that
* system's outermost world) becomes the player's destination.
* setDestination does the rest: the route is derived from it, the
* orange compass marker points at the gate to fly, and the map's
* route lane lights up — no extra input from the player. Arriving is
* 'Meet Trestle' (trestle-arrive plays on the jump-in).
*/
_commEnded(def) {
if (!def || def.id !== 'trestle-brief-3') return;
const t = this.galaxy?.neutralCapitalTarget?.();
if (!t) {
// The faction pass always designates one on a real galaxy — this
// is the degenerate-galaxy guard (no branches at all).
this.consoleToast('NO CAPITAL ON FILE — COORDS LOST IN THE STATIC', {
glyph: '✕',
glyphColor: toCss(themeColor('neon2', 0xff9b9b)),
});
return;
}
this.setDestination(t.systemId, t.objectId);
}
/** /**
* SET PRIORITY — the tracker HUD's featured quest (the row popup's * SET PRIORITY — the tracker HUD's featured quest (the row popup's
* button, or the console's SET PRIORITY). The ledger is the source of * button, or the console's SET PRIORITY). The ledger is the source of

View File

@ -22,6 +22,10 @@
* JumpNetwork — a spanning tree, so the web reads as a maze): * JumpNetwork — a spanning tree, so the web reads as a maze):
* - unexplored: a faint thread * - unexplored: a faint thread
* - FRONTIER (one end visited): a brighter "next step" * - FRONTIER (one end visited): a brighter "next step"
* - BRIDGE (a cross-branch middle-belt lane, data/gates.json →
* bridges — the inter-branch links the factions use to reach each
* other without the trunk, data/factions.json): the warm
* inter-branch thread (above unexplored/frontier, below used/route)
* - TRAVELED (the run jumped it): the bright lane — a glow pass + * - TRAVELED (the run jumped it): the bright lane — a glow pass +
* a flow packet drifting along it (the route the player flew) * a flow packet drifting along it (the route the player flew)
* • the CHARTED REGION — the convex hull of the visited systems, * • the CHARTED REGION — the convex hull of the visited systems,
@ -48,12 +52,16 @@
* Reveal on tab switch / window open: a ripple out of the home system * Reveal on tab switch / window open: a ripple out of the home system
* (stars bloom by distance, lanes trace in, the region fades last). * (stars bloom by distance, lanes trace in, the region fades last).
* *
* FACTIONS (planned — not yet implemented): the snapshot carries * FACTIONS (data/factions.json — the worldgen layer is LIVE): the
* per-system `faction: null` already; when factions ship, the reserved * snapshot carries per-system `faction` (null = neutral wild space) +
* seams are the star color layer (a faction ring/tint over the * `capital` (the four designated capitals). The plate's faction layer
* archetype color) + a territory-region pass (the same hull/fill * (`_redrawFactions`, config below — data/map.json → galaxy.factions)
* recipe as the charted region, per faction color + relation alpha). * rings every territory system in its faction's color (factions.<id>
* See docs/PROJECT_NOTES.md → "Factions (planned)". * .color) and marks every capital with the double-ring seat — the
* neutral capital in neutral silver. The per-faction territory-region
* FILL (this file's hull pass, colored per faction) + the relation
* tiers (js/reputation/Reputation.js → factionStanding) are the next
* layer. See docs/PROJECT_NOTES.md → "Factions".
* *
* Pure view: it owns no game state — everything arrives through the * Pure view: it owns no game state — everything arrives through the
* `getSnapshot` callback (polled by MapWindow while open). * `getSnapshot` callback (polled by MapWindow while open).
@ -214,11 +222,12 @@ export class GalaxyView {
// px/py) — all BELOW the window chrome (plateFrame depth 2, tags 3) // px/py) — all BELOW the window chrome (plateFrame depth 2, tags 3)
this.hullG = s.graphics().setScrollFactor(0).setDepth(1.05); this.hullG = s.graphics().setScrollFactor(0).setDepth(1.05);
this.edgeG = s.graphics().setScrollFactor(0).setDepth(1.1); this.edgeG = s.graphics().setScrollFactor(0).setDepth(1.1);
this.factionG = s.graphics().setScrollFactor(0).setDepth(1.38); // the FACTION layer (data/factions.json) — above the core images, below the star art
this.flowG = s.graphics().setScrollFactor(0).setDepth(1.45); this.flowG = s.graphics().setScrollFactor(0).setDepth(1.45);
this.starG = s.graphics().setScrollFactor(0).setDepth(1.42); // the zoom-bloom star art (above the core image, below the flow packets + labels) this.starG = s.graphics().setScrollFactor(0).setDepth(1.42); // the zoom-bloom star art (above the core image, below the flow packets + labels)
this.markerG = s.graphics().setScrollFactor(0).setDepth(1.6); this.markerG = s.graphics().setScrollFactor(0).setDepth(1.6);
this.hoverG = s.graphics().setScrollFactor(0).setDepth(1.7); this.hoverG = s.graphics().setScrollFactor(0).setDepth(1.7);
for (const o of [this.hullG, this.edgeG, this.flowG, this.starG, this.markerG, this.hoverG]) { for (const o of [this.hullG, this.edgeG, this.factionG, this.flowG, this.starG, this.markerG, this.hoverG]) {
o.setVisible(false); o.setVisible(false);
win.add(o); win.add(o);
} }
@ -423,6 +432,13 @@ export class GalaxyView {
isHome: rec.isHome, isHome: rec.isHome,
isCurrent: rec.isCurrent, isCurrent: rec.isCurrent,
gates: rec.gates, gates: rec.gates,
// FACTIONS (data/factions.json — the roster's faction pass):
// the system's faction id (null = neutral wild space) + its
// capital designation (null = not a capital) — the faction
// layer's ring colors (_redrawFactions, data/map.json →
// galaxy.factions) read these.
faction: rec.faction ?? null,
capital: rec.capital ?? null,
glowW, glowW,
coreW, coreW,
revealDelay: 0, revealDelay: 0,
@ -600,7 +616,10 @@ export class GalaxyView {
// ROUTE lanes (the active SET DESTINATION path) read first — the // ROUTE lanes (the active SET DESTINATION path) read first — the
// "where am I going" line in orange (data/map.json → galaxy.edges.route, // "where am I going" line in orange (data/map.json → galaxy.edges.route,
// color = data/gates.json → route.compassColor) — above used/frontier. // color = data/gates.json → route.compassColor) — above used/frontier.
const st = e.route ? ec.route : e.used ? ec.used : e.frontier ? ec.frontier : ec.unexplored; // BRIDGE lanes (the cross-branch middle-belt links, data/gates.json
// → bridges — the faction design's "don't go back to the trunk" routes)
// read as the warm inter-branch thread, above unexplored/frontier.
const st = e.route ? ec.route : e.used ? ec.used : e.frontier ? ec.frontier : e.bridge ? ec.bridge : ec.unexplored;
const color = toColor(st.color); const color = toColor(st.color);
const alpha = st.alpha ?? 0.5; const alpha = st.alpha ?? 0.5;
const width = Math.max(0.6, (st.width ?? 1) * (0.75 + 0.25 * k)); const width = Math.max(0.6, (st.width ?? 1) * (0.75 + 0.25 * k));
@ -677,11 +696,61 @@ export class GalaxyView {
if (show) st.label.setAlpha(0.92 * rf); if (show) st.label.setAlpha(0.92 * rf);
} }
this._drawStarArt(time, tR); this._drawStarArt(time, tR);
this._redrawFactions(time, tR);
this._drawFlow(time); this._drawFlow(time);
this._drawMarkers(time, k); this._drawMarkers(time, k);
} }
/**
* The FACTION LAYER (data/factions.json — the roster's faction pass,
* js/galaxy/Galaxy.js; config data/map.json → galaxy.factions): a ring
* in the faction's color around every system that sits in a faction
* territory (the wild-space ring right outside home stays plain — its
* systems are neutral), and the double-ring SEAT mark around every
* capital (the neutral capital wears neutral silver — factions.
* neutral.color). The ring radius rides the star's dot (the 2px floor
* keeps it readable at 1×), so the layer reads at every zoom; the
* per-frame redraw follows the star's pulse/reveal like the rest of
* the plate. The territory-region FILL (the hull pass, per faction
* color) is the next layer.
*/
_redrawFactions(time, tR) {
const g = this.factionG;
g.clear();
if (!this._snap) return;
const fc = this._cfg.factions ?? {};
if (fc.enabled === false) return;
const colors = config.get('factions.factions', {});
const alpha = fc.alpha ?? 0.62;
const ringMul = Number(fc.ringMul ?? 1.9);
const width = Number(fc.width ?? 1.2);
const capWidth = Number(fc.capitalWidth ?? 1.8);
const capAlpha = Number(fc.capitalAlpha ?? 0.9);
const capOuter = Number(fc.capitalOuter ?? 2.8);
for (const st of this._stars) {
if (st.culled) continue;
const fid = st.capital ?? st.faction;
if (!fid) continue;
const rf = this._easeReveal(tR, st.revealDelay, 480);
if (rf <= 0.01) continue; // still in the reveal fade-in
const color = toColor(colors?.[fid]?.color ?? 0xb9c4d6);
const base = Math.max(2, this._dotOf(st) * 2.4) * (st.visited ? 1 : 0.8);
if (st.capital) {
// The SEAT — a double ring (inner solid + outer soft): the
// faction's home world on the plate, unmistakable at 1×.
g.lineStyle(capWidth, color, capAlpha * rf);
g.strokeCircle(st.lx, st.ly, base * ringMul);
g.lineStyle(1, color, capAlpha * 0.4 * rf);
g.strokeCircle(st.lx, st.ly, base * capOuter);
} else {
// Territory ring — the faction's color over the archetype dot.
g.lineStyle(width, color, alpha * rf);
g.strokeCircle(st.lx, st.ly, base * ringMul);
}
}
}
_labelVisible(st) { _labelVisible(st) {
const lab = this._cfg.labels; const lab = this._cfg.labels;
if (lab.enabled === false) return false; if (lab.enabled === false) return false;
@ -1292,6 +1361,7 @@ export class GalaxyView {
const objs = [ const objs = [
this.hullG, this.hullG,
this.edgeG, this.edgeG,
this.factionG,
this.flowG, this.flowG,
this.markerG, this.markerG,
this.starG, this.starG,