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:
parent
3137a800bf
commit
e2878da59c
Binary file not shown.
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"galaxy.json",
|
||||
"systems.json",
|
||||
"settlements.json",
|
||||
"factions.json",
|
||||
"stations.json",
|
||||
"gates.json",
|
||||
"reputation.json",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue