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
|
"delayMs": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"$comment": "Briefing beat 3 of 3 (trigger 'trestleBrief') — the last of the sequence: the rendezvous (Trestle is in the home-NEIGHBOR system — 'the neighboring system' of the quest's final check) and the way to find it (the orange markers on the compass/map). With all three heard, the quest's 'Talk to Trestle' check is complete (the played-ledger witness above).",
|
"$comment": "Briefing beat 3 of 3 (trigger 'trestleBrief') — the last of the sequence: the rendezvous (Trestle is in the home-NEIGHBOR system — 'the neighboring system' of the quest's final check) and the way to find it (the orange markers on the compass/map). The 'star cluster's capital' is the NEUTRAL CAPITAL (data/factions.json — the roster's faction pass, js/galaxy/Galaxy.js → _assignFactions): the scene acts on the acknowledgment — the moment the player clicks ACKNOWLEDGE, its destination is set to that system's capital world (the system's outermost planet) — 'sending the coordinates to your computer right now' (js/comms/CommsHub.js → onEnd, js/scenes/GameScene.js → _commEnded → setDestination — the route, the orange compass marker, and the map's route lane all follow from it). With all three heard, the quest's 'Talk to Trestle' check is complete (the played-ledger witness above).",
|
||||||
"id": "trestle-brief-3",
|
"id": "trestle-brief-3",
|
||||||
"from": "trestle",
|
"from": "trestle",
|
||||||
"type": "ack",
|
"type": "ack",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
"enabled": true,
|
||||||
"minGates": 1,
|
"minGates": 1,
|
||||||
"maxGates": 3,
|
"maxGates": 3,
|
||||||
"neighborPool": 8,
|
"neighborPool": 8,
|
||||||
"shortcuts": false,
|
"shortcuts": false,
|
||||||
|
"bridges": {
|
||||||
|
"_comment": "BRIDGES — the cross-branch links (js/galaxy/JumpNetwork.js, the bridge pass after the spanning tree). With shortcuts OFF the tree is a pure maze: branch-to-branch travel (a branch = a top-level subtree of the home system) means ALL the way back to the trunk. Bridges cut that: for each PAIR of distinct branches, `perPair` bidirectional gate-pairs join two systems of the middle belt (d in dMin..dMax — the home→far diagonal's middle slice, data/galaxy.json → distribution.zones), so the branches interconnect around the middle of the galaxy (a triangle for three branches) and a branch-to-branch trip is one extra hop, not a trek to the trunk — the faction design's \"reach each other without routing through home\" (data/factions.json). HARD RULES: both ends must sit in each other's `neighborPool` symmetric union (the LOCAL rule — a bridge is still a gate to a NEAR star, like any other), both ends non-barren (a dead-end leaf keeps its single gate), and both ends keep degree ≤ maxGates (the cap never breaks). SOFT: the middle-belt preference — an out-of-band pool pair is the fallback, and a pair with NO pool candidate degrades (a console note; the maze still connects the pair). Each bridge runs BOTH ways (a return gate at each end — the \"every jump has a return gate\" rule); planRoute's Dijkstra then prefers the shorter of bridge vs trunk automatically. Deterministic: same roster ⇒ same bridges (index tiebreaks).",
|
||||||
|
"enabled": true,
|
||||||
|
"perPair": 2,
|
||||||
|
"dMin": 0.37,
|
||||||
|
"dMax": 0.63
|
||||||
|
},
|
||||||
"size": 96,
|
"size": 96,
|
||||||
"texture": "assets/images/jumpgate.png",
|
"texture": "assets/images/jumpgate.png",
|
||||||
"frameWidth": 256,
|
"frameWidth": 256,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"galaxy.json",
|
"galaxy.json",
|
||||||
"systems.json",
|
"systems.json",
|
||||||
"settlements.json",
|
"settlements.json",
|
||||||
|
"factions.json",
|
||||||
"stations.json",
|
"stations.json",
|
||||||
"gates.json",
|
"gates.json",
|
||||||
"reputation.json",
|
"reputation.json",
|
||||||
|
|
|
||||||
|
|
@ -173,10 +173,11 @@
|
||||||
"void": { "speed": 0.2, "amp": 0.3 }
|
"void": { "speed": 0.2, "amp": 0.3 }
|
||||||
},
|
},
|
||||||
"edges": {
|
"edges": {
|
||||||
"_comment": "The jump-lane web — three reads of the same thin line. used = a lane the run TRAVELED (bright glow pass + the flow packet); frontier = exactly one end charted (the next step); unexplored = the faint maze. route = a lane on the active SET DESTINATION path (the \"where am I going\" line, data/gates.json → route.compassColor orange) — drawn above used/frontier so the course reads clearly. width = plate-px at 1× (0.75–1.25× with zoom).",
|
"_comment": "The jump-lane web — three reads of the same thin line. used = a lane the run TRAVELED (bright glow pass + the flow packet); frontier = exactly one end charted (the next step); unexplored = the faint maze. route = a lane on the active SET DESTINATION path (the \"where am I going\" line, data/gates.json → route.compassColor orange) — drawn above used/frontier so the course reads clearly. bridge = a cross-branch middle-belt lane (data/gates.json → bridges — the inter-branch links the factions use to reach each other without the trunk, data/factions.json) — drawn above unexplored/frontier (below used/route) as the warm inter-branch thread. width = plate-px at 1× (0.75–1.25× with zoom).",
|
||||||
"route": { "color": "#ff8c1a", "width": 2.4, "alpha": 0.95 },
|
"route": { "color": "#ff8c1a", "width": 2.4, "alpha": 0.95 },
|
||||||
"used": { "color": "#8df3ff", "width": 1.8, "alpha": 0.95 },
|
"used": { "color": "#8df3ff", "width": 1.8, "alpha": 0.95 },
|
||||||
"frontier": { "color": "#3fb9d8", "width": 1.1, "alpha": 0.62 },
|
"frontier": { "color": "#3fb9d8", "width": 1.1, "alpha": 0.62 },
|
||||||
|
"bridge": { "color": "#d8b45a", "width": 1.0, "alpha": 0.42 },
|
||||||
"unexplored": { "color": "#2a4f70", "width": 0.8, "alpha": 0.3 }
|
"unexplored": { "color": "#2a4f70", "width": 0.8, "alpha": 0.3 }
|
||||||
},
|
},
|
||||||
"destination": {
|
"destination": {
|
||||||
|
|
@ -187,13 +188,23 @@
|
||||||
"alpha": 0.9
|
"alpha": 0.9
|
||||||
},
|
},
|
||||||
"hull": {
|
"hull": {
|
||||||
"_comment": "The CHARTED REGION — the convex hull of the visited systems, inflated by `padding` (a fraction of the plate width at 1× zoom — stable under zoom). A soft fill + a subtle double outline (the discovered-area shading). FACTIONS (planned): per-faction territories reuse this exact pass, colored per faction.",
|
"_comment": "The CHARTED REGION — the convex hull of the visited systems, inflated by `padding` (a fraction of the plate width at 1× zoom — stable under zoom). A soft fill + a subtle double outline (the discovered-area shading). FACTIONS: the per-faction RING layer lives in `factions` below (the plate's faction color layer — the worldgen layer is live, data/factions.json); a per-faction territory-region FILL reusing this hull pass is the next layer.",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"color": "#00e5ff",
|
"color": "#00e5ff",
|
||||||
"padding": 0.055,
|
"padding": 0.055,
|
||||||
"fillAlpha": 0.05,
|
"fillAlpha": 0.05,
|
||||||
"outlineAlpha": 0.26
|
"outlineAlpha": 0.26
|
||||||
},
|
},
|
||||||
|
"factions": {
|
||||||
|
"_comment": "The FACTION LAYER on the plate (data/factions.json — the roster's faction pass, js/galaxy/Galaxy.js). Every system that sits in a faction territory gets a RING in that faction's color (factions.<id>.color — neutral silver for the wild space's edge), and every CAPITAL gets the double-ring seat mark (the neutral capital wears neutral silver). The ring radius = the star's glow dot × ringMul, width = plate-px (both plate-stable under zoom — the dot floor keeps the ring readable even at 1×). alpha = the ring's opacity (territory rings run a touch dimmer than the capital rings — the seat reads first). enabled = false turns the whole layer off (the plate falls back to plain archetype colors). The per-faction territory-region FILL (this file's hull pass, colored per faction) + the relation tiers are the next layer.",
|
||||||
|
"enabled": true,
|
||||||
|
"alpha": 0.62,
|
||||||
|
"ringMul": 1.9,
|
||||||
|
"width": 1.2,
|
||||||
|
"capitalWidth": 1.8,
|
||||||
|
"capitalAlpha": 0.9,
|
||||||
|
"capitalOuter": 2.8
|
||||||
|
},
|
||||||
"flow": {
|
"flow": {
|
||||||
"_comment": "Flow packets drifting along the TRAVELED lanes — the routes the player flew (the lane's history). size = plate-px; cycleMs = one traversal (a per-lane seeded stagger desyncs them).",
|
"_comment": "Flow packets drifting along the TRAVELED lanes — the routes the player flew (the lane's history). size = plate-px; cycleMs = one traversal (a per-lane seeded stagger desyncs them).",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,43 @@
|
||||||
"rocky": [6, 7, 8],
|
"rocky": [6, 7, 8],
|
||||||
"gas": [3, 4, 5],
|
"gas": [3, 4, 5],
|
||||||
"ice": [9, 10, 11],
|
"ice": [9, 10, 11],
|
||||||
"lava": [12, 13, 14]
|
"lava": [12, 13, 14],
|
||||||
|
"arid": [15, 16, 17],
|
||||||
|
"capitalNeutral": [18],
|
||||||
|
"capitalApex": [19],
|
||||||
|
"capitalMindweave": [20],
|
||||||
|
"capitalZenith": [21]
|
||||||
|
},
|
||||||
|
"capitals": {
|
||||||
|
"_comment": "CAPITAL WORLDS (data/factions.json, the faction pass in js/galaxy/Galaxy.js): the four designated capitals are NOT composition planets — the roster picks the SYSTEM (neutral: a seeded pick of home's gate neighbors; apex/mindweave/zenith: one per branch, middle band, seeded) and SystemGenerator converts the system's OUTERMOST world into the capital world (replacing it — the object budget + arc layout + settlement count stay exactly as rolled). frames = the planets.png frame per capital id (18 neutral, 19 Apex, 20 MindWeave, 21 Zenith — fixed per faction, never a random pool); the art carries each faction's color, so classTint is white (no tint) for the capital classes and the renderer's classScale sizes them (planets.classScale.capital*). All four are habitable cities (a capital has population by definition) and host the 'capital' settlement (data/settlements.json → kinds.capital); the neutral capital's settlement is owner-less, the faction ones are owner: <faction> (the reputation faction seam).",
|
||||||
|
"frames": {
|
||||||
|
"neutral": 18,
|
||||||
|
"apex": 19,
|
||||||
|
"mindweave": 20,
|
||||||
|
"zenith": 21
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"classScale": {
|
"classScale": {
|
||||||
"rocky": 1.0,
|
"rocky": 1.0,
|
||||||
"gas": 1.45,
|
"gas": 1.45,
|
||||||
"ice": 0.85,
|
"ice": 0.85,
|
||||||
"lava": 0.95
|
"lava": 0.95,
|
||||||
|
"arid": 0.9,
|
||||||
|
"capitalNeutral": 1.2,
|
||||||
|
"capitalApex": 1.2,
|
||||||
|
"capitalMindweave": 1.2,
|
||||||
|
"capitalZenith": 1.2
|
||||||
},
|
},
|
||||||
"classTint": {
|
"classTint": {
|
||||||
"rocky": "#e8d9b8",
|
"rocky": "#e8d9b8",
|
||||||
"gas": "#ffb066",
|
"gas": "#ffb066",
|
||||||
"ice": "#cfeaff",
|
"ice": "#cfeaff",
|
||||||
"lava": "#ff8a5c"
|
"lava": "#ff8a5c",
|
||||||
|
"arid": "#e2a25f",
|
||||||
|
"capitalNeutral": "#ffffff",
|
||||||
|
"capitalApex": "#ffffff",
|
||||||
|
"capitalMindweave": "#ffffff",
|
||||||
|
"capitalZenith": "#ffffff"
|
||||||
},
|
},
|
||||||
"compassColor": "#3dff88",
|
"compassColor": "#3dff88",
|
||||||
"typeLabels": {
|
"typeLabels": {
|
||||||
|
|
@ -29,7 +53,12 @@
|
||||||
"rocky": "Rocky World",
|
"rocky": "Rocky World",
|
||||||
"gas": "Gas Giant",
|
"gas": "Gas Giant",
|
||||||
"ice": "Ice World",
|
"ice": "Ice World",
|
||||||
"lava": "Lava World"
|
"lava": "Lava World",
|
||||||
|
"arid": "Arid World",
|
||||||
|
"capitalNeutral": "Neutral Capital",
|
||||||
|
"capitalApex": "Apex Capital",
|
||||||
|
"capitalMindweave": "MindWeave Capital",
|
||||||
|
"capitalZenith": "Zenith Capital"
|
||||||
},
|
},
|
||||||
"homePlanet": "terran",
|
"homePlanet": "terran",
|
||||||
"homeName": "Terra",
|
"homeName": "Terra",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
{
|
{
|
||||||
"_comment": "The galaxy is already LIVED IN: it was settled long before the player. For now EVERY planet hosts a settlement (allPlanetsSettled): each world gets the one kind that fits its class (settledKindByClass — a habitable rocky world earns a colony, every other world gets its mining outfit, gas giants ride cloud bases). Flip allPlanetsSettled to false to return to the old probabilistic layer (per-type chance + needs in data/systems.json). The free-space kinds (deepSpaceStation, waypoint) still roll per type. kinds = the shared vocabulary (label, theme, population range, anchor type). Core→rim density gradient lives in data/galaxy.json (settlements.gradient).",
|
"_comment": "The galaxy is already LIVED IN: it was settled long before the player. For now EVERY planet hosts a settlement (allPlanetsSettled): each world gets the one kind that fits its class (settledKindByClass — a habitable rocky OR arid world earns a colony, every other world gets its mining outfit, gas giants ride cloud bases, and the four CAPITAL worlds host the capital kind). Arid worlds are habitable by rule (data/systems.json doesn't roll it — js/galaxy/SystemGenerator.js sets habitable=true for every arid), so they settle as colonies like rocky habitables. The CAPITALS (data/factions.json — neutral/apex/mindweave/zenith) are designated systems whose outermost world is a class-`capital*` planet (data/planets.json → capitals); that world hosts the capital kind and its settlement carries the faction's `owner` (the reputation faction seam) — the neutral capital's owner stays null. Flip allPlanetsSettled to false to return to the old probabilistic layer (per-type chance + needs in data/systems.json). The free-space kinds (deepSpaceStation, waypoint) still roll per type. kinds = the shared vocabulary (label, theme, population range, anchor type). Core→rim density gradient lives in data/galaxy.json (settlements.gradient).",
|
||||||
"allPlanetsSettled": true,
|
"allPlanetsSettled": true,
|
||||||
"settledKindByClass": {
|
"settledKindByClass": {
|
||||||
"rocky": { "habitable": "colony", "default": "miningStation" },
|
"rocky": { "habitable": "colony", "default": "miningStation" },
|
||||||
|
"arid": "colony",
|
||||||
"gas": "cloudBase",
|
"gas": "cloudBase",
|
||||||
"ice": "miningStation",
|
"ice": "miningStation",
|
||||||
"lava": "miningStation"
|
"lava": "miningStation",
|
||||||
|
"capitalNeutral": "capital",
|
||||||
|
"capitalApex": "capital",
|
||||||
|
"capitalMindweave": "capital",
|
||||||
|
"capitalZenith": "capital"
|
||||||
},
|
},
|
||||||
"kinds": {
|
"kinds": {
|
||||||
"colony": {
|
"colony": {
|
||||||
|
|
@ -29,6 +34,13 @@
|
||||||
"population": { "min": 500, "max": 120000 },
|
"population": { "min": 500, "max": 120000 },
|
||||||
"anchor": "planet"
|
"anchor": "planet"
|
||||||
},
|
},
|
||||||
|
"capital": {
|
||||||
|
"label": "Capital",
|
||||||
|
"description": "A faction's seat — the administrative capital of a controlled branch of the galaxy.",
|
||||||
|
"theme": { "color": "#b9c4d6" },
|
||||||
|
"population": { "min": 5000000, "max": 200000000 },
|
||||||
|
"anchor": "planet"
|
||||||
|
},
|
||||||
"deepSpaceStation": {
|
"deepSpaceStation": {
|
||||||
"label": "Deep-Space Station",
|
"label": "Deep-Space Station",
|
||||||
"description": "A free-floating station adrift in open system space — refinery, gate, or trading post.",
|
"description": "A free-floating station adrift in open system space — refinery, gate, or trading post.",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
"mass": { "G": [0.8, 1.4], "K": [0.5, 0.8], "M": [0.1, 0.5] }
|
"mass": { "G": [0.8, 1.4], "K": [0.5, 0.8], "M": [0.1, 0.5] }
|
||||||
},
|
},
|
||||||
"binaryChance": 0.05,
|
"binaryChance": 0.05,
|
||||||
"planetClasses": { "rocky": 45, "gas": 25, "ice": 18, "lava": 12 },
|
"planetClasses": { "rocky": 38, "gas": 25, "ice": 18, "lava": 12, "arid": 7 },
|
||||||
"moonChance": 0.3,
|
"moonChance": 0.3,
|
||||||
"beltChance": 0.35,
|
"beltChance": 0.35,
|
||||||
"habitability": 0.1,
|
"habitability": 0.1,
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
"mass": { "M": [0.08, 0.35], "K": [0.5, 0.8] }
|
"mass": { "M": [0.08, 0.35], "K": [0.5, 0.8] }
|
||||||
},
|
},
|
||||||
"binaryChance": 0.12,
|
"binaryChance": 0.12,
|
||||||
"planetClasses": { "rocky": 50, "gas": 15, "ice": 28, "lava": 7 },
|
"planetClasses": { "rocky": 42, "gas": 15, "ice": 28, "lava": 7, "arid": 8 },
|
||||||
"moonChance": 0.45,
|
"moonChance": 0.45,
|
||||||
"beltChance": 0.3,
|
"beltChance": 0.3,
|
||||||
"habitability": 0.22,
|
"habitability": 0.22,
|
||||||
|
|
@ -75,7 +75,7 @@
|
||||||
"mass": { "F": [1.0, 1.6], "G": [0.8, 1.4], "K": [0.5, 0.8] }
|
"mass": { "F": [1.0, 1.6], "G": [0.8, 1.4], "K": [0.5, 0.8] }
|
||||||
},
|
},
|
||||||
"binaryChance": 1.0,
|
"binaryChance": 1.0,
|
||||||
"planetClasses": { "rocky": 30, "gas": 40, "ice": 20, "lava": 10 },
|
"planetClasses": { "rocky": 26, "gas": 40, "ice": 20, "lava": 10, "arid": 4 },
|
||||||
"moonChance": 0.5,
|
"moonChance": 0.5,
|
||||||
"beltChance": 0.6,
|
"beltChance": 0.6,
|
||||||
"habitability": 0.06,
|
"habitability": 0.06,
|
||||||
|
|
@ -113,7 +113,7 @@
|
||||||
"mass": { "G": [0.85, 1.3], "K": [0.6, 0.85] }
|
"mass": { "G": [0.85, 1.3], "K": [0.6, 0.85] }
|
||||||
},
|
},
|
||||||
"binaryChance": 0.03,
|
"binaryChance": 0.03,
|
||||||
"planetClasses": { "rocky": 62, "gas": 18, "ice": 14, "lava": 6 },
|
"planetClasses": { "rocky": 52, "gas": 18, "ice": 14, "lava": 6, "arid": 10 },
|
||||||
"moonChance": 0.35,
|
"moonChance": 0.35,
|
||||||
"beltChance": 0.25,
|
"beltChance": 0.25,
|
||||||
"habitability": 0.5,
|
"habitability": 0.5,
|
||||||
|
|
@ -138,7 +138,7 @@
|
||||||
"mass": { "A": [1.4, 2.1], "F": [1.0, 1.6], "G": [0.8, 1.4] }
|
"mass": { "A": [1.4, 2.1], "F": [1.0, 1.6], "G": [0.8, 1.4] }
|
||||||
},
|
},
|
||||||
"binaryChance": 0.1,
|
"binaryChance": 0.1,
|
||||||
"planetClasses": { "rocky": 20, "gas": 25, "ice": 15, "lava": 40 },
|
"planetClasses": { "rocky": 17, "gas": 25, "ice": 15, "lava": 40, "arid": 3 },
|
||||||
"moonChance": 0.2,
|
"moonChance": 0.2,
|
||||||
"beltChance": 0.8,
|
"beltChance": 0.8,
|
||||||
"habitability": 0.02,
|
"habitability": 0.02,
|
||||||
|
|
@ -161,7 +161,7 @@
|
||||||
"mass": { "M": [0.08, 0.3], "K": [0.4, 0.6] }
|
"mass": { "M": [0.08, 0.3], "K": [0.4, 0.6] }
|
||||||
},
|
},
|
||||||
"binaryChance": 0.08,
|
"binaryChance": 0.08,
|
||||||
"planetClasses": { "rocky": 20, "gas": 10, "ice": 55, "lava": 15 },
|
"planetClasses": { "rocky": 17, "gas": 10, "ice": 55, "lava": 15, "arid": 3 },
|
||||||
"moonChance": 0.1,
|
"moonChance": 0.1,
|
||||||
"beltChance": 0.2,
|
"beltChance": 0.2,
|
||||||
"habitability": 0.02,
|
"habitability": 0.02,
|
||||||
|
|
|
||||||
|
|
@ -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 (class, frame) assignment galaxy-wide', same);
|
||||||
check('same seed ⇒ same home-world frame', g.homeWorldFrame === g2.homeWorldFrame);
|
check('same seed ⇒ same home-world frame', g.homeWorldFrame === g2.homeWorldFrame);
|
||||||
|
|
||||||
// Content frames === galaxy frames (the stamping contract).
|
// Content frames === galaxy frames (the stamping contract). CAPITAL
|
||||||
|
// WORLDS are exempt: their frame is fixed per faction (planets.json →
|
||||||
|
// capitals — the art carries the faction's color), NOT stamped by the
|
||||||
|
// pass (they replace the outermost composition world — see
|
||||||
|
// js/galaxy/SystemGenerator.js), so the pass's stamp for that slot is
|
||||||
|
// intentionally overridden in content.
|
||||||
let stamped = true;
|
let stamped = true;
|
||||||
for (const rec of g.records) {
|
for (const rec of g.records) {
|
||||||
const c = g.ensureContent(rec.id);
|
const c = g.ensureContent(rec.id);
|
||||||
if (JSON.stringify(c.planets.map((p) => p.frame)) !== JSON.stringify(g.planetFrames.get(rec.id))) {
|
const pass = g.planetFrames.get(rec.id) ?? [];
|
||||||
|
const frames = c.planets.map((p) => (p.capital ? null : p.frame));
|
||||||
|
const passFrames = pass.map((f, i) => (c.planets[i]?.capital ? null : f));
|
||||||
|
if (JSON.stringify(frames) !== JSON.stringify(passFrames)) {
|
||||||
stamped = false;
|
stamped = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
check('content planet frames === the galaxy pass (lazy === eager stamping)', stamped);
|
check('content planet frames === the galaxy pass (lazy === eager stamping; capitals wear their fixed faction face)', stamped);
|
||||||
|
|
||||||
const g3 = Galaxy.create('frames-test-OTHER');
|
const g3 = Galaxy.create('frames-test-OTHER');
|
||||||
let diff = false;
|
let diff = false;
|
||||||
|
|
|
||||||
|
|
@ -93,11 +93,21 @@ const b = others[1];
|
||||||
check('snap: home/current flags', recA.isHome === false && recA.isCurrent === false);
|
check('snap: home/current flags', recA.isHome === false && recA.isCurrent === false);
|
||||||
check('snap: gate counts from the jump network',
|
check('snap: gate counts from the jump network',
|
||||||
recA.gates === (galaxy.jumpNetwork.gates.get(a.id)?.length ?? 0));
|
recA.gates === (galaxy.jumpNetwork.gates.get(a.id)?.length ?? 0));
|
||||||
check('snap: FACTIONS seam present (faction: null, not yet implemented)',
|
const FIDS = new Set(Object.keys(config.get('factions.factions', {})));
|
||||||
snap.systems.every((s) => 'faction' in s && s.faction === null));
|
check('snap: FACTIONS live (faction null|id + capital null|designation; the neutral capital is designated)',
|
||||||
|
snap.systems.every((s) => 'faction' in s && (s.faction === null || FIDS.has(s.faction)))
|
||||||
|
&& snap.systems.every((s) => 'capital' in s && (s.capital === null || ['neutral', ...FIDS].includes(s.capital)))
|
||||||
|
&& snap.systems.some((s) => s.capital === 'neutral')
|
||||||
|
&& snap.systems.some((s) => s.capital === null && s.faction === null));
|
||||||
|
|
||||||
check('snap: a spanning tree = n−1 lanes (dedup’d)',
|
// The lane web = the spanning TREE (n−1 undirected lanes) PLUS the
|
||||||
galaxy.records.length === 9 ? snap.edges.length === 8 : snap.edges.length === galaxy.records.length - 1);
|
// BRIDGES (data/gates.json → bridges — the cross-branch middle-belt
|
||||||
|
// links, each one dedup'd lane).
|
||||||
|
const nBridges = (galaxy.jumpNetwork?.bridges ?? []).length;
|
||||||
|
check('snap: lanes = n−1 tree lanes + the bridges (dedup’d)',
|
||||||
|
snap.edges.length === galaxy.records.length - 1 + nBridges);
|
||||||
|
check('snap: the bridge lanes are marked (bridge: true, exactly the placed bridges)',
|
||||||
|
snap.edges.filter((e) => e.bridge).length === nBridges);
|
||||||
const homeA = snap.edges.find((e) => edgeKey(e.a, e.b) === lane);
|
const homeA = snap.edges.find((e) => edgeKey(e.a, e.b) === lane);
|
||||||
check('snap: the traveled lane is used', homeA?.used === true);
|
check('snap: the traveled lane is used', homeA?.used === true);
|
||||||
check('snap: the live lane out of the current system is live', homeA?.live === true);
|
check('snap: the live lane out of the current system is live', homeA?.live === true);
|
||||||
|
|
|
||||||
|
|
@ -332,6 +332,11 @@ let big;
|
||||||
const kinds = config.get('settlements.kinds', {});
|
const kinds = config.get('settlements.kinds', {});
|
||||||
check('settlement kinds defined with theme + population range', Object.keys(kinds).length >= 4 && Object.values(kinds).every((k) => typeof k.theme?.color === 'string' && k.population?.min >= 0 && k.population?.max >= k.population?.min));
|
check('settlement kinds defined with theme + population range', Object.keys(kinds).length >= 4 && Object.values(kinds).every((k) => typeof k.theme?.color === 'string' && k.population?.min >= 0 && k.population?.max >= k.population?.min));
|
||||||
|
|
||||||
|
// FACTIONS (data/factions.json — the roster's faction pass, js/galaxy/
|
||||||
|
// Galaxy.js → _assignFactions): a settlement's `owner` is null (neutral
|
||||||
|
// wild space) or one of the faction ids — never anything else.
|
||||||
|
const FIDS = new Set(Object.keys(config.get('factions.factions', {})));
|
||||||
|
|
||||||
// Structure across a big sample of the full galaxy.
|
// Structure across a big sample of the full galaxy.
|
||||||
const big2 = Galaxy.create(SEED);
|
const big2 = Galaxy.create(SEED);
|
||||||
const sample = big2.records.slice(0, 3000);
|
const sample = big2.records.slice(0, 3000);
|
||||||
|
|
@ -346,12 +351,12 @@ let big;
|
||||||
} else if (s.anchor.type !== 'space') structOk = false;
|
} else if (s.anchor.type !== 'space') structOk = false;
|
||||||
const pop = kinds[s.kind].population;
|
const pop = kinds[s.kind].population;
|
||||||
if (s.population < pop.min || s.population > pop.max) structOk = false;
|
if (s.population < pop.min || s.population > pop.max) structOk = false;
|
||||||
if (s.owner !== null) structOk = false; // reserved seam, unused for now
|
if (s.owner !== null && !FIDS.has(s.owner)) structOk = false; // faction seam — the roster's faction pass fills it
|
||||||
if (!s.name) structOk = false;
|
if (!s.name) structOk = false;
|
||||||
}
|
}
|
||||||
if (!structOk) break;
|
if (!structOk) break;
|
||||||
}
|
}
|
||||||
check('settlement structure: known kinds, valid anchors, population in range, owner=null (faction seam)', structOk);
|
check('settlement structure: known kinds, valid anchors, population in range, owner = null or a faction id', structOk);
|
||||||
|
|
||||||
// The lived-in mix actually shows up across the sample.
|
// The lived-in mix actually shows up across the sample.
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
|
@ -363,15 +368,17 @@ let big;
|
||||||
const p = c.planets.find((pl) => pl.ordinal === s.anchor.ordinal);
|
const p = c.planets.find((pl) => pl.ordinal === s.anchor.ordinal);
|
||||||
if (s.kind === 'colony' && !p?.habitable) anchorRulesOk = false;
|
if (s.kind === 'colony' && !p?.habitable) anchorRulesOk = false;
|
||||||
if (s.kind === 'cloudBase' && p?.class !== 'gas') anchorRulesOk = false;
|
if (s.kind === 'cloudBase' && p?.class !== 'gas') anchorRulesOk = false;
|
||||||
|
if (s.kind === 'capital' && !String(p?.class ?? '').startsWith('capital')) anchorRulesOk = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
check('the lived-in mix appears (colonies, miners, cloud bases, stations, beacons)', ['colony', 'miningStation', 'cloudBase', 'deepSpaceStation', 'waypoint'].every((k) => seen.has(k)));
|
check('the lived-in mix appears (colonies, miners, cloud bases, stations, beacons)', ['colony', 'miningStation', 'cloudBase', 'deepSpaceStation', 'waypoint'].every((k) => seen.has(k)));
|
||||||
check('colonies sit on habitable worlds; cloud bases ride gas giants', anchorRulesOk);
|
check('colonies sit on habitable worlds; cloud bases ride gas giants; capitals sit on capital worlds', anchorRulesOk);
|
||||||
|
|
||||||
// The all-planets-settled rule (for now): EVERY world hosts a settlement,
|
// The all-planets-settled rule (for now): EVERY world hosts a settlement,
|
||||||
// the kind that fits its class (data/settlements.json → settledKindByClass
|
// the kind that fits its class (data/settlements.json → settledKindByClass
|
||||||
// — habitable rocky ⇒ colony, other rocky/ice/lava ⇒ mining station,
|
// — habitable rocky ⇒ colony, ARID ⇒ colony (always habitable),
|
||||||
// gas ⇒ cloud base).
|
// other rocky/ice/lava ⇒ mining station, gas ⇒ cloud base,
|
||||||
|
// capital worlds ⇒ capital).
|
||||||
let allSettledOk = true;
|
let allSettledOk = true;
|
||||||
for (const r of sample) {
|
for (const r of sample) {
|
||||||
const c = big2.ensureContent(r.id);
|
const c = big2.ensureContent(r.id);
|
||||||
|
|
@ -381,15 +388,17 @@ let big;
|
||||||
);
|
);
|
||||||
const kindOk = c.settlements.filter(
|
const kindOk = c.settlements.filter(
|
||||||
(s) => s.anchor?.type === 'planet' && s.anchor?.ordinal === p.ordinal,
|
(s) => s.anchor?.type === 'planet' && s.anchor?.ordinal === p.ordinal,
|
||||||
).every((s) =>
|
).every((s) => {
|
||||||
(p.class === 'gas' && s.kind === 'cloudBase') ||
|
if (p.class === 'gas') return s.kind === 'cloudBase';
|
||||||
(p.class !== 'gas' && (s.kind === 'colony' || s.kind === 'miningStation'))
|
if (String(p.class).startsWith('capital')) return s.kind === 'capital';
|
||||||
);
|
if (p.class === 'arid') return s.kind === 'colony';
|
||||||
|
return s.kind === 'colony' || s.kind === 'miningStation';
|
||||||
|
});
|
||||||
if (!anchored || !kindOk) { allSettledOk = false; break; }
|
if (!anchored || !kindOk) { allSettledOk = false; break; }
|
||||||
}
|
}
|
||||||
if (!allSettledOk) break;
|
if (!allSettledOk) break;
|
||||||
}
|
}
|
||||||
check('every planet is settled with a class-fitting kind (colonies only on habitable rocky worlds)', allSettledOk);
|
check('every planet is settled with a class-fitting kind (colonies on habitable rocky + arid worlds, capitals on capital worlds)', allSettledOk);
|
||||||
|
|
||||||
// OBJECT COMPOSITION: the BARREN systems (objectCount → 0) are the only
|
// OBJECT COMPOSITION: the BARREN systems (objectCount → 0) are the only
|
||||||
// unsettled ones — the gate-only dead-end leaves of the jump network
|
// unsettled ones — the gate-only dead-end leaves of the jump network
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,12 @@
|
||||||
* - the network is STRONGLY CONNECTED: from the home system every
|
* - the network is STRONGLY CONNECTED: from the home system every
|
||||||
* other system is reachable (forward BFS) AND every system can
|
* other system is reachable (forward BFS) AND every system can
|
||||||
* reach home (reverse BFS) — no closed systems, no trapped sets;
|
* reach home (reverse BFS) — no closed systems, no trapped sets;
|
||||||
* - the network is a PURE SPANNING TREE (shortcuts OFF in the config):
|
* - the network is a SPANNING TREE of N−1 undirected tree edges
|
||||||
* exactly N−1 undirected edges — no loops, one route between any two
|
* (shortcuts OFF in the config) — the maze's single path per branch
|
||||||
* systems (the maze) — and every BARREN system (objectCount → 0) is
|
* pair — PLUS the BRIDGES: the cross-branch middle-belt links
|
||||||
* a LEAF: exactly one gate, in and out the same way (the dead end);
|
* (data/gates.json → bridges), each a local bidirectional lane, and
|
||||||
|
* every BARREN system (objectCount → 0) is a LEAF: exactly one gate,
|
||||||
|
* in and out the same way (the dead end);
|
||||||
*
|
*
|
||||||
* the IN-SYSTEM PLACEMENT (js/galaxy/SystemGenerator.js → layoutGates):
|
* the IN-SYSTEM PLACEMENT (js/galaxy/SystemGenerator.js → layoutGates):
|
||||||
* - content.jumps matches the network (count + destinations);
|
* - content.jumps matches the network (count + destinations);
|
||||||
|
|
@ -179,16 +181,48 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
|
||||||
}
|
}
|
||||||
check('every system can reach home (no closed systems, no trapped sets)', rev.size === g.records.length, `${rev.size}/${g.records.length}`);
|
check('every system can reach home (no closed systems, no trapped sets)', rev.size === g.records.length, `${rev.size}/${g.records.length}`);
|
||||||
|
|
||||||
// TREE (shortcuts OFF in data/gates.json): exactly N−1 undirected
|
// TREE + BRIDGES (data/gates.json): the tree is exactly N−1 undirected
|
||||||
// edges — no loops, no redundant routes (the maze's single path).
|
// tree edges (shortcuts OFF — the maze's single path per branch pair),
|
||||||
|
// plus the BRIDGES — the cross-branch middle-belt links (data/gates.json
|
||||||
|
// → bridges — the faction design's "don't go back to the trunk" routes,
|
||||||
|
// data/factions.json). Each bridge is ONE undirected lane (both ends'
|
||||||
|
// gate lists carry it) between two systems of DIFFERENT branches,
|
||||||
|
// local (the locality check above already covers it) and within the
|
||||||
|
// gate budget (the 1–maxGates check above already covers it).
|
||||||
|
const BRC = config.get('gates.bridges', {});
|
||||||
|
const BR_PER_PAIR = BRC.enabled === false ? 0 : Math.max(0, Math.floor(Number(BRC.perPair) || 0));
|
||||||
const pairs = new Set();
|
const pairs = new Set();
|
||||||
let treeEdges = 0;
|
|
||||||
for (const r of g.records) for (const t of g.jumpGatesFor(r.id)) {
|
for (const r of g.records) for (const t of g.jumpGatesFor(r.id)) {
|
||||||
const key = [r.id, t.id].sort().join('\u0000');
|
pairs.add([r.id, t.id].sort().join('\u0000'));
|
||||||
if (!pairs.has(key)) pairs.add(key), treeEdges++;
|
}
|
||||||
|
const bridges = g.jumpNetwork?.bridges ?? [];
|
||||||
|
const bridgeKeys = new Set(bridges.map(([a, b]) => [a, b].sort().join('\u0000')));
|
||||||
|
const treeEdges = [...pairs].filter((k) => !bridgeKeys.has(k)).length;
|
||||||
|
check(`spanning tree: exactly ${g.records.length - 1} tree edges (no loops)`,
|
||||||
|
treeEdges === g.records.length - 1, `${treeEdges} tree edges over ${g.records.length} systems`);
|
||||||
|
if (BR_PER_PAIR > 0) {
|
||||||
|
// The branches are the tree's top-level subtrees (js/galaxy/JumpNetwork.js
|
||||||
|
// exposes branchOf — the exact spanning tree the bridge pass used; a BFS
|
||||||
|
// over the final gate list can re-parent systems across branches,
|
||||||
|
// since bridges are loops). Every bridge must span two DIFFERENT
|
||||||
|
// branches (that's the whole point: branch-to-branch without the
|
||||||
|
// trunk), and the count stays within perPair per pair (degraded only
|
||||||
|
// when the budget/pool ran dry).
|
||||||
|
const bOf = g.jumpNetwork?.branchOf ?? new Map();
|
||||||
|
let spanOk = true;
|
||||||
|
let spanWhy = '';
|
||||||
|
for (const [a, b] of bridges) {
|
||||||
|
const fa = bOf.get(a), fb = bOf.get(b);
|
||||||
|
if (fa === undefined || fb === undefined || fa === fb) {
|
||||||
|
spanOk = false;
|
||||||
|
spanWhy = `${a}↔${b} (branch ${fa ?? '?'} / ${fb ?? '?'})`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check('every bridge spans two DIFFERENT branches (the inter-branch links)', spanOk, spanWhy);
|
||||||
|
check(`bridges: ${bridges.length} cross-branch lane(s), ≤ ${BR_PER_PAIR} per pair`,
|
||||||
|
bridges.length > 0 && bridges.length <= BR_PER_PAIR * 3 * 2, `${bridges.length} bridges (want > 0, ≤ ${BR_PER_PAIR * 6})`);
|
||||||
}
|
}
|
||||||
check(`spanning tree: exactly ${g.records.length - 1} undirected edges (no loops)`,
|
|
||||||
treeEdges === g.records.length - 1, `${treeEdges} edges over ${g.records.length} systems`);
|
|
||||||
|
|
||||||
// MAZE DEAD ENDS: every barren system (objectCount → 0) is a LEAF —
|
// MAZE DEAD ENDS: every barren system (objectCount → 0) is a LEAF —
|
||||||
// exactly one gate, in and out the same way. (Home is exempt — it
|
// exactly one gate, in and out the same way. (Home is exempt — it
|
||||||
|
|
|
||||||
|
|
@ -190,9 +190,14 @@ branch is now the normal case, not a defensive fallback. Model & seams:
|
||||||
`floor`). Each record carries `d` (0 = home corner, 1 = far corner) and
|
`floor`). Each record carries `d` (0 = home corner, 1 = far corner) and
|
||||||
its `zone` so density — and later faction strength, hazard, and trade
|
its `zone` so density — and later faction strength, hazard, and trade
|
||||||
value — is per-system, not global.
|
value — is per-system, not global.
|
||||||
- **Reserved seam: `owner`** — every settlement has `owner: null`. That's
|
- **Faction ownership (LIVE)** — the roster's faction pass
|
||||||
where **factions and pirates** will plug in later (claim, flag,
|
(`js/galaxy/Galaxy.js → _assignFactions`, data/factions.json) assigns
|
||||||
relations). Deliberately absent for now — no factions yet.
|
each system a `faction` (or null = neutral wild space) and designates
|
||||||
|
the four capitals; every settlement's `owner` is populated from the
|
||||||
|
system's territory (a capital world's settlement is owned by its
|
||||||
|
faction; neutral/wild space stays owner-less — pirates are still the
|
||||||
|
future layer). The dossier (js/galaxy/SystemReport.js) reads the
|
||||||
|
faction's label + color.
|
||||||
- **Pure report formatter** — `js/galaxy/SystemReport.js`
|
- **Pure report formatter** — `js/galaxy/SystemReport.js`
|
||||||
(`formatSystemReport(content)` → title/subtitle/faction/population/
|
(`formatSystemReport(content)` → title/subtitle/faction/population/
|
||||||
status). Compact by design: the HUD shows the system name, identity
|
status). Compact by design: the HUD shows the system name, identity
|
||||||
|
|
@ -452,19 +457,43 @@ before `captureState()`, so the save carries the footprint; `SaveData`
|
||||||
captures/restores/resets both, and a save that predates the tab
|
captures/restores/resets both, and a save that predates the tab
|
||||||
defaults to "current system charted, nothing traveled" (old saves load).
|
defaults to "current system charted, nothing traveled" (old saves load).
|
||||||
|
|
||||||
**FACTIONS (planned — not yet implemented):** the snapshot already
|
**FACTIONS (the worldgen layer is LIVE — data/factions.json):** the
|
||||||
reserves the seam — every system carries `faction: null`, and
|
roster's faction pass (`js/galaxy/Galaxy.js → _assignFactions`) assigns
|
||||||
`GalaxyView`'s header documents the two reserved rendering passes for
|
**territories** — each top-level BRANCH of the jump tree (a subtree
|
||||||
when they ship: a **faction color layer** over the stars (a ring/tint on
|
rooted at one of home's gate neighbors) is one faction's territory,
|
||||||
top of the archetype color) and a **territory region fill** (the exact
|
starting one system into the branch and stopping one before its
|
||||||
hull/fill recipe of the charted region, per faction color + relation
|
dead-end tips (the ring right outside home + the branch tips stay
|
||||||
alpha). The settlements' `owner: null` seam (above) +
|
neutral wild space) — and designates the **four capitals**: the NEUTRAL
|
||||||
`Reputation.factionStanding()` are the data-side counterparts.
|
capital (always a direct gate neighbor of home, its system stays
|
||||||
|
neutral) + one faction capital per faction (apex / mindweave / zenith,
|
||||||
|
middle band, seeded). The branch↔faction order is a SEEDED SHUFFLE — a
|
||||||
|
new galaxy reshuffles who sits where; same seed ⇒ identical. Each
|
||||||
|
capital's system grows its capital planet (class `capital*`,
|
||||||
|
planets.png frames 18..21 — data/planets.json → capitals; the
|
||||||
|
`capital` settlement — data/settlements.json; `owner` = the faction —
|
||||||
|
the reputation seam). The plate's FACTION LAYER (js/ui/GalaxyView.js →
|
||||||
|
`_redrawFactions`, data/map.json → galaxy.factions) rings every
|
||||||
|
territory system in its faction's color and marks the capitals with the
|
||||||
|
double-ring seat. The 'Leaving the Nest' quest rides it: Trestle's last
|
||||||
|
brief (data/comms.json: trestle-brief-3 — "this star cluster's capital")
|
||||||
|
sets the player's destination to the neutral capital's seat on the
|
||||||
|
acknowledgment (js/comms/CommsHub.js → onEnd, js/scenes/GameScene.js →
|
||||||
|
_commEnded, js/galaxy/Galaxy.js → neutralCapitalTarget()). The
|
||||||
|
cross-branch BRIDGES (data/gates.json → bridges; the bridge pass in
|
||||||
|
js/galaxy/JumpNetwork.js) link the territories through the middle belt
|
||||||
|
so the factions can reach — and fight — each other without routing
|
||||||
|
through the trunk.
|
||||||
|
|
||||||
|
The next layer: the
|
||||||
|
per-faction territory-region FILL (this file's hull pass, colored per
|
||||||
|
faction) + the relation tiers (`Reputation.factionStanding()` —
|
||||||
|
the standing data) + reachability / combat / trade off the territories.
|
||||||
|
`Reputation.factionStanding()` is the data-side counterpart.
|
||||||
|
|
||||||
- **Tuning** — all of it in `data/map.json → galaxy` (stars, pulse per
|
- **Tuning** — all of it in `data/map.json → galaxy` (stars, pulse per
|
||||||
archetype, edges, hull, flow, dust, labels, zoom, reveal, dialog copy).
|
archetype, edges, hull, flow, dust, labels, zoom, reveal, dialog copy).
|
||||||
- **Tests** — `dev/galaxy-map.test.mjs` (edgeKey symmetry, the snapshot's
|
- **Tests** — `dev/galaxy-map.test.mjs` (edgeKey symmetry, the snapshot's
|
||||||
systems/lanes/flags + the faction seam, hull/padding geometry incl.
|
systems/lanes/flags (incl. the faction data + bridge lanes), hull/padding geometry incl.
|
||||||
the parallel-polygon corners, the pulse's bounds + determinism, the
|
the parallel-polygon corners, the pulse's bounds + determinism, the
|
||||||
archetype colors, and the save round-trip incl. the legacy default).
|
archetype colors, and the save round-trip incl. the legacy default).
|
||||||
|
|
||||||
|
|
@ -995,7 +1024,7 @@ incl. remaining-time preservation), `dev/research-builds.test.mjs`
|
||||||
the build carries it), `dev/saves.test.mjs` (the builds field rides the
|
the build carries it), `dev/saves.test.mjs` (the builds field rides the
|
||||||
pending restore; legacy records load clean).
|
pending restore; legacy records load clean).
|
||||||
|
|
||||||
## Reputation — standing on planets & space stations (data layer; factions later)
|
## Reputation — standing on planets & space stations (data layer)
|
||||||
|
|
||||||
The player holds a REPUTATION (standing) on each planet and space station:
|
The player holds a REPUTATION (standing) on each planet and space station:
|
||||||
- **Scale** — `data/reputation.json`: `min…max` (−20…+20, max = best),
|
- **Scale** — `data/reputation.json`: `min…max` (−20…+20, max = best),
|
||||||
|
|
@ -1004,13 +1033,17 @@ The player holds a REPUTATION (standing) on each planet and space station:
|
||||||
- **The home-world exception** — the player's home world is ALWAYS +20:
|
- **The home-world exception** — the player's home world is ALWAYS +20:
|
||||||
pinned. Nothing sets, changes, or clears it (it's not even storable —
|
pinned. Nothing sets, changes, or clears it (it's not even storable —
|
||||||
the key is derived from the scale itself).
|
the key is derived from the scale itself).
|
||||||
- **The faction check (placeholder)** — `Reputation.standingFor(place)`
|
- **The faction check** — `Reputation.standingFor(place)` resolves:
|
||||||
resolves: **home → stored standing → the place's `owner` → faction
|
**home → stored standing → the place's `owner` → faction
|
||||||
standing → neutral**. `owner` is the settlements' reserved seam (always
|
standing → neutral**. `owner` is now POPULATED by the worldgen layer
|
||||||
`null` until factions exist) and `Reputation.factionStanding(id)` is a
|
(data/factions.json — the roster's faction pass sets a settlement's
|
||||||
marked `TODO(factions)` stub returning null — so today the whole galaxy
|
owner to its system's faction; neutral/wild space stays owner-less) so
|
||||||
reads neutral (home: +20). When factions land: populate `owner` in
|
faction-controlled places resolve through the check; the per-faction
|
||||||
generation + fill `factionStanding()`; the resolution order is final.
|
standing itself is still a marked `TODO(factions)` stub —
|
||||||
|
`Reputation.factionStanding(id)` returns null (no standing with any
|
||||||
|
faction yet — the player's real relations land there; the resolution
|
||||||
|
order is final), so today a faction-controlled place still reads
|
||||||
|
neutral (home: +20).
|
||||||
- **The mutation seams** — `set(key, value)` / `change(key, delta)`, both
|
- **The mutation seams** — `set(key, value)` / `change(key, delta)`, both
|
||||||
clamped to the scale; "ways to influence reputation" land on these.
|
clamped to the scale; "ways to influence reputation" land on these.
|
||||||
- **Place identity** — every planet and settlement now carries a stable
|
- **Place identity** — every planet and settlement now carries a stable
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,10 @@ export class CommsHub {
|
||||||
* is on the run's played-ledger
|
* is on the run's played-ledger
|
||||||
* already — scene bookkeeping
|
* already — scene bookkeeping
|
||||||
* that rides on a comm starting)
|
* that rides on a comm starting)
|
||||||
|
* onEnd?: (comm: object) => void, the comm ENDED (for 'ack' comms
|
||||||
|
* the ACKNOWLEDGE press itself —
|
||||||
|
* scene bookkeeping that rides on
|
||||||
|
* a comm being acknowledged)
|
||||||
* }} [o]
|
* }} [o]
|
||||||
*/
|
*/
|
||||||
constructor(scene, o = {}) {
|
constructor(scene, o = {}) {
|
||||||
|
|
@ -83,6 +87,7 @@ export class CommsHub {
|
||||||
this.anchor = typeof o.anchor === 'function' ? o.anchor : null;
|
this.anchor = typeof o.anchor === 'function' ? o.anchor : null;
|
||||||
this.onPause = typeof o.onPause === 'function' ? o.onPause : null;
|
this.onPause = typeof o.onPause === 'function' ? o.onPause : null;
|
||||||
this.onStart = typeof o.onStart === 'function' ? o.onStart : null;
|
this.onStart = typeof o.onStart === 'function' ? o.onStart : null;
|
||||||
|
this.onEnd = typeof o.onEnd === 'function' ? o.onEnd : null;
|
||||||
|
|
||||||
this.queue = []; // { def, dueAt, assets, loadingDone }
|
this.queue = []; // { def, dueAt, assets, loadingDone }
|
||||||
this.current = null; // the comm in play (its def)
|
this.current = null; // the comm in play (its def)
|
||||||
|
|
@ -350,6 +355,7 @@ export class CommsHub {
|
||||||
/** The comm is over: tear down, unpause, hand the next one its turn. */
|
/** The comm is over: tear down, unpause, hand the next one its turn. */
|
||||||
_finish() {
|
_finish() {
|
||||||
if (!this.current) return;
|
if (!this.current) return;
|
||||||
|
const def = this.current;
|
||||||
this._stopVoice();
|
this._stopVoice();
|
||||||
this._duck(false);
|
this._duck(false);
|
||||||
this._setPaused(false);
|
this._setPaused(false);
|
||||||
|
|
@ -359,6 +365,17 @@ export class CommsHub {
|
||||||
this.current = null;
|
this.current = null;
|
||||||
this._voiceDone = true;
|
this._voiceDone = true;
|
||||||
if (box) box.close(); // the fade-out; the hub moves on (the next pump)
|
if (box) box.close(); // the fade-out; the hub moves on (the next pump)
|
||||||
|
// The scene's bookkeeping for the ENDED comm (for 'ack' comms this
|
||||||
|
// fires on the ACKNOWLEDGE press — GameScene rides it: Trestle's
|
||||||
|
// last brief sets the player's destination). A failing hook must
|
||||||
|
// not hold the queue.
|
||||||
|
if (this.onEnd) {
|
||||||
|
try {
|
||||||
|
this.onEnd(def);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[comms] onEnd hook failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tear everything down (scene shutdown / destroy). Idempotent. */
|
/** Tear everything down (scene shutdown / destroy). Idempotent. */
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,13 @@ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||||||
* in any order of generation. Dev/test tools rely on this.
|
* in any order of generation. Dev/test tools rely on this.
|
||||||
*
|
*
|
||||||
* Extension points for later world rules (see docs/PROJECT_NOTES.md):
|
* Extension points for later world rules (see docs/PROJECT_NOTES.md):
|
||||||
* - FACTIONS: assign each system a faction id (or null = wild space)
|
* - FACTIONS: ASSIGNED AT ROSTER TIME by the faction pass (_assignFactions,
|
||||||
* at roster time — the record is the seam (reputation's `owner`
|
* data/factions.json) — each system's record carries `faction` (or
|
||||||
* resolution and the galaxy plate's `faction: null` are reserved).
|
* null = neutral wild space) + `capital` (the four designated
|
||||||
|
* capitals: neutral + apex/mindweave/zenith). The reputation seam
|
||||||
|
* (js/reputation/Reputation.js → factionStanding) and the galaxy
|
||||||
|
* plate's faction ring layer (js/ui/GalaxyView.js) read them; reachability
|
||||||
|
* / combat / trade off the territories is the next layer.
|
||||||
* - COMBAT / TRADE: read difficulty and price levels off `record.d`
|
* - COMBAT / TRADE: read difficulty and price levels off `record.d`
|
||||||
* and `record.zone` (the middle zone is where the zone borders cross
|
* and `record.zone` (the middle zone is where the zone borders cross
|
||||||
* — the planned contested space + transit trade hubs).
|
* — the planned contested space + transit trade hubs).
|
||||||
|
|
@ -181,8 +185,11 @@ export class Galaxy {
|
||||||
// never leak into it. d (0 = home corner, 1 = far corner) and zone
|
// never leak into it. d (0 = home corner, 1 = far corner) and zone
|
||||||
// stay on the record: the settlement generator uses d (home→far
|
// stay on the record: the settlement generator uses d (home→far
|
||||||
// density), and factions/trade/combat will read difficulty off both.
|
// density), and factions/trade/combat will read difficulty off both.
|
||||||
|
// faction/capital default to null (neutral wild space, no capital)
|
||||||
|
// — the faction pass (_assignFactions) stamps the territories +
|
||||||
|
// capital designations after the jump network exists.
|
||||||
const name = NameGenerator.star(Rng.derive(this.seed, 'name', id));
|
const name = NameGenerator.star(Rng.derive(this.seed, 'name', id));
|
||||||
const rec = { id, name, type, x: p.x, y: p.y, d, zone };
|
const rec = { id, name, type, x: p.x, y: p.y, d, zone, faction: null, capital: null };
|
||||||
records.push(rec);
|
records.push(rec);
|
||||||
this.byId.set(id, rec);
|
this.byId.set(id, rec);
|
||||||
}
|
}
|
||||||
|
|
@ -253,14 +260,18 @@ export class Galaxy {
|
||||||
}
|
}
|
||||||
|
|
||||||
// The JUMP NETWORK (data/gates.json): which star each system's jump
|
// The JUMP NETWORK (data/gates.json): which star each system's jump
|
||||||
// gates reach. A PURE SPANNING TREE of the nearest-star graph (no
|
// gates reach. A degree-limited spanning TREE of the nearest-star
|
||||||
// shortcuts — data/gates.json → shortcuts:false) — the galaxy reads as
|
// graph — the galaxy reads as a MAZE: (nearly) one route between any
|
||||||
// a MAZE: exactly one route between any two systems, no closed loops.
|
// two systems, dead ends at the leaves — and every barren system is
|
||||||
// Tree edges run BOTH ways, so from any system the player can reach
|
// a dead-end LEAF (exactly one gate, in and out the same way). The
|
||||||
// any other and every jump has a RETURN gate (no trapped sets). Each
|
// deliberate exception: the BRIDGES (data/gates.json → bridges)
|
||||||
// system holds 1–maxGates gates (its tree degree); a BARREN system is
|
// cross-link the branches' middle belts, so one branch doesn't have
|
||||||
// a dead-end LEAF — exactly one gate (in and out the same way).
|
// to trek back to the trunk (home) to reach another (the faction
|
||||||
// Deterministic: same roster ⇒ same network.
|
// design — data/factions.json). Tree edges run BOTH ways, so from any
|
||||||
|
// system the player can reach any other and every jump has a RETURN
|
||||||
|
// gate (no trapped sets). Each system holds 1–maxGates gates (its
|
||||||
|
// degree, bridges included). Deterministic: same roster ⇒ same
|
||||||
|
// network.
|
||||||
const pool = Math.max(1, Math.min(count - 1, (config.get('gates.neighborPool', 8) | 0)));
|
const pool = Math.max(1, Math.min(count - 1, (config.get('gates.neighborPool', 8) | 0)));
|
||||||
this.jumpNetwork = buildJumpNetwork({
|
this.jumpNetwork = buildJumpNetwork({
|
||||||
records,
|
records,
|
||||||
|
|
@ -270,6 +281,7 @@ export class Galaxy {
|
||||||
shortcuts: config.get('gates.shortcuts', true) === true,
|
shortcuts: config.get('gates.shortcuts', true) === true,
|
||||||
barren,
|
barren,
|
||||||
rootId: this.currentSystemId,
|
rootId: this.currentSystemId,
|
||||||
|
bridges: config.get('gates.bridges', null),
|
||||||
});
|
});
|
||||||
// Defensive repairs that had to fire (0 on a healthy kNN graph).
|
// Defensive repairs that had to fire (0 on a healthy kNN graph).
|
||||||
this.jumpNetworkRepaired = this.jumpNetwork.repaired;
|
this.jumpNetworkRepaired = this.jumpNetwork.repaired;
|
||||||
|
|
@ -277,6 +289,15 @@ export class Galaxy {
|
||||||
console.warn(`[orbit] jump network: ${this.jumpNetworkRepaired} system(s) needed a repair attach`);
|
console.warn(`[orbit] jump network: ${this.jumpNetworkRepaired} system(s) needed a repair attach`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FACTIONS (data/factions.json — the faction pass): branch
|
||||||
|
// territories + the four CAPITAL designations (neutral + apex /
|
||||||
|
// mindweave / zenith), stamped on the roster records
|
||||||
|
// (record.faction / record.capital). Runs after the jump network
|
||||||
|
// (it needs the branches the tree defines) and before the frame
|
||||||
|
// pass (independent of it — the capital planet is not a
|
||||||
|
// composition planet, so the frame pass never sees it).
|
||||||
|
this._assignFactions(barren);
|
||||||
|
|
||||||
// FRAME DIVERSITY (js/galaxy/PlanetFrames.js): the galaxy-wide
|
// FRAME DIVERSITY (js/galaxy/PlanetFrames.js): the galaxy-wide
|
||||||
// (class, frame) assignment — each planet's sheet frame avoids what
|
// (class, frame) assignment — each planet's sheet frame avoids what
|
||||||
// the NEAREST stars already wear for that class, so the same face is
|
// the NEAREST stars already wear for that class, so the same face is
|
||||||
|
|
@ -312,6 +333,170 @@ export class Galaxy {
|
||||||
this.stationFrames = stationFrames; // Map id → frame (station-bearing systems)
|
this.stationFrames = stationFrames; // Map id → frame (station-bearing systems)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The FACTION PASS — branch territories + the four capital designations
|
||||||
|
* (data/factions.json; see docs/PROJECT_NOTES.md "Factions"). Runs at
|
||||||
|
* roster build, before any content generation, so the stamp is pure
|
||||||
|
* function of the seed (lazy === eager: content generation only READS
|
||||||
|
* the record fields it leaves behind):
|
||||||
|
*
|
||||||
|
* BRANCHES — the jump tree's top-level subtrees, rooted at the home
|
||||||
|
* system's gate neighbors (home has ≤ maxGates gates, so ≤ 3).
|
||||||
|
* NEUTRAL CAPITAL — a seeded pick among home's gate neighbors (the
|
||||||
|
* "always connected to the home star" rule); its system stays
|
||||||
|
* faction-NEUTRAL even though it roots one faction's branch
|
||||||
|
* (a neutral hub at the edge of that corridor).
|
||||||
|
* FACTION CAPITALS — one per faction (apex / mindweave / zenith): a
|
||||||
|
* SEEDED pick inside that faction's branch, preferred in the middle
|
||||||
|
* band (d in 0.37..0.63 — the home→far diagonal's middle slice),
|
||||||
|
* on a non-barren system (it must be able to host a planet). The
|
||||||
|
* branch↔faction assignment is a SEEDED SHUFFLE — a new galaxy
|
||||||
|
* reshuffles who sits where (never a fixed order), same seed ⇒
|
||||||
|
* identical placement.
|
||||||
|
* TERRITORY — a branch's systems at depth 2 … (maxDepth−1) (home =
|
||||||
|
* depth 0, its gate neighbors = depth 1): the ring right outside
|
||||||
|
* home and every branch's dead-end tips stay NEUTRAL WILD SPACE;
|
||||||
|
* anything not in a territory is neutral. A faction capital always
|
||||||
|
* sits in its faction's territory (fallbacks only when the branch
|
||||||
|
* is too short to have one).
|
||||||
|
*
|
||||||
|
* @param {Set<string>|null} barren the gate-only dead ends (excluded from
|
||||||
|
* capital candidacy — they can't host a world)
|
||||||
|
*/
|
||||||
|
_assignFactions(barren) {
|
||||||
|
const FACTIONS = ['apex', 'mindweave', 'zenith'];
|
||||||
|
const dMin = 0.37, dMax = 0.63; // the middle band (data/galaxy.json zones)
|
||||||
|
const homeId = this.homeSystemId;
|
||||||
|
|
||||||
|
// Branch + depth of every system — from the JUMP NETWORK's own
|
||||||
|
// spanning tree (js/galaxy/JumpNetwork.js exposes branches/branchOf/
|
||||||
|
// depth): the bridge pass and the faction pass must agree on the SAME
|
||||||
|
// branch structure. (A BFS over the final gate list — tree + bridges
|
||||||
|
// — can re-parent systems across branches, since the bridges are
|
||||||
|
// loops; that is NOT the tree's branch structure.) The fallback below
|
||||||
|
// is only for a network that lacks the export (defensive).
|
||||||
|
const netBranchOf = this.jumpNetwork?.branchOf instanceof Map ? this.jumpNetwork.branchOf : null;
|
||||||
|
const netDepth = this.jumpNetwork?.depth instanceof Map ? this.jumpNetwork.depth : null;
|
||||||
|
let branchOf, depth;
|
||||||
|
if (netBranchOf && netDepth) {
|
||||||
|
branchOf = netBranchOf;
|
||||||
|
depth = netDepth;
|
||||||
|
} else {
|
||||||
|
const gates = this.jumpNetwork?.gates ?? new Map();
|
||||||
|
depth = new Map([[homeId, 0]]);
|
||||||
|
branchOf = new Map(); // id → its branch-root id (a home neighbor)
|
||||||
|
const q = [homeId];
|
||||||
|
while (q.length) {
|
||||||
|
const u = q.shift();
|
||||||
|
const du = depth.get(u);
|
||||||
|
for (const v of gates.get(u) ?? []) {
|
||||||
|
if (depth.has(v)) continue;
|
||||||
|
depth.set(v, du + 1);
|
||||||
|
branchOf.set(v, du === 0 ? v : branchOf.get(u));
|
||||||
|
q.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const branchRoots = (this.jumpNetwork?.branches ?? [...new Set(branchOf.values())]).slice().sort();
|
||||||
|
if (branchRoots.length === 0) {
|
||||||
|
// One-system galaxy (nothing connected to home) — no branches,
|
||||||
|
// no capitals, no territories. The galaxy still generates.
|
||||||
|
this.factions = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-branch node lists + the branch's max depth (territory bounds).
|
||||||
|
const nodesOf = new Map(branchRoots.map((b) => [b, []]));
|
||||||
|
for (const [id, b] of branchOf) nodesOf.get(b).push(id);
|
||||||
|
const maxDepthOf = new Map();
|
||||||
|
for (const b of branchRoots) {
|
||||||
|
let m = 1;
|
||||||
|
for (const id of nodesOf.get(b)) m = Math.max(m, depth.get(id) ?? 1);
|
||||||
|
maxDepthOf.set(b, m);
|
||||||
|
}
|
||||||
|
// Territory = the branch's middle systems (depth 2 … maxDepth−1):
|
||||||
|
// the depth-1 ring at home and the branch tips stay neutral.
|
||||||
|
const inTerritory = (id, b) => {
|
||||||
|
const d = depth.get(id) ?? 0;
|
||||||
|
return d >= 2 && d <= (maxDepthOf.get(b) ?? 1) - 1;
|
||||||
|
};
|
||||||
|
const inBand = (id) => {
|
||||||
|
const rec = this.byId.get(id);
|
||||||
|
return !!rec && rec.d >= dMin && rec.d <= dMax;
|
||||||
|
};
|
||||||
|
const isBarren = (id) => barren instanceof Set && barren.has(id);
|
||||||
|
|
||||||
|
// NEUTRAL CAPITAL — one of home's gate neighbors (seeded pick).
|
||||||
|
const neutralId = Rng.derive(this.seed, 'factions', 'neutral').pick(branchRoots);
|
||||||
|
|
||||||
|
// Branch↔faction: a SEEDED SHUFFLE of the three factions across the
|
||||||
|
// branches (fewer than three branches ⇒ factions share one — the
|
||||||
|
// "nice to have" relaxation; their capitals still differ).
|
||||||
|
const assign = Rng.derive(this.seed, 'factions', 'assign').shuffle(FACTIONS);
|
||||||
|
const branchFaction = new Map();
|
||||||
|
branchRoots.forEach((b, i) => branchFaction.set(b, assign[i % assign.length]));
|
||||||
|
|
||||||
|
// FACTION CAPITALS — one per faction, inside its branch (seeded).
|
||||||
|
const taken = new Set([neutralId]);
|
||||||
|
const capitals = {};
|
||||||
|
for (const f of FACTIONS) {
|
||||||
|
const b = branchRoots.find((x) => branchFaction.get(x) === f);
|
||||||
|
if (!b) continue;
|
||||||
|
const nodes = nodesOf.get(b) ?? [];
|
||||||
|
const free = (ids) => ids.filter((id) => !taken.has(id) && !isBarren(id));
|
||||||
|
// Tiered candidacy: middle-band territory → any territory → any
|
||||||
|
// free branch system (tiny-branch fallback — a working capital
|
||||||
|
// beats no capital).
|
||||||
|
let cands = free(nodes).filter((id) => inTerritory(id, b) && inBand(id));
|
||||||
|
if (cands.length === 0) cands = free(nodes).filter((id) => inTerritory(id, b));
|
||||||
|
if (cands.length === 0) cands = free(nodes);
|
||||||
|
if (cands.length === 0) cands = branchRoots.filter((id) => id !== neutralId);
|
||||||
|
const capitalId = Rng.derive(this.seed, 'factions', 'capital', f).pick(cands);
|
||||||
|
if (!capitalId) continue;
|
||||||
|
const rec = this.byId.get(capitalId);
|
||||||
|
if (rec) {
|
||||||
|
rec.capital = f;
|
||||||
|
rec.faction = f; // its seat — even on a fallback outside the territory
|
||||||
|
}
|
||||||
|
taken.add(capitalId);
|
||||||
|
capitals[f] = capitalId;
|
||||||
|
}
|
||||||
|
// The neutral capital's stamp (its system stays faction-neutral).
|
||||||
|
{
|
||||||
|
const rec = this.byId.get(neutralId);
|
||||||
|
if (rec) {
|
||||||
|
rec.capital = 'neutral';
|
||||||
|
rec.faction = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TERRITORIES — the branch's middle systems take its faction;
|
||||||
|
// the neutral hub is explicitly exempt, everything else untouched
|
||||||
|
// (neutral wild space).
|
||||||
|
for (const b of branchRoots) {
|
||||||
|
const f = branchFaction.get(b);
|
||||||
|
for (const id of nodesOf.get(b) ?? []) {
|
||||||
|
if (id === neutralId) continue;
|
||||||
|
if (inTerritory(id, b)) {
|
||||||
|
const rec = this.byId.get(id);
|
||||||
|
if (rec && !rec.capital) rec.faction = f; // a capital keeps its own
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pass's output (UI/tests read this; the records carry the rest).
|
||||||
|
this.factions = {
|
||||||
|
neutralCapital: neutralId,
|
||||||
|
capitals,
|
||||||
|
branches: branchRoots,
|
||||||
|
branchFaction: Object.fromEntries(branchFaction),
|
||||||
|
};
|
||||||
|
const missing = FACTIONS.filter((f) => !capitals[f]);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.warn(`[orbit] factions: no capital placed for ${missing.join(', ')} (degenerate branch layout)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The home→far zones (data/galaxy.json → distribution.zones),
|
* The home→far zones (data/galaxy.json → distribution.zones),
|
||||||
* validated and sorted by `d`. A single fallback zone when the config
|
* validated and sorted by `d`. A single fallback zone when the config
|
||||||
|
|
@ -541,6 +726,30 @@ export class Galaxy {
|
||||||
return this.contentCache.size;
|
return this.contentCache.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The NEUTRAL CAPITAL as a destination target (data/factions.json —
|
||||||
|
* the faction pass's designation): the system id + the capital world's
|
||||||
|
* object id. The OBJECT vocabulary is the map's discoveryId (js/scenes/
|
||||||
|
* GameScene.js: planets carry `discoveryId = rec.name`) — the same id
|
||||||
|
* MapWindow's SET DESTINATION passes and the arrival/compass/plate
|
||||||
|
* lookups read, so the capital world is the final stop the same way a
|
||||||
|
* clicked planet would be. The capital world is the system's OUTERMOST
|
||||||
|
* planet (js/galaxy/SystemGenerator.js converts it: `p.capital`). The
|
||||||
|
* 'Leaving the Nest' quest's last brief (data/comms.json:
|
||||||
|
* trestle-brief-3 — Trestle "sends the coordinates") hands this to the
|
||||||
|
* scene's setDestination on acknowledgment (js/scenes/GameScene.js →
|
||||||
|
* _commEnded).
|
||||||
|
* @returns {{systemId: string, objectId: (string|null)}|null} null when
|
||||||
|
* the faction pass never designated one (degenerate one-system galaxy)
|
||||||
|
*/
|
||||||
|
neutralCapitalTarget() {
|
||||||
|
const sysId = this.factions?.neutralCapital;
|
||||||
|
if (!sysId || !this.byId.has(sysId)) return null;
|
||||||
|
const content = this.ensureContent(sysId);
|
||||||
|
const cap = (content.planets ?? []).find((p) => p.capital) ?? null;
|
||||||
|
return { systemId: sysId, objectId: cap ? cap.name : null };
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
/** Debug/console summary. */
|
/** Debug/console summary. */
|
||||||
|
|
@ -557,6 +766,17 @@ export class Galaxy {
|
||||||
byZone,
|
byZone,
|
||||||
generated: this.generatedCount,
|
generated: this.generatedCount,
|
||||||
currentSystemId: this.currentSystemId,
|
currentSystemId: this.currentSystemId,
|
||||||
|
// FACTIONS (the faction pass — data/factions.json): branch map +
|
||||||
|
// the four capital system ids (a null capital = placement failed
|
||||||
|
// for that faction, logged at roster build).
|
||||||
|
factions: this.factions ? {
|
||||||
|
neutralCapital: this.factions.neutralCapital,
|
||||||
|
capitals: this.factions.capitals,
|
||||||
|
branches: this.factions.branches,
|
||||||
|
branchFaction: this.factions.branchFaction,
|
||||||
|
} : null,
|
||||||
|
jumpNetworkEdges: this.jumpNetwork ? [...this.jumpNetwork.gates.values()].reduce((n, t) => n + t.length, 0) : null,
|
||||||
|
jumpNetworkBridges: this.jumpNetwork?.bridges ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,17 @@
|
||||||
* joined with '<' (save-stable: the same lane
|
* joined with '<' (save-stable: the same lane
|
||||||
* from either end)
|
* from either end)
|
||||||
* buildGalaxySnapshot(o) the live GALAXY plate data — the systems
|
* buildGalaxySnapshot(o) the live GALAXY plate data — the systems
|
||||||
* (position, type, visited), the dedup'd jump
|
* (position, type, visited, faction, capital),
|
||||||
* lanes (used / frontier / live), and the
|
* the dedup'd jump lanes (used / frontier /
|
||||||
* readout stats. Per-system `faction: null`
|
* live / bridge), and the readout stats.
|
||||||
* is the FACTIONS seam (planned — see
|
* Per-system `faction` / `capital` are the
|
||||||
* docs/PROJECT_NOTES.md "Factions"): the
|
* FACTIONS layer (data/factions.json — the
|
||||||
* plate already reserves a faction color
|
* roster's faction pass, js/galaxy/Galaxy.js):
|
||||||
* layer + a territory-region pass for it.
|
* the plate's faction ring layer + capital
|
||||||
|
* rings read them (js/ui/GalaxyView.js);
|
||||||
|
* each edge's `bridge` marks the cross-branch
|
||||||
|
* middle-belt lanes (data/gates.json →
|
||||||
|
* bridges) for the plate's lane styling.
|
||||||
* convexHull(points) monotone-chain hull (interior + collinear
|
* convexHull(points) monotone-chain hull (interior + collinear
|
||||||
* points dropped)
|
* points dropped)
|
||||||
* paddedHullPolygon(p, pad) the hull inflated by `pad` world-px (the
|
* paddedHullPolygon(p, pad) the hull inflated by `pad` world-px (the
|
||||||
|
|
@ -83,17 +87,30 @@ export function buildGalaxySnapshot({ galaxy, visited = null, used = null, live
|
||||||
// it (GalaxyView) so the "where am I going" target is unmistakable.
|
// it (GalaxyView) so the "where am I going" target is unmistakable.
|
||||||
isDestination: s.id === destinationId,
|
isDestination: s.id === destinationId,
|
||||||
gates: network?.gates?.get?.(s.id)?.length ?? 0,
|
gates: network?.gates?.get?.(s.id)?.length ?? 0,
|
||||||
// FACTIONS (planned — not yet implemented): the system's faction
|
// FACTIONS (data/factions.json — the roster's faction pass,
|
||||||
// id + the player's relation tier (Neutral / Friendly / Aligned /
|
// js/galaxy/Galaxy.js): the system's faction id (null = neutral wild
|
||||||
// Hostile) will land here. The galaxy plate reserves a faction
|
// space) + its CAPITAL designation (neutral / apex / mindweave /
|
||||||
// color layer on the stars + a territory-region fill (the same
|
// zenith — null = not a capital). The plate's faction ring layer
|
||||||
// hull pass as the charted region) for when it ships.
|
// (faction color from data/factions.json → factions) + the capital
|
||||||
faction: null,
|
// rings (js/ui/GalaxyView.js) read them; the relation tier
|
||||||
|
// (Neutral / Friendly / Aligned / Hostile) is the next layer —
|
||||||
|
// the reputation seam (js/reputation/Reputation.js →
|
||||||
|
// factionStanding).
|
||||||
|
faction: s.faction ?? null,
|
||||||
|
capital: s.capital ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const ids = new Set(systems.map((s) => s.id));
|
const ids = new Set(systems.map((s) => s.id));
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
// BRIDGES (data/gates.json → bridges — the cross-branch middle-belt
|
||||||
|
// lanes): the set of bridge lane keys, for the edges' `bridge` mark
|
||||||
|
// below (the plate's lane styling distinguishes the trunk maze from
|
||||||
|
// the inter-branch links).
|
||||||
|
const bridgeKeys = new Set();
|
||||||
|
for (const br of network?.bridges ?? []) {
|
||||||
|
if (br && br[0] && br[1]) bridgeKeys.add(edgeKey(br[0], br[1]));
|
||||||
|
}
|
||||||
const edges = [];
|
const edges = [];
|
||||||
for (const [a, dests] of network?.gates ?? []) {
|
for (const [a, dests] of network?.gates ?? []) {
|
||||||
if (!ids.has(a) || !Array.isArray(dests)) continue;
|
if (!ids.has(a) || !Array.isArray(dests)) continue;
|
||||||
|
|
@ -123,6 +140,10 @@ export function buildGalaxySnapshot({ galaxy, visited = null, used = null, live
|
||||||
live:
|
live:
|
||||||
currentSystemId != null &&
|
currentSystemId != null &&
|
||||||
(liveSet.has(`${currentSystemId}>${lo}`) || liveSet.has(`${currentSystemId}>${hi}`)),
|
(liveSet.has(`${currentSystemId}>${lo}`) || liveSet.has(`${currentSystemId}>${hi}`)),
|
||||||
|
// BRIDGE — a cross-branch middle-belt lane (data/gates.json →
|
||||||
|
// bridges): the "don't go back to the trunk" links between
|
||||||
|
// faction territories (data/factions.json).
|
||||||
|
bridge: bridgeKeys.has(key),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,30 @@
|
||||||
* galaxy reads as a maze of dead ends and long hauls — and
|
* galaxy reads as a maze of dead ends and long hauls — and
|
||||||
* every barren system (the `barren` set, gate-only dead ends)
|
* every barren system (the `barren` set, gate-only dead ends)
|
||||||
* is a LEAF: exactly one gate, in and out the same way.
|
* is a LEAF: exactly one gate, in and out the same way.
|
||||||
|
* BRIDGES — the deliberate exception to the maze (data/gates.json →
|
||||||
|
* bridges): cross-branch links in the MIDDLE belt of the
|
||||||
|
* galaxy. A branch is a top-level subtree of the tree (a
|
||||||
|
* subtree rooted at one of the home system's gate
|
||||||
|
* neighbors); without bridges, branch-to-branch travel means
|
||||||
|
* ALL the way back to the trunk (home). For each pair of
|
||||||
|
* branches, `perPair` bidirectional gate-pairs join two
|
||||||
|
* systems of the middle belt (record.d in dMin..dMax), so
|
||||||
|
* the branches interconnect around the middle of the galaxy
|
||||||
|
* (a triangle, for three branches) and a branch-to-branch
|
||||||
|
* trip is ONE EXTRA HOP, not a trek to the trunk — the
|
||||||
|
* faction design's "reach each other without routing through
|
||||||
|
* home" (data/factions.json). Hard rules: both ends in each
|
||||||
|
* other's neighbor pool (the LOCAL rule — a bridge is still
|
||||||
|
* a gate to a NEAR star, like any other), both ends
|
||||||
|
* non-barren (a dead end keeps its single gate), both ends
|
||||||
|
* within the maxGates budget (the cap never breaks). Soft
|
||||||
|
* rule: the middle-belt preference — an out-of-band pool
|
||||||
|
* pair is the fallback; a pair with NO pool candidate
|
||||||
|
* degrades (a console note — the maze still connects them).
|
||||||
|
* Each bridge runs BOTH ways (a return gate at each end —
|
||||||
|
* the "every jump has a return gate" rule), so planRoute's
|
||||||
|
* Dijkstra simply prefers the shorter of bridge vs trunk —
|
||||||
|
* the maze gains loops by design, nothing else changes.
|
||||||
* PURE — no Math.random: every tie is broken by index. Same seed
|
* PURE — no Math.random: every tie is broken by index. Same seed
|
||||||
* ⇒ same network (deterministic across machines and runs).
|
* ⇒ same network (deterministic across machines and runs).
|
||||||
*
|
*
|
||||||
|
|
@ -46,6 +70,12 @@
|
||||||
* budget (nodes under maxGates) buys extra one-way local edges — the
|
* budget (nodes under maxGates) buys extra one-way local edges — the
|
||||||
* web, not just the roads. Adding edges never removes reachability,
|
* web, not just the roads. Adding edges never removes reachability,
|
||||||
* so the strong connectivity survives.
|
* so the strong connectivity survives.
|
||||||
|
* 5. Optional BRIDGES (ON in the current config): the cross-branch
|
||||||
|
* middle-belt links described above — the "don't go back to the
|
||||||
|
* trunk" routes. They add loops to the tree; everything downstream
|
||||||
|
* (planRoute's Dijkstra, the strong-connectivity proof, the gate
|
||||||
|
* counts) already copes, because a bridge is just two more local
|
||||||
|
* gates.
|
||||||
*
|
*
|
||||||
* The repair pass (below) is defensive: it fires only if the neighbor
|
* The repair pass (below) is defensive: it fires only if the neighbor
|
||||||
* graph is disconnected (effectively impossible at this scale), and it
|
* graph is disconnected (effectively impossible at this scale), and it
|
||||||
|
|
@ -69,13 +99,33 @@
|
||||||
* (objectCount → 0, from the composition roll): a barren node never
|
* (objectCount → 0, from the composition roll): a barren node never
|
||||||
* adopts children, so it ends up a tree LEAF — exactly one gate, in and
|
* adopts children, so it ends up a tree LEAF — exactly one gate, in and
|
||||||
* out the same way (the maze's dead ends). The repair pass prefers to
|
* out the same way (the maze's dead ends). The repair pass prefers to
|
||||||
* attach leftovers to non-barren nodes.
|
* attach leftovers to non-barren nodes, and a barren system is never a
|
||||||
|
* bridge endpoint.
|
||||||
* @param {string|null} [o.rootId] — grow the tree from this system (the home
|
* @param {string|null} [o.rootId] — grow the tree from this system (the home
|
||||||
* system) — it then keeps the lowest possible degree.
|
* system) — it then keeps the lowest possible degree. Branches (for the
|
||||||
* @returns {{ gates: Map<string, string[]>, repaired: number }}
|
* bridge pass) are the subtrees rooted at this system's gate neighbors.
|
||||||
|
* @param {object|null} [o.bridges] — the bridge config (data/gates.json →
|
||||||
|
* bridges): { enabled, perPair, dMin, dMax }. When enabled, perPair
|
||||||
|
* bidirectional gate-pairs link each pair of branches through the
|
||||||
|
* middle belt (record.d in dMin..dMax), under the LOCAL + BUDGET rules.
|
||||||
|
* @returns {{ gates: Map<string, string[]>, repaired: number, bridges: Array<[string, string]>, branches: string[], branchOf: Map<string, string>, depth: Map<string, number> }}
|
||||||
* gates: system id → ordered list of gate destinations (parent edge
|
* gates: system id → ordered list of gate destinations (parent edge
|
||||||
* first — "the road home" — then children/shortcuts).
|
* first — "the road home" — then children/shortcuts, then the bridge
|
||||||
|
* gates).
|
||||||
* repaired: systems that needed the defensive attach (0 on real data).
|
* repaired: systems that needed the defensive attach (0 on real data).
|
||||||
|
* bridges: the bridge pairs actually placed (each pair = one gate on
|
||||||
|
* each end; both ends' gate lists carry the other).
|
||||||
|
* branches: the tree's top-level branch roots (the home system's gate
|
||||||
|
* neighbors), sorted — the faction pass's branch vocabulary
|
||||||
|
* (data/factions.json).
|
||||||
|
* branchOf: system id → its branch root id (the depth-1 ancestor on the
|
||||||
|
* SPANNING TREE — the exact tree the bridge pass used, NOT a BFS over
|
||||||
|
* the final gate list: the bridges add loops, and a BFS over the final
|
||||||
|
* graph can re-parent systems across branches, which is NOT the
|
||||||
|
* branch structure the faction pass assigns).
|
||||||
|
* depth: system id → tree depth from home (0 = home, 1 = the ring
|
||||||
|
* outside home) — the territory rule's bounds (depth 2 … maxDepth−1)
|
||||||
|
* are measured on THIS tree.
|
||||||
*/
|
*/
|
||||||
export function buildJumpNetwork({
|
export function buildJumpNetwork({
|
||||||
records,
|
records,
|
||||||
|
|
@ -85,6 +135,7 @@ export function buildJumpNetwork({
|
||||||
shortcuts = true,
|
shortcuts = true,
|
||||||
barren = null,
|
barren = null,
|
||||||
rootId = null,
|
rootId = null,
|
||||||
|
bridges = null,
|
||||||
}) {
|
}) {
|
||||||
const n = records.length;
|
const n = records.length;
|
||||||
const isBarren = (i) => barren instanceof Set && barren.has(records[i].id);
|
const isBarren = (i) => barren instanceof Set && barren.has(records[i].id);
|
||||||
|
|
@ -119,7 +170,7 @@ export function buildJumpNetwork({
|
||||||
if (n <= 1) {
|
if (n <= 1) {
|
||||||
const gates = new Map();
|
const gates = new Map();
|
||||||
for (const r of records) gates.set(r.id, []);
|
for (const r of records) gates.set(r.id, []);
|
||||||
return { gates, repaired: 0 };
|
return { gates, repaired: 0, bridges: [], branches: [], branchOf: new Map(), depth: new Map() };
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = rootId ? iOf(rootId) : 0;
|
const root = rootId ? iOf(rootId) : 0;
|
||||||
|
|
@ -276,14 +327,123 @@ export function buildJumpNetwork({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Branches + tree depth (the faction pass's vocabulary) ----------
|
||||||
|
// A BRANCH is a top-level subtree of the spanning tree: the subtree
|
||||||
|
// rooted at one of the home system's gate neighbors (depth 1). Walk the
|
||||||
|
// parent chain up to the root's child: the tree is acyclic, so this is
|
||||||
|
// exact. Exposed (branches / branchOf / depth below) because the
|
||||||
|
// faction pass (js/galaxy/Galaxy.js → _assignFactions) + the bridge
|
||||||
|
// pass must agree on the SAME branch structure — a BFS over the FINAL
|
||||||
|
// gate list (tree + bridges) can re-parent systems across branches
|
||||||
|
// (the bridges are loops), which is not the tree's branch structure.
|
||||||
|
const branchOfArr = new Int32Array(n).fill(-1);
|
||||||
|
const depthArr = new Int32Array(n).fill(-1);
|
||||||
|
depthArr[root] = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
if (i === root || parent[i] < 0) continue;
|
||||||
|
let b = i;
|
||||||
|
while (parent[b] >= 0 && parent[b] !== root) b = parent[b];
|
||||||
|
if (parent[b] === root) branchOfArr[i] = b;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
if (i === root) continue;
|
||||||
|
let d = 0;
|
||||||
|
let cur = i;
|
||||||
|
while (parent[cur] >= 0) { d++; cur = parent[cur]; }
|
||||||
|
if (cur === root) depthArr[i] = d;
|
||||||
|
}
|
||||||
|
const branchList = [];
|
||||||
|
{
|
||||||
|
const seen = new Set();
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const b = branchOfArr[i];
|
||||||
|
if (b !== -1 && !seen.has(b)) {
|
||||||
|
seen.add(b);
|
||||||
|
branchList.push(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
branchList.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
// --- Bridges: the cross-branch middle-belt links (data/gates.json →
|
||||||
|
// bridges — see the BRIDGES constraint above) --------------------------
|
||||||
|
// Two hard constraints, both preserved: LOCAL (both ends sit in each
|
||||||
|
// other's neighbor pool — `nbr`, the symmetric union: a bridge is still
|
||||||
|
// a gate to a NEAR star, like any other) and BUDGET (both ends keep
|
||||||
|
// degree ≤ maxGates — the cap never breaks; a barren dead end is never
|
||||||
|
// an endpoint — it keeps its single gate). Soft: both ends in the
|
||||||
|
// middle belt (record.d in dMin..dMax); an out-of-band pool pair is the
|
||||||
|
// fallback, and a pair with NO pool candidate degrades (a console note).
|
||||||
|
// The bridge list drives the gate assembly below; each bridge runs BOTH
|
||||||
|
// ways (the return-gate rule) — planRoute's Dijkstra picks the shorter
|
||||||
|
// of bridge vs trunk automatically, so nothing else changes.
|
||||||
|
const bridgesPlaced = [];
|
||||||
|
const bCfg = bridges && bridges.enabled !== false ? bridges : null;
|
||||||
|
const perPair = bCfg ? Math.max(0, Math.floor(Number(bCfg.perPair) || 0)) : 0;
|
||||||
|
if (perPair > 0 && n > 1 && root >= 0) {
|
||||||
|
const dOf = (i) => (Number.isFinite(Number(records[i].d)) ? Number(records[i].d) : 0.5);
|
||||||
|
const dMin = Number(bCfg.dMin ?? 0.37);
|
||||||
|
const dMax = Number(bCfg.dMax ?? 0.63);
|
||||||
|
const inBand = (i) => dOf(i) >= dMin && dOf(i) <= dMax;
|
||||||
|
for (let bi = 0; bi < branchList.length; bi++) {
|
||||||
|
for (let bj = bi + 1; bj < branchList.length; bj++) {
|
||||||
|
const A = branchList[bi], B = branchList[bj];
|
||||||
|
// Candidate pool: every (u in branch A, v in branch B) whose ends
|
||||||
|
// are LOCAL (v ∈ nbr[u]) — non-barren, never the home root. Sort:
|
||||||
|
// middle-belt pairs first (the soft preference), then nearest,
|
||||||
|
// then indices (determinism).
|
||||||
|
const cands = [];
|
||||||
|
for (let u = 0; u < n; u++) {
|
||||||
|
if (branchOfArr[u] !== A || isBarren(u) || u === root) continue;
|
||||||
|
for (const v of nbr[u]) {
|
||||||
|
if (branchOfArr[v] !== B || isBarren(v) || v === root) continue;
|
||||||
|
cands.push({ u, v, band: inBand(u) && inBand(v), d: dist2(u, v) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cands.sort((p, q) =>
|
||||||
|
(p.band === q.band ? 0 : p.band ? -1 : 1) ||
|
||||||
|
p.d - q.d ||
|
||||||
|
p.u - q.u ||
|
||||||
|
p.v - q.v);
|
||||||
|
// Prefer two DISTINCT endpoints (two real alternative routes);
|
||||||
|
// only reuse an endpoint on the second pass (budget left over —
|
||||||
|
// the "two bridges per pair" redundancy degrades gracefully).
|
||||||
|
const usedEnds = new Set();
|
||||||
|
let added = 0;
|
||||||
|
for (let pass = 0; pass < 2 && added < perPair; pass++) {
|
||||||
|
for (const c of cands) {
|
||||||
|
if (added >= perPair || c.added) continue;
|
||||||
|
if (pass === 0 && (usedEnds.has(c.u) || usedEnds.has(c.v))) continue;
|
||||||
|
if (deg[c.u] >= maxGates || deg[c.v] >= maxGates) continue; // BUDGET
|
||||||
|
c.added = true;
|
||||||
|
deg[c.u]++;
|
||||||
|
deg[c.v]++;
|
||||||
|
usedEnds.add(c.u);
|
||||||
|
usedEnds.add(c.v);
|
||||||
|
bridgesPlaced.push([records[c.u].id, records[c.v].id]);
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added < perPair && cands.length === 0) {
|
||||||
|
// No LOCAL candidate at all for this pair — the pair degrades
|
||||||
|
// to the maze (the tree still connects the two branches via the
|
||||||
|
// trunk). A budget shortfall (cands exist, none fit) is the
|
||||||
|
// normal, designed degradation and stays quiet.
|
||||||
|
console.warn(`[orbit] jump network: no bridge pool between branches ${records[A].id} and ${records[B].id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Assemble ----------------------------------------------------------
|
// --- Assemble ----------------------------------------------------------
|
||||||
// Each node's gates = the tree edges touching it — its parent first ("the
|
// Each node's gates = the tree edges touching it — its parent first ("the
|
||||||
// road home"), then its tree children in index order — plus its
|
// road home"), then its tree children in index order — plus its
|
||||||
// shortcuts. A bidirected spanning tree is strongly connected by
|
// shortcuts, then its bridge gates. A bidirected spanning tree is strongly
|
||||||
// construction (the unique tree path between any two systems is walkable
|
// connected by construction (the unique tree path between any two systems
|
||||||
// in both directions), so every system both reaches and is reachable;
|
// is walkable in both directions), so every system both reaches and is
|
||||||
// every node's gate count is its tree degree (≥ 1 for n > 1, ≤ maxGates)
|
// reachable; the bridges add loops on top (Dijkstra then prefers the
|
||||||
// plus any shortcuts it bought.
|
// shorter of the two routes); every node's gate count is its tree degree
|
||||||
|
// (≥ 1 for n > 1, ≤ maxGates) plus any shortcuts/bridges it bought.
|
||||||
const children = Array.from({ length: n }, () => []);
|
const children = Array.from({ length: n }, () => []);
|
||||||
for (let i = 0; i < n; i++) if (parent[i] >= 0) children[parent[i]].push(i);
|
for (let i = 0; i < n; i++) if (parent[i] >= 0) children[parent[i]].push(i);
|
||||||
const gates = new Map();
|
const gates = new Map();
|
||||||
|
|
@ -292,13 +452,30 @@ export function buildJumpNetwork({
|
||||||
if (parent[i] >= 0) targets.push(records[parent[i]].id);
|
if (parent[i] >= 0) targets.push(records[parent[i]].id);
|
||||||
for (const c of children[i].sort((a, b) => a - b)) targets.push(records[c].id);
|
for (const c of children[i].sort((a, b) => a - b)) targets.push(records[c].id);
|
||||||
for (const v of shortcutsOf[i]) targets.push(records[v].id);
|
for (const v of shortcutsOf[i]) targets.push(records[v].id);
|
||||||
|
for (const br of bridgesPlaced) {
|
||||||
|
if (br[0] === records[i].id) targets.push(br[1]);
|
||||||
|
else if (br[1] === records[i].id) targets.push(br[0]);
|
||||||
|
}
|
||||||
if (targets.length < minGates && n > 1) {
|
if (targets.length < minGates && n > 1) {
|
||||||
// Can't happen (tree degree ≥ 1), but never emit a closed system.
|
// Can't happen (tree degree ≥ 1), but never emit a closed system.
|
||||||
throw new Error(`Jump network left system ${records[i].id} with ${targets.length} gate(s) < minGates ${minGates}`);
|
throw new Error(`Jump network left system ${records[i].id} with ${targets.length} gate(s) < minGates ${minGates}`);
|
||||||
}
|
}
|
||||||
gates.set(records[i].id, targets);
|
gates.set(records[i].id, targets);
|
||||||
}
|
}
|
||||||
return { gates, repaired };
|
const branchOfMap = new Map();
|
||||||
|
const depthMap = new Map();
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
if (branchOfArr[i] !== -1) branchOfMap.set(records[i].id, records[branchOfArr[i]].id);
|
||||||
|
if (depthArr[i] >= 0) depthMap.set(records[i].id, depthArr[i]);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
gates,
|
||||||
|
repaired,
|
||||||
|
bridges: bridgesPlaced,
|
||||||
|
branches: branchList.map((i) => records[i].id),
|
||||||
|
branchOf: branchOfMap,
|
||||||
|
depth: depthMap,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = (a, b) => (a < b ? a : b) + '\u0000' + (a < b ? b : a);
|
const key = (a, b) => (a < b ? a : b) + '\u0000' + (a < b ? b : a);
|
||||||
|
|
|
||||||
|
|
@ -40,13 +40,16 @@ const DEG = Math.PI / 180;
|
||||||
* BARREN systems (objectCount → 0) are the deliberate exception: jump
|
* BARREN systems (objectCount → 0) are the deliberate exception: jump
|
||||||
* gates — and, in most, an asteroid cluster drifting inside the gate's
|
* gates — and, in most, an asteroid cluster drifting inside the gate's
|
||||||
* tether to mine at the stop — and nothing else. They are the DEAD-END
|
* tether to mine at the stop — and nothing else. They are the DEAD-END
|
||||||
* LEAVES of the jump network (a pure spanning tree, no loops — the maze's
|
* LEAVES of the jump network (the maze's dead ends: one gate, in and out
|
||||||
* dead ends: one gate, in and out the same way), where the player's room
|
* the same way), where the player's room to move is the activated gate's
|
||||||
* to move is the activated gate's own level-1 tether (data/gates.json →
|
* own level-1 tether (data/gates.json → ACTIVITY: every gate carries
|
||||||
* ACTIVITY: every gate carries `active`, default false; the activation
|
* `active`, default false; the activation mechanic is future work). The
|
||||||
* mechanic is future work). Nothing here is hostile yet: `owner` on every
|
* galaxy is settled by FACTION (data/factions.json — the roster's
|
||||||
* settlement is a reserved seam for the factions and pirates we'll
|
* faction pass, js/galaxy/Galaxy.js → _assignFactions): each branch's
|
||||||
* introduce later.
|
* territory systems carry `record.faction`, and the `owner` seam on
|
||||||
|
* every settlement is populated from it (a capital world's settlement is
|
||||||
|
* owned by its faction — the neutral capital's stays owner-less); null
|
||||||
|
* = neutral wild space (pirates are still future work).
|
||||||
*
|
*
|
||||||
* The STARTING system is special: the player's home world sits at the
|
* The STARTING system is special: the player's home world sits at the
|
||||||
* origin (not a generated planet, fixed key 'home'), and the system always
|
* origin (not a generated planet, fixed key 'home'), and the system always
|
||||||
|
|
@ -92,9 +95,11 @@ const DEG = Math.PI / 180;
|
||||||
* on the ray toward the destination, `barrenDistance` from the center.
|
* on the ray toward the destination, `barrenDistance` from the center.
|
||||||
* Every gate record carries `active` (default false — the activation
|
* Every gate record carries `active` (default false — the activation
|
||||||
* mechanic is future work; an activated gate anchors a level-1 tether).
|
* mechanic is future work; an activated gate anchors a level-1 tether).
|
||||||
* The galaxy-wide network is a pure spanning tree (no loops — the maze):
|
* The galaxy-wide network is the degree-limited spanning tree plus the
|
||||||
* from any system the player can reach any other, and every jump has a
|
* cross-branch BRIDGES (data/gates.json → bridges — the middle-belt
|
||||||
* return gate.
|
* links that let one branch reach another without the trunk): from any
|
||||||
|
* system the player can reach any other, and every jump has a return
|
||||||
|
* gate.
|
||||||
*
|
*
|
||||||
* FRAME DIVERSITY (js/galaxy/PlanetFrames.js): each planet's spritesheet
|
* FRAME DIVERSITY (js/galaxy/PlanetFrames.js): each planet's spritesheet
|
||||||
* frame is assigned by a galaxy-wide pass — the system's (class, frame)
|
* frame is assigned by a galaxy-wide pass — the system's (class, frame)
|
||||||
|
|
@ -198,7 +203,11 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
||||||
ordinal: i,
|
ordinal: i,
|
||||||
class: pclass,
|
class: pclass,
|
||||||
moons,
|
moons,
|
||||||
habitable: pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1),
|
// ARID worlds are ALWAYS habitable — a dry terrestrial world is
|
||||||
|
// still a world you can live on (they settle as colonies, like
|
||||||
|
// rocky habitables — data/settlements.json → settledKindByClass);
|
||||||
|
// rocky worlds roll the per-type chance.
|
||||||
|
habitable: pclass === 'arid' || (pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// FRAME DIVERSITY (js/galaxy/PlanetFrames.js — the galaxy-wide
|
// FRAME DIVERSITY (js/galaxy/PlanetFrames.js — the galaxy-wide
|
||||||
|
|
@ -210,6 +219,32 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
||||||
for (let i = 0; i < planets.length; i++) planets[i].frame = frames[i];
|
for (let i = 0; i < planets.length; i++) planets[i].frame = frames[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CAPITALS (data/factions.json — the roster's faction pass, js/galaxy/
|
||||||
|
// Galaxy.js → _assignFactions): when this system is a designated
|
||||||
|
// capital (record.capital), its OUTERMOST world becomes the capital
|
||||||
|
// world — the faction's seat takes the outer slot of the world list:
|
||||||
|
// class `capital*` (planets.png frames 18..21 per data/planets.json →
|
||||||
|
// capitals, fixed per faction — the art carries the faction's color,
|
||||||
|
// so classTint stays white), always habitable (a capital is a city),
|
||||||
|
// and it hosts the 'capital' settlement below (owner: its faction —
|
||||||
|
// the reputation seam). Replacing the outer slot (rather than adding a
|
||||||
|
// world) keeps the system's object budget (objectCount) + the arc
|
||||||
|
// layout + the settlement count exactly as rolled — the capital is
|
||||||
|
// the world the faction built where the outermost world used to be.
|
||||||
|
// It is not a composition planet: the frame pass never saw it (its
|
||||||
|
// frame is fixed by the faction, not spread).
|
||||||
|
if (record.capital && !isHome && planets.length > 0) {
|
||||||
|
const cap = String(record.capital);
|
||||||
|
const capitalClass = `capital${cap.charAt(0).toUpperCase()}${cap.slice(1)}`;
|
||||||
|
const outer = planets[planets.length - 1];
|
||||||
|
outer.class = capitalClass;
|
||||||
|
outer.capital = true;
|
||||||
|
outer.faction = cap === 'neutral' ? null : cap; // the owner seam (capital world)
|
||||||
|
outer.frame = config.get(`planets.capitals.frames.${cap}`, 18);
|
||||||
|
outer.habitable = true; // a capital is a city — population by definition
|
||||||
|
outer.moons = 0;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Settlements (the lived-in layer) ---------------------------------
|
// --- Settlements (the lived-in layer) ---------------------------------
|
||||||
const settlements = generateSettlements({
|
const settlements = generateSettlements({
|
||||||
rng,
|
rng,
|
||||||
|
|
@ -223,6 +258,11 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
||||||
density: settlementDensity(galaxy, record),
|
density: settlementDensity(galaxy, record),
|
||||||
stations: { deepSpace: composition.deepSpace, waypoint: composition.waypoint },
|
stations: { deepSpace: composition.deepSpace, waypoint: composition.waypoint },
|
||||||
isHome,
|
isHome,
|
||||||
|
// FACTION ownership (data/factions.json — the roster's faction pass):
|
||||||
|
// the system's territory (null = neutral wild space) claims its
|
||||||
|
// settlements' `owner` (a planet's own faction wins over the
|
||||||
|
// system's — the capital world's settlement is its faction's seat).
|
||||||
|
systemFaction: record.faction ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// STATION VARIANTS (js/galaxy/StationFrames.js — the galaxy-wide
|
// STATION VARIANTS (js/galaxy/StationFrames.js — the galaxy-wide
|
||||||
|
|
@ -317,6 +357,12 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
||||||
jumps, // the system's jump gates (1–3; [] for a one-system galaxy)
|
jumps, // the system's jump gates (1–3; [] for a one-system galaxy)
|
||||||
atmosphere: { color: atmosphereColor }, // nebula gas color (rendered on nebula systems)
|
atmosphere: { color: atmosphereColor }, // nebula gas color (rendered on nebula systems)
|
||||||
fx, // per-system effect variation (flare phase / orbit / split / wanderer)
|
fx, // per-system effect variation (flare phase / orbit / split / wanderer)
|
||||||
|
// FACTIONS (data/factions.json — the roster's faction pass):
|
||||||
|
// the system's faction id (null = neutral wild space) + its capital
|
||||||
|
// designation (null = not a capital) — the dossier
|
||||||
|
// (js/galaxy/SystemReport.js → "Faction: …") + the HUD read them.
|
||||||
|
faction: record.faction ?? null,
|
||||||
|
capital: record.capital ?? null,
|
||||||
};
|
};
|
||||||
if (isHome) {
|
if (isHome) {
|
||||||
content.homeName = homeName; // the player's home world (starting system only)
|
content.homeName = homeName; // the player's home world (starting system only)
|
||||||
|
|
@ -1365,21 +1411,26 @@ export function settlementDensity(galaxy, record) {
|
||||||
* settlements in orbital order, then free-floating (deep-space station,
|
* settlements in orbital order, then free-floating (deep-space station,
|
||||||
* waypoint). Every roll goes through the system's own stream. Each
|
* waypoint). Every roll goes through the system's own stream. Each
|
||||||
* settlement gets a stable `id` (`<systemId>-s<n>`, n = its position in
|
* settlement gets a stable `id` (`<systemId>-s<n>`, n = its position in
|
||||||
* this order) — the reputation/factions key.
|
* this order) — the reputation/factions key. `owner` is the FACTION
|
||||||
|
* SEAM (data/factions.json): the system's territory (systemFaction, from
|
||||||
|
* the roster's faction pass) claims the settlement — a capital world's
|
||||||
|
* own faction wins over the system's (its seat); null = neutral wild
|
||||||
|
* space (pirates are still future work).
|
||||||
*
|
*
|
||||||
* The planet-bound layer is a RULE, not a roll (for now): the galaxy is
|
* The planet-bound layer is a RULE, not a roll (for now): the galaxy is
|
||||||
* fully settled, so EVERY planet hosts the one kind that fits its class
|
* fully settled, so EVERY planet hosts the one kind that fits its class
|
||||||
* (data/settlements.json → allPlanetsSettled + settledKindByClass — a
|
* (data/settlements.json → allPlanetsSettled + settledKindByClass — a
|
||||||
* habitable rocky world earns a colony, every other world gets its mining
|
* habitable rocky OR ARID world earns a colony, every other world gets
|
||||||
* outfit, gas giants ride cloud bases). Flip allPlanetsSettled off and
|
* its mining outfit, gas giants ride cloud bases, and the four capital
|
||||||
|
* worlds host the capital kind). Flip allPlanetsSettled off and
|
||||||
* the old per-type odds (spec.chance + needs) take over again. The
|
* the old per-type odds (spec.chance + needs) take over again. The
|
||||||
* free-space kinds still roll per type (home→far scaled).
|
* free-space kinds still roll per type (home→far scaled).
|
||||||
*/
|
*/
|
||||||
function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDeck, density, stations, isHome = false }) {
|
function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDeck, density, stations, isHome = false, systemFaction = null }) {
|
||||||
const out = [];
|
const out = [];
|
||||||
let nameIndex = 0; // next station name from the system's deck (no repeats)
|
let nameIndex = 0; // next station name from the system's deck (no repeats)
|
||||||
|
|
||||||
const make = (kind, anchor) => {
|
const make = (kind, anchor, owner = null) => {
|
||||||
const def = kindDefs[kind] ?? {};
|
const def = kindDefs[kind] ?? {};
|
||||||
out.push({
|
out.push({
|
||||||
id: `${systemId}-s${out.length + 1}`, // reputation/factions key (stable per seed)
|
id: `${systemId}-s${out.length + 1}`, // reputation/factions key (stable per seed)
|
||||||
|
|
@ -1387,7 +1438,7 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe
|
||||||
name: stationDeck[nameIndex++ % stationDeck.length],
|
name: stationDeck[nameIndex++ % stationDeck.length],
|
||||||
anchor,
|
anchor,
|
||||||
population: logPopulation(rng, def.population),
|
population: logPopulation(rng, def.population),
|
||||||
owner: null, // reserved: factions / pirates claim settlements later
|
owner, // the faction seam (null = neutral wild space / unclaimed)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1397,21 +1448,22 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe
|
||||||
const byClass = config.get('settlements.settledKindByClass', {});
|
const byClass = config.get('settlements.settledKindByClass', {});
|
||||||
for (const p of planets) {
|
for (const p of planets) {
|
||||||
const kind = settledKindFor(p, byClass, kindDefs);
|
const kind = settledKindFor(p, byClass, kindDefs);
|
||||||
if (kind) make(kind, { type: 'planet', ordinal: p.ordinal });
|
if (kind) make(kind, { type: 'planet', ordinal: p.ordinal }, p.faction ?? systemFaction ?? null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// The old probabilistic layer (per-type chances + class needs).
|
// The old probabilistic layer (per-type chances + class needs).
|
||||||
for (const p of planets) {
|
for (const p of planets) {
|
||||||
|
const owner = p.faction ?? systemFaction ?? null;
|
||||||
if (p.habitable && roll(rng, spec.colony?.chance ?? 0.3, density)) {
|
if (p.habitable && roll(rng, spec.colony?.chance ?? 0.3, density)) {
|
||||||
make('colony', { type: 'planet', ordinal: p.ordinal });
|
make('colony', { type: 'planet', ordinal: p.ordinal }, owner);
|
||||||
}
|
}
|
||||||
if (needs(p, spec.miningStation?.needs, ['rocky', 'lava', 'ice']) &&
|
if (needs(p, spec.miningStation?.needs, ['rocky', 'lava', 'ice']) &&
|
||||||
roll(rng, spec.miningStation?.chance ?? 0.2, density)) {
|
roll(rng, spec.miningStation?.chance ?? 0.2, density)) {
|
||||||
make('miningStation', { type: 'planet', ordinal: p.ordinal });
|
make('miningStation', { type: 'planet', ordinal: p.ordinal }, owner);
|
||||||
}
|
}
|
||||||
if (needs(p, spec.cloudBase?.needs, ['gas']) &&
|
if (needs(p, spec.cloudBase?.needs, ['gas']) &&
|
||||||
roll(rng, spec.cloudBase?.chance ?? 0.15, density)) {
|
roll(rng, spec.cloudBase?.chance ?? 0.15, density)) {
|
||||||
make('cloudBase', { type: 'planet', ordinal: p.ordinal });
|
make('cloudBase', { type: 'planet', ordinal: p.ordinal }, owner);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1421,8 +1473,8 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe
|
||||||
// rolls a waypoint — its band can't hold 5 points). A BARREN system
|
// rolls a waypoint — its band can't hold 5 points). A BARREN system
|
||||||
// (objectCount → 0) rolled no stations — it is a jump-gate-only stop,
|
// (objectCount → 0) rolled no stations — it is a jump-gate-only stop,
|
||||||
// deliberately.
|
// deliberately.
|
||||||
if (stations?.deepSpace) make('deepSpaceStation', { type: 'space' });
|
if (stations?.deepSpace) make('deepSpaceStation', { type: 'space' }, systemFaction);
|
||||||
if (stations?.waypoint) make('waypoint', { type: 'space' });
|
if (stations?.waypoint) make('waypoint', { type: 'space' }, systemFaction);
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,10 @@ import { config } from '../config/Config.js';
|
||||||
* // { title, subtitle, faction, population, status, statusColor }
|
* // { title, subtitle, faction, population, status, statusColor }
|
||||||
* // title — the system name
|
* // title — the system name
|
||||||
* // subtitle — "Main Sequence system · star G"
|
* // subtitle — "Main Sequence system · star G"
|
||||||
* // status — "Faction: Neutral · Pop ~1.2M" (or "… · Unclaimed")
|
* // status — "Faction: Apex · Pop ~1.2M" (or "… · Unclaimed")
|
||||||
|
* // The FACTION (data/factions.json — the roster's faction pass, on
|
||||||
|
* // content.faction): the controlling faction's label + color, or
|
||||||
|
* // Neutral (light gray) for the wild space + the neutral ring.
|
||||||
*/
|
*/
|
||||||
export function formatSystemReport(content, typeDefs = null) {
|
export function formatSystemReport(content, typeDefs = null) {
|
||||||
const types = typeDefs ?? config.get('systems.types', {});
|
const types = typeDefs ?? config.get('systems.types', {});
|
||||||
|
|
@ -28,11 +31,15 @@ export function formatSystemReport(content, typeDefs = null) {
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Faction control — every system is Neutral for now. When factions
|
// FACTION CONTROL (data/factions.json — the roster's faction pass):
|
||||||
// land (the `owner` seam on settlements), this is where the faction's
|
// the system's faction (content.faction — null = neutral wild space)
|
||||||
// name + theme color will plug in (Neutral = light gray).
|
// names the power + its color; a content without the field (old
|
||||||
const faction = 'Neutral';
|
// saves / hand-built content) reads Neutral, as before.
|
||||||
const statusColor = '#c4cad8';
|
const FACTIONS = config.get('factions.factions', {});
|
||||||
|
const fid = content.faction ?? null;
|
||||||
|
const fdef = fid ? FACTIONS[fid] : null;
|
||||||
|
const faction = fdef?.label ?? 'Neutral';
|
||||||
|
const statusColor = fdef?.color ?? '#c4cad8';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: content.name,
|
title: content.name,
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,20 @@ export function pickPreviewSystem(galaxy, spec, systemType = null) {
|
||||||
// world (starting system only — a terran-class body by rule).
|
// world (starting system only — a terran-class body by rule).
|
||||||
const candidates = classes.map((cls, i) => ({ cls, frame: frames ? frames[i] : null, ordinal: i + 1 }));
|
const candidates = classes.map((cls, i) => ({ cls, frame: frames ? frames[i] : null, ordinal: i + 1 }));
|
||||||
if (isHome) candidates.push({ cls: homeCls, frame: galaxy?.homeWorldFrame ?? null, ordinal: 0 });
|
if (isHome) candidates.push({ cls: homeCls, frame: galaxy?.homeWorldFrame ?? null, ordinal: 0 });
|
||||||
|
// CAPITAL WORLDS (data/factions.json — the roster's faction pass):
|
||||||
|
// a capital's seat is NOT a composition planet (its frame is fixed
|
||||||
|
// per faction — planets.json → capitals — not stamped by the frame
|
||||||
|
// pass). It REPLACES the system's outermost world (content ordinal
|
||||||
|
// = the composition count), so it joins the candidates as that
|
||||||
|
// outer slot — ?type=capitalneutral & co. preview the real seat.
|
||||||
|
if (rec.capital) {
|
||||||
|
const capCls = `capital${rec.capital.charAt(0).toUpperCase()}${rec.capital.slice(1)}`;
|
||||||
|
candidates.push({
|
||||||
|
cls: capCls,
|
||||||
|
frame: config.get(`planets.capitals.frames.${rec.capital}`, null),
|
||||||
|
ordinal: classes.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let exact = null;
|
let exact = null;
|
||||||
for (const c of candidates) {
|
for (const c of candidates) {
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,17 @@
|
||||||
* - the ONE exception: the player's home world is ALWAYS +20 (the
|
* - the ONE exception: the player's home world is ALWAYS +20 (the
|
||||||
* `home` value) — pinned. Nothing sets, changes, or clears it.
|
* `home` value) — pinned. Nothing sets, changes, or clears it.
|
||||||
*
|
*
|
||||||
* The FACTION CHECK (placeholder — factions come later): every settlement
|
* The FACTION CHECK: every settlement carries an `owner` (a faction id,
|
||||||
* carries the reserved `owner` seam (js/galaxy/SystemGenerator.js —
|
* or null = neutral/wild space — js/galaxy/SystemGenerator.js populates
|
||||||
* `owner: null` for now). standingFor() resolves it: a place owned by a
|
* it from the system's territory, the roster's faction pass in
|
||||||
* faction falls back to the player's standing WITH THAT FACTION
|
* js/galaxy/Galaxy.js, data/factions.json). standingFor() resolves it:
|
||||||
* (factionStanding()) before settling on neutral. Until factions exist
|
* a place owned by a faction falls back to the player's standing WITH
|
||||||
* every owner is null and factionStanding() is a stub — so the whole
|
* THAT FACTION (factionStanding()) before settling on neutral. The
|
||||||
* galaxy reads neutral (home world: +20). When factions land, only
|
* per-faction standing itself is still a marked `TODO(factions)` stub
|
||||||
* (a) populate `owner` in generation and (b) fill factionStanding()
|
* (factionStanding() returns null — the player's real relations with
|
||||||
* remain; the resolution order below is already the final one.
|
* apex/mindweave/zenith land there), so a faction-controlled place
|
||||||
|
* reads neutral today (home world: +20); the resolution order below is
|
||||||
|
* already the final one.
|
||||||
*
|
*
|
||||||
* PLACE IDENTITY (the reputation keys — stable per seed, from the
|
* PLACE IDENTITY (the reputation keys — stable per seed, from the
|
||||||
* generator): planets `<systemId>-p<ordinal>`, settlements
|
* generator): planets `<systemId>-p<ordinal>`, settlements
|
||||||
|
|
|
||||||
|
|
@ -920,6 +920,12 @@ export class GameScene extends Phaser.Scene {
|
||||||
onStart: (def) => {
|
onStart: (def) => {
|
||||||
if (def && def.from === 'trestle') this._questRefresh();
|
if (def && def.from === 'trestle') this._questRefresh();
|
||||||
},
|
},
|
||||||
|
// The comm ENDED (for 'ack' comms: the ACKNOWLEDGE press): the
|
||||||
|
// scene's ack-side bookkeeping — Trestle's last brief ('trestle
|
||||||
|
// sends the coordinates to your computer right now') sets the
|
||||||
|
// player's destination to the neutral capital on acknowledgment
|
||||||
|
// (GameScene._commEnded).
|
||||||
|
onEnd: (def) => this._commEnded(def),
|
||||||
});
|
});
|
||||||
|
|
||||||
// The run's FIRST rock field (the 'firstCluster' comm beat — see
|
// The run's FIRST rock field (the 'firstCluster' comm beat — see
|
||||||
|
|
@ -3592,6 +3598,36 @@ export class GameScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A comm ENDED (the CommsHub's onEnd hook — for 'ack' comms this is
|
||||||
|
* the ACKNOWLEDGE press): the scene's ack-side bookkeeping.
|
||||||
|
*
|
||||||
|
* TRESTLE'S LAST BRIEF (data/comms.json: trestle-brief-3) — he is
|
||||||
|
* "in the system right next to you in this star cluster's capital" and
|
||||||
|
* "sending the coordinates to your computer right now": the moment the
|
||||||
|
* player acknowledges it, the NEUTRAL CAPITAL (the roster's faction
|
||||||
|
* pass — js/galaxy/Galaxy.js → _assignFactions; its seat is that
|
||||||
|
* system's outermost world) becomes the player's destination.
|
||||||
|
* setDestination does the rest: the route is derived from it, the
|
||||||
|
* orange compass marker points at the gate to fly, and the map's
|
||||||
|
* route lane lights up — no extra input from the player. Arriving is
|
||||||
|
* 'Meet Trestle' (trestle-arrive plays on the jump-in).
|
||||||
|
*/
|
||||||
|
_commEnded(def) {
|
||||||
|
if (!def || def.id !== 'trestle-brief-3') return;
|
||||||
|
const t = this.galaxy?.neutralCapitalTarget?.();
|
||||||
|
if (!t) {
|
||||||
|
// The faction pass always designates one on a real galaxy — this
|
||||||
|
// is the degenerate-galaxy guard (no branches at all).
|
||||||
|
this.consoleToast('NO CAPITAL ON FILE — COORDS LOST IN THE STATIC', {
|
||||||
|
glyph: '✕',
|
||||||
|
glyphColor: toCss(themeColor('neon2', 0xff9b9b)),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setDestination(t.systemId, t.objectId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SET PRIORITY — the tracker HUD's featured quest (the row popup's
|
* SET PRIORITY — the tracker HUD's featured quest (the row popup's
|
||||||
* button, or the console's SET PRIORITY). The ledger is the source of
|
* button, or the console's SET PRIORITY). The ledger is the source of
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@
|
||||||
* JumpNetwork — a spanning tree, so the web reads as a maze):
|
* JumpNetwork — a spanning tree, so the web reads as a maze):
|
||||||
* - unexplored: a faint thread
|
* - unexplored: a faint thread
|
||||||
* - FRONTIER (one end visited): a brighter "next step"
|
* - FRONTIER (one end visited): a brighter "next step"
|
||||||
|
* - BRIDGE (a cross-branch middle-belt lane, data/gates.json →
|
||||||
|
* bridges — the inter-branch links the factions use to reach each
|
||||||
|
* other without the trunk, data/factions.json): the warm
|
||||||
|
* inter-branch thread (above unexplored/frontier, below used/route)
|
||||||
* - TRAVELED (the run jumped it): the bright lane — a glow pass +
|
* - TRAVELED (the run jumped it): the bright lane — a glow pass +
|
||||||
* a flow packet drifting along it (the route the player flew)
|
* a flow packet drifting along it (the route the player flew)
|
||||||
* • the CHARTED REGION — the convex hull of the visited systems,
|
* • the CHARTED REGION — the convex hull of the visited systems,
|
||||||
|
|
@ -48,12 +52,16 @@
|
||||||
* Reveal on tab switch / window open: a ripple out of the home system
|
* Reveal on tab switch / window open: a ripple out of the home system
|
||||||
* (stars bloom by distance, lanes trace in, the region fades last).
|
* (stars bloom by distance, lanes trace in, the region fades last).
|
||||||
*
|
*
|
||||||
* FACTIONS (planned — not yet implemented): the snapshot carries
|
* FACTIONS (data/factions.json — the worldgen layer is LIVE): the
|
||||||
* per-system `faction: null` already; when factions ship, the reserved
|
* snapshot carries per-system `faction` (null = neutral wild space) +
|
||||||
* seams are the star color layer (a faction ring/tint over the
|
* `capital` (the four designated capitals). The plate's faction layer
|
||||||
* archetype color) + a territory-region pass (the same hull/fill
|
* (`_redrawFactions`, config below — data/map.json → galaxy.factions)
|
||||||
* recipe as the charted region, per faction color + relation alpha).
|
* rings every territory system in its faction's color (factions.<id>
|
||||||
* See docs/PROJECT_NOTES.md → "Factions (planned)".
|
* .color) and marks every capital with the double-ring seat — the
|
||||||
|
* neutral capital in neutral silver. The per-faction territory-region
|
||||||
|
* FILL (this file's hull pass, colored per faction) + the relation
|
||||||
|
* tiers (js/reputation/Reputation.js → factionStanding) are the next
|
||||||
|
* layer. See docs/PROJECT_NOTES.md → "Factions".
|
||||||
*
|
*
|
||||||
* Pure view: it owns no game state — everything arrives through the
|
* Pure view: it owns no game state — everything arrives through the
|
||||||
* `getSnapshot` callback (polled by MapWindow while open).
|
* `getSnapshot` callback (polled by MapWindow while open).
|
||||||
|
|
@ -214,11 +222,12 @@ export class GalaxyView {
|
||||||
// px/py) — all BELOW the window chrome (plateFrame depth 2, tags 3)
|
// px/py) — all BELOW the window chrome (plateFrame depth 2, tags 3)
|
||||||
this.hullG = s.graphics().setScrollFactor(0).setDepth(1.05);
|
this.hullG = s.graphics().setScrollFactor(0).setDepth(1.05);
|
||||||
this.edgeG = s.graphics().setScrollFactor(0).setDepth(1.1);
|
this.edgeG = s.graphics().setScrollFactor(0).setDepth(1.1);
|
||||||
|
this.factionG = s.graphics().setScrollFactor(0).setDepth(1.38); // the FACTION layer (data/factions.json) — above the core images, below the star art
|
||||||
this.flowG = s.graphics().setScrollFactor(0).setDepth(1.45);
|
this.flowG = s.graphics().setScrollFactor(0).setDepth(1.45);
|
||||||
this.starG = s.graphics().setScrollFactor(0).setDepth(1.42); // the zoom-bloom star art (above the core image, below the flow packets + labels)
|
this.starG = s.graphics().setScrollFactor(0).setDepth(1.42); // the zoom-bloom star art (above the core image, below the flow packets + labels)
|
||||||
this.markerG = s.graphics().setScrollFactor(0).setDepth(1.6);
|
this.markerG = s.graphics().setScrollFactor(0).setDepth(1.6);
|
||||||
this.hoverG = s.graphics().setScrollFactor(0).setDepth(1.7);
|
this.hoverG = s.graphics().setScrollFactor(0).setDepth(1.7);
|
||||||
for (const o of [this.hullG, this.edgeG, this.flowG, this.starG, this.markerG, this.hoverG]) {
|
for (const o of [this.hullG, this.edgeG, this.factionG, this.flowG, this.starG, this.markerG, this.hoverG]) {
|
||||||
o.setVisible(false);
|
o.setVisible(false);
|
||||||
win.add(o);
|
win.add(o);
|
||||||
}
|
}
|
||||||
|
|
@ -423,6 +432,13 @@ export class GalaxyView {
|
||||||
isHome: rec.isHome,
|
isHome: rec.isHome,
|
||||||
isCurrent: rec.isCurrent,
|
isCurrent: rec.isCurrent,
|
||||||
gates: rec.gates,
|
gates: rec.gates,
|
||||||
|
// FACTIONS (data/factions.json — the roster's faction pass):
|
||||||
|
// the system's faction id (null = neutral wild space) + its
|
||||||
|
// capital designation (null = not a capital) — the faction
|
||||||
|
// layer's ring colors (_redrawFactions, data/map.json →
|
||||||
|
// galaxy.factions) read these.
|
||||||
|
faction: rec.faction ?? null,
|
||||||
|
capital: rec.capital ?? null,
|
||||||
glowW,
|
glowW,
|
||||||
coreW,
|
coreW,
|
||||||
revealDelay: 0,
|
revealDelay: 0,
|
||||||
|
|
@ -600,7 +616,10 @@ export class GalaxyView {
|
||||||
// ROUTE lanes (the active SET DESTINATION path) read first — the
|
// ROUTE lanes (the active SET DESTINATION path) read first — the
|
||||||
// "where am I going" line in orange (data/map.json → galaxy.edges.route,
|
// "where am I going" line in orange (data/map.json → galaxy.edges.route,
|
||||||
// color = data/gates.json → route.compassColor) — above used/frontier.
|
// color = data/gates.json → route.compassColor) — above used/frontier.
|
||||||
const st = e.route ? ec.route : e.used ? ec.used : e.frontier ? ec.frontier : ec.unexplored;
|
// BRIDGE lanes (the cross-branch middle-belt links, data/gates.json
|
||||||
|
// → bridges — the faction design's "don't go back to the trunk" routes)
|
||||||
|
// read as the warm inter-branch thread, above unexplored/frontier.
|
||||||
|
const st = e.route ? ec.route : e.used ? ec.used : e.frontier ? ec.frontier : e.bridge ? ec.bridge : ec.unexplored;
|
||||||
const color = toColor(st.color);
|
const color = toColor(st.color);
|
||||||
const alpha = st.alpha ?? 0.5;
|
const alpha = st.alpha ?? 0.5;
|
||||||
const width = Math.max(0.6, (st.width ?? 1) * (0.75 + 0.25 * k));
|
const width = Math.max(0.6, (st.width ?? 1) * (0.75 + 0.25 * k));
|
||||||
|
|
@ -677,11 +696,61 @@ export class GalaxyView {
|
||||||
if (show) st.label.setAlpha(0.92 * rf);
|
if (show) st.label.setAlpha(0.92 * rf);
|
||||||
}
|
}
|
||||||
this._drawStarArt(time, tR);
|
this._drawStarArt(time, tR);
|
||||||
|
this._redrawFactions(time, tR);
|
||||||
|
|
||||||
this._drawFlow(time);
|
this._drawFlow(time);
|
||||||
this._drawMarkers(time, k);
|
this._drawMarkers(time, k);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The FACTION LAYER (data/factions.json — the roster's faction pass,
|
||||||
|
* js/galaxy/Galaxy.js; config data/map.json → galaxy.factions): a ring
|
||||||
|
* in the faction's color around every system that sits in a faction
|
||||||
|
* territory (the wild-space ring right outside home stays plain — its
|
||||||
|
* systems are neutral), and the double-ring SEAT mark around every
|
||||||
|
* capital (the neutral capital wears neutral silver — factions.
|
||||||
|
* neutral.color). The ring radius rides the star's dot (the 2px floor
|
||||||
|
* keeps it readable at 1×), so the layer reads at every zoom; the
|
||||||
|
* per-frame redraw follows the star's pulse/reveal like the rest of
|
||||||
|
* the plate. The territory-region FILL (the hull pass, per faction
|
||||||
|
* color) is the next layer.
|
||||||
|
*/
|
||||||
|
_redrawFactions(time, tR) {
|
||||||
|
const g = this.factionG;
|
||||||
|
g.clear();
|
||||||
|
if (!this._snap) return;
|
||||||
|
const fc = this._cfg.factions ?? {};
|
||||||
|
if (fc.enabled === false) return;
|
||||||
|
const colors = config.get('factions.factions', {});
|
||||||
|
const alpha = fc.alpha ?? 0.62;
|
||||||
|
const ringMul = Number(fc.ringMul ?? 1.9);
|
||||||
|
const width = Number(fc.width ?? 1.2);
|
||||||
|
const capWidth = Number(fc.capitalWidth ?? 1.8);
|
||||||
|
const capAlpha = Number(fc.capitalAlpha ?? 0.9);
|
||||||
|
const capOuter = Number(fc.capitalOuter ?? 2.8);
|
||||||
|
for (const st of this._stars) {
|
||||||
|
if (st.culled) continue;
|
||||||
|
const fid = st.capital ?? st.faction;
|
||||||
|
if (!fid) continue;
|
||||||
|
const rf = this._easeReveal(tR, st.revealDelay, 480);
|
||||||
|
if (rf <= 0.01) continue; // still in the reveal fade-in
|
||||||
|
const color = toColor(colors?.[fid]?.color ?? 0xb9c4d6);
|
||||||
|
const base = Math.max(2, this._dotOf(st) * 2.4) * (st.visited ? 1 : 0.8);
|
||||||
|
if (st.capital) {
|
||||||
|
// The SEAT — a double ring (inner solid + outer soft): the
|
||||||
|
// faction's home world on the plate, unmistakable at 1×.
|
||||||
|
g.lineStyle(capWidth, color, capAlpha * rf);
|
||||||
|
g.strokeCircle(st.lx, st.ly, base * ringMul);
|
||||||
|
g.lineStyle(1, color, capAlpha * 0.4 * rf);
|
||||||
|
g.strokeCircle(st.lx, st.ly, base * capOuter);
|
||||||
|
} else {
|
||||||
|
// Territory ring — the faction's color over the archetype dot.
|
||||||
|
g.lineStyle(width, color, alpha * rf);
|
||||||
|
g.strokeCircle(st.lx, st.ly, base * ringMul);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_labelVisible(st) {
|
_labelVisible(st) {
|
||||||
const lab = this._cfg.labels;
|
const lab = this._cfg.labels;
|
||||||
if (lab.enabled === false) return false;
|
if (lab.enabled === false) return false;
|
||||||
|
|
@ -1292,6 +1361,7 @@ export class GalaxyView {
|
||||||
const objs = [
|
const objs = [
|
||||||
this.hullG,
|
this.hullG,
|
||||||
this.edgeG,
|
this.edgeG,
|
||||||
|
this.factionG,
|
||||||
this.flowG,
|
this.flowG,
|
||||||
this.markerG,
|
this.markerG,
|
||||||
this.starG,
|
this.starG,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue