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
},
{
"$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",
"from": "trestle",
"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,
"minGates": 1,
"maxGates": 3,
"neighborPool": 8,
"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,
"texture": "assets/images/jumpgate.png",
"frameWidth": 256,

View File

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

View File

@ -173,10 +173,11 @@
"void": { "speed": 0.2, "amp": 0.3 }
},
"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 },
"used": { "color": "#8df3ff", "width": 1.8, "alpha": 0.95 },
"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 }
},
"destination": {
@ -187,13 +188,23 @@
"alpha": 0.9
},
"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,
"color": "#00e5ff",
"padding": 0.055,
"fillAlpha": 0.05,
"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": {
"_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,

View File

@ -9,19 +9,43 @@
"rocky": [6, 7, 8],
"gas": [3, 4, 5],
"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": {
"rocky": 1.0,
"gas": 1.45,
"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": {
"rocky": "#e8d9b8",
"gas": "#ffb066",
"ice": "#cfeaff",
"lava": "#ff8a5c"
"lava": "#ff8a5c",
"arid": "#e2a25f",
"capitalNeutral": "#ffffff",
"capitalApex": "#ffffff",
"capitalMindweave": "#ffffff",
"capitalZenith": "#ffffff"
},
"compassColor": "#3dff88",
"typeLabels": {
@ -29,7 +53,12 @@
"rocky": "Rocky World",
"gas": "Gas Giant",
"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",
"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,
"settledKindByClass": {
"rocky": { "habitable": "colony", "default": "miningStation" },
"arid": "colony",
"gas": "cloudBase",
"ice": "miningStation",
"lava": "miningStation"
"lava": "miningStation",
"capitalNeutral": "capital",
"capitalApex": "capital",
"capitalMindweave": "capital",
"capitalZenith": "capital"
},
"kinds": {
"colony": {
@ -29,6 +34,13 @@
"population": { "min": 500, "max": 120000 },
"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": {
"label": "Deep-Space Station",
"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] }
},
"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,
"beltChance": 0.35,
"habitability": 0.1,
@ -47,7 +47,7 @@
"mass": { "M": [0.08, 0.35], "K": [0.5, 0.8] }
},
"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,
"beltChance": 0.3,
"habitability": 0.22,
@ -75,7 +75,7 @@
"mass": { "F": [1.0, 1.6], "G": [0.8, 1.4], "K": [0.5, 0.8] }
},
"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,
"beltChance": 0.6,
"habitability": 0.06,
@ -113,7 +113,7 @@
"mass": { "G": [0.85, 1.3], "K": [0.6, 0.85] }
},
"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,
"beltChance": 0.25,
"habitability": 0.5,
@ -138,7 +138,7 @@
"mass": { "A": [1.4, 2.1], "F": [1.0, 1.6], "G": [0.8, 1.4] }
},
"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,
"beltChance": 0.8,
"habitability": 0.02,
@ -161,7 +161,7 @@
"mass": { "M": [0.08, 0.3], "K": [0.4, 0.6] }
},
"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,
"beltChance": 0.2,
"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 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;
for (const rec of g.records) {
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;
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');
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: gate counts from the jump network',
recA.gates === (galaxy.jumpNetwork.gates.get(a.id)?.length ?? 0));
check('snap: FACTIONS seam present (faction: null, not yet implemented)',
snap.systems.every((s) => 'faction' in s && s.faction === null));
const FIDS = new Set(Object.keys(config.get('factions.factions', {})));
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)',
galaxy.records.length === 9 ? snap.edges.length === 8 : snap.edges.length === galaxy.records.length - 1);
// The lane web = the spanning TREE (n−1 undirected lanes) PLUS the
// 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);
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);

View File

