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