@ -332,6 +332,11 @@ let big;
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));
// 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.
const big2 = Galaxy.create(SEED);
const sample = big2.records.slice(0, 3000);
@ -346,12 +351,12 @@ let big;
} else if (s.anchor.type !== 'space') structOk = false;
const pop = kinds[s.kind].population;
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 (!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.
const seen = new Set();
@ -363,15 +368,17 @@ let big;
const p = c.planets.find((pl) => pl.ordinal === s.anchor.ordinal);
if (s.kind === 'colony' && !p?.habitable) 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('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 kind that fits its class (data/settlements.json → settledKindByClass
// — habitable rocky ⇒ colony, other rocky/ice/lava ⇒ mining station,
// gas ⇒ cloud base).
// — habitable rocky ⇒ colony, ARID ⇒ colony (always habitable),
// other rocky/ice/lava ⇒ mining station, gas ⇒ cloud base,
// capital worlds ⇒ capital).
let allSettledOk = true;
for (const r of sample) {
const c = big2.ensureContent(r.id);
@ -381,15 +388,17 @@ let big;
);
const kindOk = c.settlements.filter(
(s) => s.anchor?.type === 'planet' && s.anchor?.ordinal === p.ordinal,
).every((s) =>
(p.class === 'gas' && s.kind === 'cloudBase') ||
(p.class !== 'gas' && (s.kind === 'colony' || s.kind === 'miningStation'))
);
).every((s) => {
if (p.class === 'gas') return s.kind === 'cloudBase';
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 (!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
// 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
* other system is reachable (forward BFS) AND every system can
* reach home (reverse BFS) — no closed systems, no trapped sets;
* - the network is a PURE SPANNING TREE (shortcuts OFF in the config):
* exactly N−1 undirected edges — no loops, one route between any two
* systems (the maze) — and every BARREN system (objectCount → 0) is
* a LEAF: exactly one gate, in and out the same way (the dead end);
* - the network is a SPANNING TREE of N−1 undirected tree edges
* (shortcuts OFF in the config) — the maze's single path per branch
* pair — PLUS the BRIDGES: the cross-branch middle-belt links
* (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):
* - 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}`);
// TREE (shortcuts OFF in data/gates.json): exactly N−1 undirected
// edges — no loops, no redundant routes (the maze's single path).
// TREE + BRIDGES (data/gates.json): the tree is exactly N−1 undirected
// 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();
let treeEdges = 0;
for (const r of g.records) for (const t of g.jumpGatesFor(r.id)) {
const key = [r.id, t.id].sort().join('\u0000');
if (!pairs.has(key)) pairs.add(key), treeEdges++;
pairs.add([r.id, t.id].sort().join('\u0000'));
}
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 —
// 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
its `zone` so density — and later faction strength, hazard, and trade
value — is per-system, not global.
- **Reserved seam: `owner`** — every settlement has `owner: null`. That's
where **factions and pirates** will plug in later (claim, flag,
relations). Deliberately absent for now — no factions yet.
- **Faction ownership (LIVE)** — the roster's faction pass
(`js/galaxy/Galaxy.js → _assignFactions`, data/factions.json) assigns
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`
(`formatSystemReport(content)` → title/subtitle/faction/population/
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
defaults to "current system charted, nothing traveled" (old saves load).
**FACTIONS (planned — not yet implemented):** the snapshot already
reserves the seam — every system carries `faction: null`, and
`GalaxyView`'s header documents the two reserved rendering passes for
when they ship: a **faction color layer** over the stars (a ring/tint on
top of the archetype color) and a **territory region fill** (the exact
hull/fill recipe of the charted region, per faction color + relation
alpha). The settlements' `owner: null` seam (above) +
`Reputation.factionStanding()` are the data-side counterparts.
**FACTIONS (the worldgen layer is LIVE — data/factions.json):** the
roster's faction pass (`js/galaxy/Galaxy.js → _assignFactions`) assigns
**territories** — each top-level BRANCH of the jump tree (a subtree
rooted at one of home's gate neighbors) is one faction's territory,
starting one system into the branch and stopping one before its
dead-end tips (the ring right outside home + the branch tips stay
neutral wild space) — and designates the **four capitals**: the NEUTRAL
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
archetype, edges, hull, flow, dust, labels, zoom, reveal, dialog copy).
- **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
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
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:
- **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:
pinned. Nothing sets, changes, or clears it (it's not even storable —
the key is derived from the scale itself).
- **The faction check (placeholder)** — `Reputation.standingFor(place)`
resolves: **home → stored standing → the place's `owner` → faction
standing → neutral**. `owner` is the settlements' reserved seam (always
`null` until factions exist) and `Reputation.factionStanding(id)` is a
marked `TODO(factions)` stub returning null — so today the whole galaxy
reads neutral (home: +20). When factions land: populate `owner` in
generation + fill `factionStanding()`; the resolution order is final.
- **The faction check** — `Reputation.standingFor(place)` resolves:
**home → stored standing → the place's `owner` → faction
standing → neutral**. `owner` is now POPULATED by the worldgen layer
(data/factions.json — the roster's faction pass sets a settlement's
owner to its system's faction; neutral/wild space stays owner-less) so
faction-controlled places resolve through the check; the per-faction
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
clamped to the scale; "ways to influence reputation" land on these.
- **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
* already — scene bookkeeping
* 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]
*/
constructor(scene, o = {}) {
@ -83,6 +87,7 @@ export class CommsHub {
this.anchor = typeof o.anchor === 'function' ? o.anchor : null;
this.onPause = typeof o.onPause === 'function' ? o.onPause : 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.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. */
_finish() {
if (!this.current) return;
const def = this.current;
this._stopVoice();
this._duck(false);
this._setPaused(false);
@ -359,6 +365,17 @@ export class CommsHub {
this.current = null;
this._voiceDone = true;
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. */

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.
*
* Extension points for later world rules (see docs/PROJECT_NOTES.md):
* - FACTIONS: assign each system a faction id (or null = wild space)
* at roster time — the record is the seam (reputation's `owner`
* resolution and the galaxy plate's `faction: null` are reserved).
* - FACTIONS: ASSIGNED AT ROSTER TIME by the faction pass (_assignFactions,
* data/factions.json) — each system's record carries `faction` (or
* 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`
* and `record.zone` (the middle zone is where the zone borders cross
* — 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
// stay on the record: the settlement generator uses d (home→far
// 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 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);
this.byId.set(id, rec);
}
@ -253,14 +260,18 @@ export class Galaxy {
}
// The JUMP NETWORK (data/gates.json): which star each system's jump
// gates reach. A PURE SPANNING TREE of the nearest-star graph (no
// shortcuts — data/gates.json → shortcuts:false) — the galaxy reads as
// a MAZE: exactly one route between any two systems, no closed loops.
// 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 tree degree); a BARREN system is
// a dead-end LEAF — exactly one gate (in and out the same way).
// Deterministic: same roster ⇒ same network.
// gates reach. A degree-limited spanning TREE of the nearest-star
// graph — the galaxy reads as a MAZE: (nearly) one route between any
// two systems, dead ends at the leaves — and every barren system is
// a dead-end LEAF (exactly one gate, in and out the same way). The
// deliberate exception: the BRIDGES (data/gates.json → bridges)
// cross-link the branches' middle belts, so one branch doesn't have
// to trek back to the trunk (home) to reach another (the faction
// 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)));
this.jumpNetwork = buildJumpNetwork({
records,
@ -270,6 +281,7 @@ export class Galaxy {
shortcuts: config.get('gates.shortcuts', true) === true,
barren,
rootId: this.currentSystemId,
bridges: config.get('gates.bridges', null),
});
// Defensive repairs that had to fire (0 on a healthy kNN graph).
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`);
}
// 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
// (class, frame) assignment — each planet's sheet frame avoids what
// 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)
}
/**
* 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),
* validated and sorted by `d`. A single fallback zone when the config
@ -541,6 +726,30 @@ export class Galaxy {
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. */
@ -557,6 +766,17 @@ export class Galaxy {
byZone,
generated: this.generatedCount,
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
* from either end)
* buildGalaxySnapshot(o) the live GALAXY plate data — the systems
* (position, type, visited), the dedup'd jump
* lanes (used / frontier / live), and the
* readout stats. Per-system `faction: null`
* is the FACTIONS seam (planned — see
* docs/PROJECT_NOTES.md "Factions"): the
* plate already reserves a faction color
* layer + a territory-region pass for it.
* (position, type, visited, faction, capital),
* the dedup'd jump lanes (used / frontier /
* live / bridge), and the readout stats.
* Per-system `faction` / `capital` are the
* FACTIONS layer (data/factions.json — the
* roster's faction pass, js/galaxy/Galaxy.js):
* 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
* points dropped)
* 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.
isDestination: s.id === destinationId,
gates: network?.gates?.get?.(s.id)?.length ?? 0,
// FACTIONS (planned — not yet implemented): the system's faction
// id + the player's relation tier (Neutral / Friendly / Aligned /
// Hostile) will land here. The galaxy plate reserves a faction
// color layer on the stars + a territory-region fill (the same
// hull pass as the charted region) for when it ships.
faction: null,
// FACTIONS (data/factions.json — the roster's faction pass,
// js/galaxy/Galaxy.js): the system's faction id (null = neutral wild
// space) + its CAPITAL designation (neutral / apex / mindweave /
// zenith — null = not a capital). The plate's faction ring layer
// (faction color from data/factions.json → factions) + the capital
// 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 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 = [];
for (const [a, dests] of network?.gates ?? []) {
if (!ids.has(a) || !Array.isArray(dests)) continue;
@ -123,6 +140,10 @@ export function buildGalaxySnapshot({ galaxy, visited = null, used = null, live
live:
currentSystemId != null &&
(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
* every barren system (the `barren` set, gate-only dead ends)
* 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
* ⇒ same network (deterministic across machines and runs).
*
@ -46,6 +70,12 @@
* budget (nodes under maxGates) buys extra one-way local edges — the
* web, not just the roads. Adding edges never removes reachability,
* 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
* graph is disconnected (effectively impossible at this scale), and it
@ -69,13 +99,33 @@
* (objectCount → 0, from the composition roll): a barren node never
* 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
* 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
* system) — it then keeps the lowest possible degree.
* @returns {{ gates: Map<string, string[]>, repaired: number }}
* system) — it then keeps the lowest possible degree. Branches (for the
* 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
* 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).
* 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({
records,
@ -85,6 +135,7 @@ export function buildJumpNetwork({
shortcuts = true,
barren = null,
rootId = null,
bridges = null,
}) {
const n = records.length;
const isBarren = (i) => barren instanceof Set && barren.has(records[i].id);
@ -119,7 +170,7 @@ export function buildJumpNetwork({
if (n <= 1) {
const gates = new Map();
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;
@ -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 ----------------------------------------------------------
// Each node's gates = the tree edges touching it — its parent first ("the
// road home"), then its tree children in index order — plus its
// shortcuts. A bidirected spanning tree is strongly connected by
// construction (the unique tree path between any two systems is walkable
// in both directions), so every system both reaches and is reachable;
// every node's gate count is its tree degree (≥ 1 for n > 1, ≤ maxGates)
// plus any shortcuts it bought.
// shortcuts, then its bridge gates. A bidirected spanning tree is strongly
// connected by construction (the unique tree path between any two systems
// is walkable in both directions), so every system both reaches and is
// reachable; the bridges add loops on top (Dijkstra then prefers the
// 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 }, () => []);
for (let i = 0; i < n; i++) if (parent[i] >= 0) children[parent[i]].push(i);
const gates = new Map();
@ -292,13 +452,30 @@ export function buildJumpNetwork({
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 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) {
// 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}`);
}
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);

View File

@ -40,13 +40,16 @@ const DEG = Math.PI / 180;
* BARREN systems (objectCount → 0) are the deliberate exception: jump
* 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
* LEAVES of the jump network (a pure spanning tree, no loops — the maze's
* dead ends: one gate, in and out the same way), where the player's room
* to move is the activated gate's own level-1 tether (data/gates.json →
* ACTIVITY: every gate carries `active`, default false; the activation
* mechanic is future work). Nothing here is hostile yet: `owner` on every
* settlement is a reserved seam for the factions and pirates we'll
* introduce later.
* LEAVES of the jump network (the maze's dead ends: one gate, in and out
* the same way), where the player's room to move is the activated gate's
* own level-1 tether (data/gates.json → ACTIVITY: every gate carries
* `active`, default false; the activation mechanic is future work). The
* galaxy is settled by FACTION (data/factions.json — the roster's
* faction pass, js/galaxy/Galaxy.js → _assignFactions): each branch's
* 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
* 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.
* Every gate record carries `active` (default false — the activation
* 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):
* from any system the player can reach any other, and every jump has a
* return gate.
* The galaxy-wide network is the degree-limited spanning tree plus the
* cross-branch BRIDGES (data/gates.json → bridges — the middle-belt
* 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 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,
class: pclass,
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
@ -210,6 +219,32 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
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) ---------------------------------
const settlements = generateSettlements({
rng,
@ -223,6 +258,11 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
density: settlementDensity(galaxy, record),
stations: { deepSpace: composition.deepSpace, waypoint: composition.waypoint },
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
@ -317,6 +357,12 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
jumps, // the system's jump gates (1–3; [] for a one-system galaxy)
atmosphere: { color: atmosphereColor }, // nebula gas color (rendered on nebula systems)
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) {
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,
* waypoint). Every roll goes through the system's own stream. Each
* 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
* fully settled, so EVERY planet hosts the one kind that fits its class
* (data/settlements.json → allPlanetsSettled + settledKindByClass — a
* habitable rocky world earns a colony, every other world gets its mining
* outfit, gas giants ride cloud bases). Flip allPlanetsSettled off and
* 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). Flip allPlanetsSettled off and
* the old per-type odds (spec.chance + needs) take over again. The
* 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 = [];
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] ?? {};
out.push({
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],
anchor,
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', {});
for (const p of planets) {
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 {
// The old probabilistic layer (per-type chances + class needs).
for (const p of planets) {
const owner = p.faction ?? systemFaction ?? null;
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']) &&
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']) &&
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
// (objectCount → 0) rolled no stations — it is a jump-gate-only stop,
// deliberately.
if (stations?.deepSpace) make('deepSpaceStation', { type: 'space' });
if (stations?.waypoint) make('waypoint', { type: 'space' });
if (stations?.deepSpace) make('deepSpaceStation', { type: 'space' }, systemFaction);
if (stations?.waypoint) make('waypoint', { type: 'space' }, systemFaction);
return out;
}

View File

@ -17,7 +17,10 @@ import { config } from '../config/Config.js';
* // { title, subtitle, faction, population, status, statusColor }
* // title — the system name
* // 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) {
const types = typeDefs ?? config.get('systems.types', {});
@ -28,11 +31,15 @@ export function formatSystemReport(content, typeDefs = null) {
0,
);
// Faction control — every system is Neutral for now. When factions
// land (the `owner` seam on settlements), this is where the faction's
// name + theme color will plug in (Neutral = light gray).
const faction = 'Neutral';
const statusColor = '#c4cad8';
// FACTION CONTROL (data/factions.json — the roster's faction pass):
// the system's faction (content.faction — null = neutral wild space)
// names the power + its color; a content without the field (old
// saves / hand-built content) reads Neutral, as before.
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 {
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).
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 });
// 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;
for (const c of candidates) {

View File

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

View File

@ -920,6 +920,12 @@ export class GameScene extends Phaser.Scene {
onStart: (def) => {
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
@ -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
* 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):
* - unexplored: a faint thread
* - 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 +
* a flow packet drifting along it (the route the player flew)
* • 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
* (stars bloom by distance, lanes trace in, the region fades last).
*
* FACTIONS (planned — not yet implemented): the snapshot carries
* per-system `faction: null` already; when factions ship, the reserved
* seams are the star color layer (a faction ring/tint over the
* archetype color) + a territory-region pass (the same hull/fill
* recipe as the charted region, per faction color + relation alpha).
* See docs/PROJECT_NOTES.md → "Factions (planned)".
* FACTIONS (data/factions.json — the worldgen layer is LIVE): the
* snapshot carries per-system `faction` (null = neutral wild space) +
* `capital` (the four designated capitals). The plate's faction layer
* (`_redrawFactions`, config below — data/map.json → galaxy.factions)
* rings every territory system in its faction's color (factions.<id>
* .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
* `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)
this.hullG = s.graphics().setScrollFactor(0).setDepth(1.05);
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.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.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);
win.add(o);
}
@ -423,6 +432,13 @@ export class GalaxyView {
isHome: rec.isHome,
isCurrent: rec.isCurrent,
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,
coreW,
revealDelay: 0,
@ -600,7 +616,10 @@ export class GalaxyView {
// ROUTE lanes (the active SET DESTINATION path) read first — the
// "where am I going" line in orange (data/map.json → galaxy.edges.route,
// 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 alpha = st.alpha ?? 0.5;
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);
}
this._drawStarArt(time, tR);
this._redrawFactions(time, tR);
this._drawFlow(time);
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) {
const lab = this._cfg.labels;
if (lab.enabled === false) return false;
@ -1292,6 +1361,7 @@ export class GalaxyView {
const objs = [
this.hullG,
this.edgeG,
this.factionG,
this.flowG,
this.markerG,
this.starG,