diff --git a/docs/design/star-map-plan.md b/docs/design/star-map-plan.md new file mode 100644 index 000000000..9ba460085 --- /dev/null +++ b/docs/design/star-map-plan.md @@ -0,0 +1,412 @@ +# Star Map Generation — Plan +**Status:** Working plan — not yet a canonical decision record +**Created:** 2026-03-13 +**Updated:** 2026-03-13 (star naming section added) +**Author:** Miri (worldbuilding) +**Precedes:** star-map generation work, CSV population + +--- + +## What the star map is + +The star map is the one deliverable from the systems framework that the CSV cannot encode. It is a graph: nodes are systems, edges are gate connections. Once it exists, the `adjacent_systems` transit graph database can be populated, `earth_proximity` hop-distances can be computed, and generation of the 300-system CSV can begin. + +The map has two consumers: + +1. **The simulation** — needs a machine-readable edge list (source system ID, destination system ID, both aperture counts decremented accordingly) +2. **Content authors and the team** — need a navigable visual reference that shows clusters, corridors, chokepoints, and the distribution of topology types + +These two needs drive the split architecture described below. + +--- + +## On d2 for 300 nodes + +d2 is the right tool for diagrams in this project. The existing examples (`district-topology.d2`, `system-tiers.d2`, etc.) are all well within d2's comfort zone — tens of nodes, carefully laid out. + +300 nodes with hundreds of edges is a different problem. The honest assessment: + +**d2 will render 300 nodes.** It will not produce a readable diagram. d2 uses automatic layout algorithms (ELK, dagre, or its own). At 300 nodes with a web topology — where many nodes have 2–4 connections and some hubs have 6–8 — the auto-layout will produce a hairball. The SVG or PNG output will be technically correct and visually useless. This is not a d2 limitation specifically; it is a property of automatic layout at this scale. Force-directed graph tools (Gephi, GraphViz neato, d3-force) have the same problem at 300 nodes unless significant hand-tuning is done. + +**The conclusion:** d2 is not the right rendering tool for the full map. We use it for sector-level breakdowns where node counts are manageable (30–60 nodes per sector map). The full map gets a different treatment. + +--- + +## Architecture: three representations + +The star map lives in three forms, each serving a different purpose. They are all generated from one canonical source. + +### 1. Canonical source: `star-map.json` + +A single JSON file is the authoritative record of the graph. It contains: + +- A node array: one entry per system with `system_id`, `system_name`, `geographic_sector`, `settlement_wave`, `gate_topology`, and `aperture_count` +- An edge array: undirected pairs `[system_id_a, system_id_b]`, one entry per gate connection + +Everything else — hop distances, `earth_proximity` values, `gate_connections` counts per node, the `adjacent_systems` transit database entries — is derived from this file by script. The JSON does not store derived data. + +**Why JSON over CSV edge list:** The node array and edge array are structurally distinct. A CSV edge list forces either two files or a mixed format that conflates them. JSON keeps them separate while remaining human-readable and trivially parseable by Python. + +**Why not a graph database:** Overkill. The graph is static for the purposes of generation. A database adds operational complexity without benefit at this stage. The transit graph database that the simulation uses is populated from this JSON, not replaced by it. + +### 2. Sector maps: d2 diagrams (×6) + +Six d2 files, one per geographic sector: `star-map-core.d2`, `star-map-north-reach.d2`, etc. + +Each diagram contains: +- All systems in that sector as labeled nodes, color-coded by settlement wave +- All intra-sector gate connections as edges +- Inter-sector connections shown as stubs: a node for the connected system in another sector, visually distinct (dashed border, dimmed), with a label indicating which sector it belongs to + +This keeps each diagram to 30–60 nodes — well within d2's readable range — while preserving the full connectivity picture. Cross-sector corridors are visible on both sides. + +A seventh d2 file (`star-map-overview.d2`) shows sectors as large cluster nodes with inter-sector edge bundles labeled with connection counts. This gives a macro view of how the sectors relate without trying to render individual systems. + +All d2 files are generated programmatically. No hand-writing of 300 node declarations. + +### 3. The overview diagram + +The `star-map-overview.d2` is the map that lives in presentations and high-level references. Seven nodes (six sectors + Gateway as a special diplomatic_periphery node), edges between them labeled with the number of gate connections crossing that boundary, and the Gateway's special status called out explicitly. + +--- + +## Generation algorithm + +Writing a realistic gate network by hand is not feasible at 300 nodes. But pure random generation produces garbage — too uniform, no chokepoints, no corridors, no narrative texture. The algorithm needs to be guided. + +### Phase 1: Sector seed placement + +Divide the 300 systems among six sectors according to the framework's distribution guidance: +- Core: ~25 systems (Wave 1–2 heavy) +- Each of four cardinal sectors: ~45–55 systems +- Deep frontier: ~25–30 systems + +Within each sector, assign systems a rough positional weight: inner-band systems are closer to the core, outer-band systems are farther. This is used to guide connection probability, not to produce actual 2D coordinates — the map is a topology, not a spatial map. + +### Phase 2: Backbone construction (spanning tree) + +Build a spanning tree across all 300 systems using a modified Prim's algorithm with the following weights: +- Inner-band systems connect to other inner-band systems with high probability +- Outer-band systems connect inward (toward the nearest already-connected system) +- Sector crossings are allowed but weighted down — they should happen at specific chokepoint systems, not uniformly + +This guarantees connectivity (the framework requirement: no stranded systems) while producing organic clustering. The spanning tree alone gives every system exactly one connection — the minimum for connectivity. + +### Phase 3: Augmentation passes + +The spanning tree is the skeleton. Multiple augmentation passes add edges to produce realistic topology: + +**Pass A — Hub formation.** For each system flagged as `hub` or `junction` topology, add connections until it reaches its target degree (4–8 for hubs, 3–4 for junctions). Hub systems are pre-seeded in high-traffic locations: the Gateway, core systems that will become institutional anchors, and 2–3 per cardinal sector. + +**Pass B — Loop formation.** Select pairs of systems that are 3–5 hops apart in the current graph and share a sector. Add a direct connection between them. This creates the routing redundancy that makes some systems `loop_member`. Target: 15–25 loops total across the full graph. + +**Pass C — Spur extension.** Some outer-band and deep-frontier systems should be spurs: one inbound connection only. These are created by adding leaf nodes in Pass A that are not subsequently augmented. `dead_end` systems get one connection (the minimum from Phase 2) and no augmentation. + +**Pass D — Cross-sector bridges.** Ensure each sector has 2–4 connections to neighboring sectors. These should run through the highest-degree systems on each side — that is what makes those systems chokepoints. Identify under-connected sector boundaries and add bridging edges. + +### Phase 4: Topology classification + +After augmentation, classify each system by its final degree (connection count): +- degree 1: `dead_end` +- degree 2, no cycles: `spur_end` +- degree 2, part of a loop: `loop_member` +- degree 3: `through_route` (if on a clear path) or `junction` (if it branches) +- degree 4+: `junction` or `hub` + +Check the resulting distribution against the framework guidance. If `hub` count is too low or `dead_end` percentage is wrong, adjust augmentation weights and regenerate. + +### Phase 5: Aperture count assignment + +Each system's `aperture_count` must be >= its `gate_connections` count (you cannot have more connections than apertures). The framework allows 1–8 apertures for systems that have a horizon station, and states ~75% of systems have stations. + +Assignment rules: +- Systems with no horizon station: `aperture_count = 0`, `gate_connections = 0` (these are transit-dead; they exist but are unreachable by gate) +- Systems with a station: `aperture_count = max(gate_connections, 1)` as a floor, then add 0–2 unused apertures for systems flagged as `research_periphery` or `contested_activation` (the unused apertures are narratively interesting) +- Single-aperture stations with one connection: valid. One way in, one way out. + +### Phase 6: Validation + +Run automated checks before accepting the graph: + +1. **Connectivity:** BFS from the Gateway system reaches all systems that have horizon stations. Systems without stations are documented as intentionally isolated. +2. **Aperture consistency:** No system has `gate_connections > aperture_count`. +3. **Hub distribution:** At least one hub per sector. No more than 15% of systems are hubs. +4. **Dead-end distribution:** 20–35% of systems are dead_end or spur_end. (The framework says dead-ends are "valid and interesting" — they should be common, not rare.) +5. **Gateway placement:** The Gateway is in the core sector, diplomatic_periphery zone, and has 3–5 connections (enough to be a nexus, not so many it's trivially robust). +6. **`earth_proximity` derivability:** BFS hop count from Gateway to every connected system should produce a plausible distribution: 10–15 systems within 2 hops (immediate), 40–60 within 10 hops (proximate), remainder at 11–30 (distant) or 30+ (irrelevant). + +--- + +## The Gateway system + +The Gateway system requires hand-crafting. It is the one system the algorithm cannot place procedurally because its position determines `earth_proximity` for the entire network. + +**Placement constraints:** +- Geographic sector: core +- Political zone: diplomatic_periphery +- Gate topology: junction or hub (3–5 connections) +- One of its apertures is the Sol-facing connection (currently closed / restricted but physically present) +- It should sit at a natural boundary of the core sector — close enough to institutional power to be politically significant, far enough to feel like a threshold + +**Narrative constraints:** +- The Gateway is the most surveilled system in the Reach. `commission_presence: dominant`, `institute_presence: dominant` +- It is not a commerce hub. It is an institutional system. The people here are administrators, archivists, permit processors, and watchers. +- Its silence_topic is the Sol-aperture permit process. Its silence_threshold is `unreachable` for most questions and `investigation_depth` for the narrowest ones. + +**Name:** Needs to be decided. "Gateway" is a working descriptor, not a canonical name. Setting note: the system's canonical name should feel like something Wave 1 settlers would have named — they knew what they were building. It should carry weight without being on-the-nose. Candidates to be proposed in a separate discussion. + +--- + +## Implementation: the generation script + +A Python script at `tooling/generate-star-map.py` will: + +1. Read a seed configuration file (`tooling/star-map-seed.json`) that specifies: + - System count per sector + - Pre-seeded hub locations (by index, before naming) + - Gateway position + - Target topology distribution percentages + - Random seed for reproducibility + +2. Run the Phase 1–6 algorithm and produce: + - `docs/design/star-map.json` — canonical edge/node data + - `docs/diagrams/design/star-map-core.d2` + - `docs/diagrams/design/star-map-north-reach.d2` + - `docs/diagrams/design/star-map-south-reach.d2` + - `docs/diagrams/design/star-map-east-reach.d2` + - `docs/diagrams/design/star-map-west-reach.d2` + - `docs/diagrams/design/star-map-deep-frontier.d2` + - `docs/diagrams/design/star-map-overview.d2` + +3. Print a validation summary: node count, edge count, topology distribution, sector cross-connections, hop distances from Gateway. + +The script should be **deterministic** given the same seed. If the topology needs adjustment, we change the seed or the configuration weights, not the output files directly. The output files are always regeneratable. + +**Script dependencies:** Python standard library only. No networkx required — the graph operations needed (BFS, spanning tree, degree calculation) are simple enough to implement inline and keeping dependencies minimal means the script runs anywhere. + +--- + +## Star naming — the dual-identifier system + +Every system has two identifiers. They serve different purposes and must both be present in `star-map.json` and the generated CSV. + +### The two fields + +**`astronomical_id`** — The catalog designation. This is what astronomers would call the star: HD 10700, Gliese 667 C, HIP 113576, 2MASS J05540421-6001245. It is the star's "real" name in the setting's scientific and institutional record. The Veil Institute indexes systems by astronomical ID. Commission permits reference astronomical IDs. Old survey documents use them. + +**`system_name`** — The common or friendly name. This is what people actually call the place. Some stars have well-known proper names from Earth's pre-settlement astronomy (Tau Ceti, Barnard's Star, Procyon) — those keep them. The rest were named by the settlers who founded them, drawing on their founding culture's naming traditions. This is the name that appears on transit boards, in casual conversation, and on the header of every system wiki page. + +The framework's existing `system_name` column becomes `system_name` (unchanged) and is joined by a new `astronomical_id` column in Section I. The framework document needs a one-line amendment. See the framework change flag at the end of this section. + +### Star selection: 300 from ~2,500 + +The nearest ~2,500 stars to Sol are the realistic candidate pool at the game's implied technology level (horizon stations are found out to roughly 250–300 light-years; no station has ever been found beyond that). From this pool, ~300 receive a horizon station. + +**Why only 300?** The gate builders were selective. Their selection logic is unknown to humans in the setting — this is part of what makes the Veil Institute's research so difficult. The apparent selection criteria do not map cleanly onto habitability, stellar stability, or any other human-legible principle. Some dead systems have stations. Some garden worlds do not. This is not an error; it is a mystery, and it is the oldest silence in the Reach. + +**Human-observable distribution of gate-bearing stars (how it actually worked out):** + +The Veil Institute has catalogued correlations without being able to explain them. For our purposes, the 300 selected systems should feel like a slightly skewed sample of real stellar neighborhoods, not a clean filter: + +- M-type dwarfs (red dwarfs): ~45–50% of gated systems — broadly proportional to their share of all nearby stars +- K-type stars (orange dwarfs): ~25–28% — modestly over-represented relative to pure frequency; K stars are common targets for human settlement anyway +- G-type stars (Sun-like): ~12–15% — roughly proportional +- F-type (slightly hotter/larger): ~5–7% +- Binary systems: ~15–20% of gated systems are in binary or multiple-star systems; both stars may or may not have their own designation depending on which component hosts the station +- Unusual / white dwarf / subdwarf: ~3–5% — the Veil Institute finds these disproportionately interesting, often as research_enclave_active sites + +**What this means for generation:** The seed file should specify spectral type distribution targets. The procedural generator fills in `star_type` values consistent with these targets. The actual star catalog population (matching each S-NNN ID to a real or fabricated designation) is a content pass after topology is validated. + +### Sourcing: real stars first, fabricated second + +**Real stars to anchor the list** — The nearest ~300 stars (within roughly 80 light-years) are well-documented in the HYG database (Hipparcos + Yale + Gliese catalog merge). We draw on these for our nearest-to-Sol systems. Stars with proper names keep those names. Stars known only by catalog designations get settler-given friendly names. + +Well-known nearby stars with proper names that anchor the real-star pool: +- Proxima Centauri (4.2 ly, M5Ve) — HIP 70890 +- Alpha Centauri A / Rigil Kentaurus (4.4 ly, G2V) — HD 128620, HIP 71683 +- Alpha Centauri B (4.4 ly, K1V) — HD 128621, HIP 71681 +- Barnard's Star (5.9 ly, M4Ve) — HIP 87937, GJ 699 +- Sirius (8.6 ly, A1V) — HD 48915, HIP 32349 +- Epsilon Eridani (10.5 ly, K2V) — HD 22049, HIP 16537 +- Tau Ceti (11.9 ly, G8V) — HD 10700, HIP 8102, GJ 71 +- Procyon (11.4 ly, F5V) — HD 61421, HIP 37279 +- 61 Cygni A/B (11.4 ly, K5V/K7V) — HD 201091/201092, HIP 104214/104217 +- Epsilon Indi (11.8 ly, K5Ve) — HD 209100, HIP 108870 +- Lalande 21185 (8.3 ly, M2V) — HIP 54035, GJ 411 +- Ross 128 (10.9 ly, M4.5V) — HIP 57548, GJ 447 +- Lacaille 9352 (10.7 ly, M2V) — HIP 114046, GJ 887 +- Groombridge 1618 (15.9 ly, K7V) — HD 36395, HIP 26335 +- 70 Ophiuchi A/B (16.6 ly, K1V/K5V) — HD 165341, HIP 88601 +- Sigma Draconis / Alsafi (18.8 ly, G9V) — HD 185144, HIP 96100 +- Eta Cassiopeiae / Achird (24.6 ly, G0V) — HD 4614, HIP 3821 +- Gliese 570 / 33 G. Librae (19.2 ly, K4V) — HD 131977, HIP 73184 +- 82 Eridani (19.8 ly, G8V) — HD 20794, HIP 15510 +- Gliese 667 C (22.7 ly, M2V) — GJ 667C, part of HD 156384 system +- Beta Hydri (24.4 ly, G2IV) — HD 2151, HIP 2021 +- Fomalhaut (25.1 ly, A3V) — HD 216956, HIP 113368 +- Vega (25.3 ly, A0V) — HD 172167, HIP 91262 +- Gliese 250 (28.4 ly, K3V/M2.5) — HD 50281, HIP 33226 +- 61 Virginis (27.8 ly, G5V) — HD 115617, HIP 64924 +- Pi3 Orionis / Tabit (26.2 ly, F6V) — HD 30652, HIP 22449 +- Chara / Beta CVn (27.4 ly, G0V) — HD 109358, HIP 61317 +- Delta Pavonis (19.9 ly, G8IV) — HD 190248, HIP 99240 +- Omicron2 Eridani (16.4 ly, K1Ve + DA4 + M4.5Ve) — HD 26965, HIP 19849 +- HR 1614 (28.4 ly, K3V) — HD 32147, HIP 23311 +- 107 Piscium (24.4 ly, K1V) — HD 10476, HIP 7981 +- Groombridge 1830 (29.9 ly, G8Vp) — HD 103095, HIP 57939 +- Kappa1 Ceti (29.8 ly, G5V) — HD 20630, HIP 15457 +- Mu Cassiopeiae / Marfak (24.6 ly, G5VIp) — HD 6582, HIP 5336 +- HD 40307 (42.0 ly, K2.5V) — HIP 27887, GJ 2046 +- Altair (16.8 ly, A7V) — HD 187642, HIP 97649 + +Beyond this core of well-known stars, the real catalog runs out of familiar proper names quickly. Most stars in the 30–100+ light-year range are known only by catalog designations (HD numbers, HIP numbers, Gliese numbers). These are the stars that get settler-given names. + +**Fabricated stars (systems beyond the well-mapped region)** — For systems beyond ~100 light-years, or for systems where a real star was not assigned, we fabricate plausible catalog designations. The fabrication rules: + +1. **Use mixed catalogs.** Real catalogs don't all use the same numbering system. Our fabricated entries should mix: some HD, some HIP, some GJ (Gliese-Jahreiss), some 2MASS. + +2. **Do not use neat sequential numbering.** Real catalog numbers have gaps, inconsistencies, and historical accidents. HD numbers jump around. HIP numbers don't correlate with distance or brightness in a simple way. Our fabricated numbers should look like they were generated by different survey campaigns at different times. + +3. **HD number range:** HD catalog goes up to ~225,000. For fabricated HD designations, use numbers in the ranges that suggest less well-known stars: HD 70000–HD 180000, with scattered gaps. Avoid HD 1–HD 10000 (too bright, too famous). + +4. **HIP number range:** HIP catalog has ~118,000 entries. Fabricated HIP numbers should fall in ranges not covered by well-known stars. Use: HIP 12000–HIP 118000, with realistic gaps. + +5. **GJ number range:** Gliese-Jahreiss catalog. GJ numbers go up to ~4500. GJ 3NNN designations (GJ 3001–GJ 3999) are a supplementary list of less well-characterized stars — good for fabrication. Also GJ 4NNN (GJ 4000+). + +6. **2MASS designations:** Format is `2MASS J` followed by RA/Dec encoded coordinates: `2MASS JHHMMSS.SS±DDMMSS.S`. Use these sparingly — they feel like stars discovered late, not well-characterized. Good for deep-frontier systems or recently activated horizon stations. + +**Examples of plausible fabricated designations:** +- HD 127643 (a G-type star in a not-too-famous range) +- HIP 44891 (Hipparcos entry in a mid-range) +- GJ 3471 (Gliese supplementary catalog) +- GJ 4012 (later Gliese-Jahreiss entry) +- 2MASS J14182612-3104543 (deep survey coordinates) +- HD 98711 (another Henry Draper mid-range entry) + +### Friendly naming conventions by founding culture + +When a star has no established proper name, the settlers who founded the system gave it one. Naming happened at first permanent settlement — the name in use today reflects the Wave and culture that planted the flag first. + +This is not about assigning names to real stars (which have their own proper names if they have any). It is about naming the catalog-only stars that make up the majority of the 300-system pool. + +**Naming conventions by founding culture type:** + +| `founding_culture_primary` | Naming register | Examples | +|---|---|---| +| `nordic_diaspora` | Old Norse place-names, kennings, and mythological geography. Short, consonant-heavy. Often evocative of landscape or weather. | Skarveld, Grenvann, Hvitholm, Dagnfjord, Kolsheim | +| `east_asian_diaspora` | Classical Chinese or Japanese characters transliterated, place-names evoking natural features (mountain, water, light). | Lintian, Suiyuan, Akishima, Torashima, Xianmen | +| `south_asian_diaspora` | Sanskrit-derived, often astronomical or mythological terms. Two or three syllables. | Taraval, Suryavat, Indravaan, Nalvara, Dharket | +| `iberian_diaspora` | Spanish or Portuguese place-name conventions: saints' names reduced to nickname, geographic descriptors, colonial-era cadence. | Novo Terceira, Corteval, Riomar, Aguada, Belverde | +| `west_african_diaspora` | Pan-African place-name blend: Yoruba, Akan, Swahili, Wolof roots. Often meaning-bearing (strength, river, dawn, iron). | Odufemi, Kwavela, Salimani, Iroko, Sembele | +| `administrative_charter` | Committee-named: tends toward Roman-style designations, Latinate compound words, or founding-figure surnames. Feels official. | Vestius, Caldaren, Harmon's Point, Nova Meria, Tertios | +| `syndic_company` | Named by a company or combine: often a founder's name, a commodity reference, or a commercial-era place-name. | Aldren Station, Cortex Prime, Havelock, Miren Reach, Cadrex | +| `religious_refugee` | Named after scripture, martyrs, sacred geography, or doctrinal concepts. Tends toward the weighty and aspirational. | Covenant's Rest, Thresh, Navarethis, Solara, Kereneth | +| `separatist_charter` | Named to signal independence: often an Abstract virtue, a deliberate break from Earth-heritage naming, sometimes a founder-name with political resonance. | Autonoma, Clearwater, Vanthis, Aldgate, New Resolve | +| `academic_scientific` | Named after scientists, mathematicians, philosophical concepts, or methodological terms. | Veritas, Hawking's Claim, Curien, Bayes Point, Lamarck | +| `military_frontier` | Named after military terms, commanders, engagements, or fortification concepts. | Rampart, Vorfeld, Callister's Landing, Thornwall, Basset | +| `asteroid_mining` | Named after ore deposits, geological terms, survey designations, or mining company shorthand. | Ferric Station, Vein-3, Gravel Point, Cressite, Marl | +| `agricultural_breadbasket` | Named after crops, seasons, harvests, or pastoral landscapes from the home culture. | Harvestholm, Granero, Sumai Plains, Kettlebrook, Ashfallow | +| `penal_exile` | Named by the authority that established it, often with numbering or administrative designations. May have been renamed by survivors. | Colony Seven, Redemption, Strandvik, Auger's Station, Penitent | +| `refugee_wave` | Named for what was lost, where they came from, or an expression of the displacement. Often carries grief. | New Calais, Remnant, Dusk Harbor, Careva, Farland | +| `speculative_venture` | Named by an investor syndicate: often hopeful, commercial-sounding, or bearing a founder's name. | Goldcrest, Brightwater, Primus Holding, Vansen, Aldine | + +**Multi-culture systems:** When `founding_culture_secondary` is set, the system name may blend conventions or use the dominant culture's convention with a secondary-culture flavor in the names of major settlements within the system. + +### The naming pass in practice + +The generation script assigns placeholder IDs (S-001 through S-300, sequenced by wave and sector). After topology is validated, a naming pass runs: + +1. **Sort systems by distance band.** The innermost systems (nearest to Sol) get real-star astronomical IDs from the catalog pool above. Farther systems get fabricated IDs. + +2. **Assign proper names first.** Any system whose real star has a proper name (Tau Ceti, Barnard's Star, etc.) takes that name as its `system_name`. Overrides founding-culture naming. + +3. **Apply founding-culture naming to the remainder.** The content author or generation script draws from the culture's naming register to produce a `system_name` for each catalog-only star. These should feel like real place-names, not made-up sci-fi words — the naming register above is the constraint. + +4. **The Gateway system and Krenn** receive their canonical names before the CSV phase, as they anchor everything else. (Gateway name still pending team decision.) + +### Framework change flag + +The framework document (`docs/design/systems-framework.md`) currently has one field in Section I — Identity and Location — where this change lands: + +**Existing:** +``` +| `system_name` | string | free text | (C)(G) | Name derived from founding culture naming conventions. | +``` + +**Proposed amendment (do not make yet — flag only):** +``` +| `astronomical_id` | string | catalog designation | (C) | Primary catalog identifier: HD, HIP, GJ, or 2MASS designation. Real where available; fabricated per spec for systems beyond the well-mapped region. | +| `system_name` | string | free text | (C)(G) | Common or friendly name. Proper name where star has one; settler-given per founding culture naming conventions otherwise. | +``` + +This adds one column to Section I (67 total, not 66). The column count summary in the framework doc needs updating. `astronomical_id` is (C) only — it is a content-author reference and worldbuilding anchor, not a runtime input to the simulation. The simulation addresses systems by `system_id`. + +--- + +## What comes after the map + +Once `star-map.json` is validated: + +1. Populate `adjacent_systems` in the transit graph database from the edge list +2. Run BFS from Gateway to populate `earth_proximity` for all 300 systems +3. Verify `gate_connections` per node matches the framework's stored column values +4. Begin CSV population — the map provides the topology columns; everything else is the content generation phase + +The galactic history wiki is a parallel deliverable (not dependent on the map) and can proceed concurrently. + +--- + +## d2 style conventions for sector maps + +Consistent with existing project d2 conventions (dark background, `#1a1e24` base, `#c8d0e0` text): + +- Node color by settlement wave: + - Wave 1: bright institutional blue (`#3060c0`) + - Wave 2: commercial amber (`#b8a020`) + - Wave 3: working grey-green (`#3a8a50`) + - Wave 4: frontier orange (`#c86010`) + - Wave 5: activation edge red (`#c02020`) + - Unsettled: dim neutral (`#4a5060`) +- Node shape by topology: + - hub: hexagon + - junction: diamond + - dead_end / spur_end: rectangle (default) + - loop_member / through_route: oval +- Cross-sector stub nodes: dashed border, dimmed fill, labeled with `[sector]` +- Gateway: distinct visual treatment — double border or highlight stroke, labeled explicitly + +--- + +## Open decisions before work begins + +1. **Gateway system name** — needs team discussion before the map is authored. Blocking for the hand-crafted node. +2. **Target topology distribution** — the framework says "hub, junction, through_route, loop_member, spur_end, dead_end" are all valid. It does not give percentages. Before running the generation script, we need a target distribution to validate against. Proposed defaults: + - dead_end: 20% + - spur_end: 15% + - through_route: 25% + - loop_member: 15% + - junction: 18% + - hub: 7% + These are parameters in `star-map-seed.json`, not hard-coded. They can be adjusted after reviewing the first generated map. +3. **Cultural corridors** — the framework reserves `cultural_corridor` as a column. Corridors follow gate paths. The map generation script can flag obvious corridor candidates (chains of systems with similar founding cultures that are gate-connected), but corridor canonization is a content-author decision, not an algorithm output. +4. **Framework amendment approval** — the dual-identifier system requires adding `astronomical_id` to Section I of the framework doc (67 columns total, up from 66). This needs lead sign-off before the framework doc is modified. The amendment is flagged in the naming section above and is low-impact: it adds one (C)-only column that does not affect any runtime system. Pending team review. + +--- + +## Summary of deliverables + +| Deliverable | Format | Author | When | +|---|---|---|---| +| `tooling/generate-star-map.py` | Python script | Miri (or assigned) | Before map generation | +| `tooling/star-map-seed.json` | JSON config | Miri | Before map generation | +| `docs/design/star-map.json` | JSON graph | Generated | Output of script | +| `docs/diagrams/design/star-map-*.d2` (×7) | d2 source | Generated | Output of script | +| `docs/diagrams/design/star-map-*.png` (×7) | PNG renders | Generated via d2 | After d2 source | +| Gateway system profile | Hand-authored | Miri | After name decided | +| Framework amendment: add `astronomical_id` | Edit to `systems-framework.md` | Miri | After lead sign-off | +| Naming catalog: real-star pool + fabrication rules | Embedded in this doc | Miri | Done (see naming section above) | + +--- + +*Plan authored 2026-03-13 by Miri. Updated 2026-03-13 with star naming section.* +*Immediate next action: decide Gateway system name, confirm topology distribution targets, get lead sign-off on framework amendment, then implement generation script.* diff --git a/docs/design/star-map.json b/docs/design/star-map.json new file mode 100644 index 000000000..28f8ac468 --- /dev/null +++ b/docs/design/star-map.json @@ -0,0 +1,5556 @@ +{ + "_meta": { + "generated": "2026-03-13", + "version": "0.1", + "system_count": 300, + "edge_count": 334, + "note": "Placeholder IDs and names. Naming pass required before CSV population.", + "sculpted": "2026-03-13", + "sculpt_note": "Sculpted topology: loop edges added, excess core/frontier edges removed. Reclassified all nodes. Aperture counts updated.", + "core_balanced": "2026-03-13", + "core_balance_note": "Core sector hand-balanced: +17 edges added within core sector. 20 nodes reclassified. Core result: 3 hubs (S-001 Gateway, S-067, S-235), 11 junctions, 8 spur_ends, 3 dead_ends (S-265, S-279, S-297).", + "tuned": "2026-03-13", + "tune_note": "Topology tuning: promoted junctions to hubs, created dead_ends, reduced loop_member/through_route/junction excess. Reclassified all nodes." + }, + "nodes": [ + { + "system_id": "S-001", + "system_name": "PLACEHOLDER_GATEWAY", + "star_type": "G", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "diplomatic_periphery", + "settlement_wave": "wave_1", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 4, + "earth_proximity": "immediate", + "hop_distance_from_gateway": 0, + "is_gateway": true + }, + { + "system_id": "S-002", + "system_name": "PLACEHOLDER_002", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-003", + "system_name": "PLACEHOLDER_003", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_2", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-004", + "system_name": "PLACEHOLDER_004", + "star_type": "K", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_2", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-005", + "system_name": "PLACEHOLDER_005", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-006", + "system_name": "PLACEHOLDER_006", + "star_type": "G", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-007", + "system_name": "PLACEHOLDER_007", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-008", + "system_name": "PLACEHOLDER_008", + "star_type": "G", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-009", + "system_name": "PLACEHOLDER_009", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_5", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-010", + "system_name": "PLACEHOLDER_010", + "star_type": "K", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "earth_proximity": "immediate", + "hop_distance_from_gateway": 1 + }, + { + "system_id": "S-011", + "system_name": "PLACEHOLDER_011", + "star_type": "binary", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "unsettled", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-012", + "system_name": "PLACEHOLDER_012", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_5", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 9 + }, + { + "system_id": "S-013", + "system_name": "PLACEHOLDER_013", + "star_type": "K", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_5", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-014", + "system_name": "PLACEHOLDER_014", + "star_type": "F", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_1", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "immediate", + "hop_distance_from_gateway": 1 + }, + { + "system_id": "S-015", + "system_name": "PLACEHOLDER_015", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_3", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-016", + "system_name": "PLACEHOLDER_016", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "unsettled", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-017", + "system_name": "PLACEHOLDER_017", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-018", + "system_name": "PLACEHOLDER_018", + "star_type": "G", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-019", + "system_name": "PLACEHOLDER_019", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_5", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 9 + }, + { + "system_id": "S-020", + "system_name": "PLACEHOLDER_020", + "star_type": "K", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_1", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-021", + "system_name": "PLACEHOLDER_021", + "star_type": "K", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_2", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-022", + "system_name": "PLACEHOLDER_022", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-023", + "system_name": "PLACEHOLDER_023", + "star_type": "K", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 10 + }, + { + "system_id": "S-024", + "system_name": "PLACEHOLDER_024", + "star_type": "K", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-025", + "system_name": "PLACEHOLDER_025", + "star_type": "K", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-026", + "system_name": "PLACEHOLDER_026", + "star_type": "G", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-027", + "system_name": "PLACEHOLDER_027", + "star_type": "unusual", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "earth_proximity": "immediate", + "hop_distance_from_gateway": 2 + }, + { + "system_id": "S-028", + "system_name": "PLACEHOLDER_028", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-029", + "system_name": "PLACEHOLDER_029", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-030", + "system_name": "PLACEHOLDER_030", + "star_type": "G", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-031", + "system_name": "PLACEHOLDER_031", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-032", + "system_name": "PLACEHOLDER_032", + "star_type": "F", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-033", + "system_name": "PLACEHOLDER_033", + "star_type": "F", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-034", + "system_name": "PLACEHOLDER_034", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-035", + "system_name": "PLACEHOLDER_035", + "star_type": "K", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_1", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 9 + }, + { + "system_id": "S-036", + "system_name": "PLACEHOLDER_036", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-037", + "system_name": "PLACEHOLDER_037", + "star_type": "M", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_1", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "immediate", + "hop_distance_from_gateway": 2 + }, + { + "system_id": "S-038", + "system_name": "PLACEHOLDER_038", + "star_type": "K", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "distant", + "hop_distance_from_gateway": 11 + }, + { + "system_id": "S-039", + "system_name": "PLACEHOLDER_039", + "star_type": "F", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-040", + "system_name": "PLACEHOLDER_040", + "star_type": "K", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-041", + "system_name": "PLACEHOLDER_041", + "star_type": "K", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-042", + "system_name": "PLACEHOLDER_042", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "unsettled", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-043", + "system_name": "PLACEHOLDER_043", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_5", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 9 + }, + { + "system_id": "S-044", + "system_name": "PLACEHOLDER_044", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-045", + "system_name": "PLACEHOLDER_045", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-046", + "system_name": "PLACEHOLDER_046", + "star_type": "binary", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-047", + "system_name": "PLACEHOLDER_047", + "star_type": "M", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_4", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "immediate", + "hop_distance_from_gateway": 2 + }, + { + "system_id": "S-048", + "system_name": "PLACEHOLDER_048", + "star_type": "K", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-049", + "system_name": "PLACEHOLDER_049", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 11 + }, + { + "system_id": "S-050", + "system_name": "PLACEHOLDER_050", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-051", + "system_name": "PLACEHOLDER_051", + "star_type": "K", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_4", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-052", + "system_name": "PLACEHOLDER_052", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_4", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-053", + "system_name": "PLACEHOLDER_053", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_4", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-054", + "system_name": "PLACEHOLDER_054", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_4", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-055", + "system_name": "PLACEHOLDER_055", + "star_type": "K", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-056", + "system_name": "PLACEHOLDER_056", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-057", + "system_name": "PLACEHOLDER_057", + "star_type": "G", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-058", + "system_name": "PLACEHOLDER_058", + "star_type": "F", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-059", + "system_name": "PLACEHOLDER_059", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-060", + "system_name": "PLACEHOLDER_060", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-061", + "system_name": "PLACEHOLDER_061", + "star_type": "unusual", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_3", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-062", + "system_name": "PLACEHOLDER_062", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-063", + "system_name": "PLACEHOLDER_063", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "political_zone": "deep_reach_isolate", + "settlement_wave": "unsettled", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "distant", + "hop_distance_from_gateway": 13 + }, + { + "system_id": "S-064", + "system_name": "PLACEHOLDER_064", + "star_type": "G", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-065", + "system_name": "PLACEHOLDER_065", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-066", + "system_name": "PLACEHOLDER_066", + "star_type": "K", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_5", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-067", + "system_name": "PLACEHOLDER_067", + "star_type": "G", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_1", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "earth_proximity": "immediate", + "hop_distance_from_gateway": 1 + }, + { + "system_id": "S-068", + "system_name": "PLACEHOLDER_068", + "star_type": "binary", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-069", + "system_name": "PLACEHOLDER_069", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 11 + }, + { + "system_id": "S-070", + "system_name": "PLACEHOLDER_070", + "star_type": "K", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-071", + "system_name": "PLACEHOLDER_071", + "star_type": "G", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_2", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-072", + "system_name": "PLACEHOLDER_072", + "star_type": "G", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-073", + "system_name": "PLACEHOLDER_073", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-074", + "system_name": "PLACEHOLDER_074", + "star_type": "K", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_1", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-075", + "system_name": "PLACEHOLDER_075", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_5", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-076", + "system_name": "PLACEHOLDER_076", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "unsettled", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 9 + }, + { + "system_id": "S-077", + "system_name": "PLACEHOLDER_077", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_5", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-078", + "system_name": "PLACEHOLDER_078", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 10 + }, + { + "system_id": "S-079", + "system_name": "PLACEHOLDER_079", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-080", + "system_name": "PLACEHOLDER_080", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-081", + "system_name": "PLACEHOLDER_081", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-082", + "system_name": "PLACEHOLDER_082", + "star_type": "K", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_2", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-083", + "system_name": "PLACEHOLDER_083", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "unsettled", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 12 + }, + { + "system_id": "S-084", + "system_name": "PLACEHOLDER_084", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 15 + }, + { + "system_id": "S-085", + "system_name": "PLACEHOLDER_085", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-086", + "system_name": "PLACEHOLDER_086", + "star_type": "G", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_2", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-087", + "system_name": "PLACEHOLDER_087", + "star_type": "K", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-088", + "system_name": "PLACEHOLDER_088", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 10 + }, + { + "system_id": "S-089", + "system_name": "PLACEHOLDER_089", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 10 + }, + { + "system_id": "S-090", + "system_name": "PLACEHOLDER_090", + "star_type": "binary", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "institutional_core", + "settlement_wave": "wave_1", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-091", + "system_name": "PLACEHOLDER_091", + "star_type": "M", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_5", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "immediate", + "hop_distance_from_gateway": 1 + }, + { + "system_id": "S-092", + "system_name": "PLACEHOLDER_092", + "star_type": "K", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-093", + "system_name": "PLACEHOLDER_093", + "star_type": "M", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_1", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "earth_proximity": "immediate", + "hop_distance_from_gateway": 2 + }, + { + "system_id": "S-094", + "system_name": "PLACEHOLDER_094", + "star_type": "K", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-095", + "system_name": "PLACEHOLDER_095", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-096", + "system_name": "PLACEHOLDER_096", + "star_type": "G", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-097", + "system_name": "PLACEHOLDER_097", + "star_type": "G", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_5", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-098", + "system_name": "PLACEHOLDER_098", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_5", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-099", + "system_name": "PLACEHOLDER_099", + "star_type": "binary", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_2", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 9 + }, + { + "system_id": "S-100", + "system_name": "PLACEHOLDER_100", + "star_type": "K", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-101", + "system_name": "PLACEHOLDER_101", + "star_type": "F", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_5", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-102", + "system_name": "PLACEHOLDER_102", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_4", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-103", + "system_name": "PLACEHOLDER_103", + "star_type": "K", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "unsettled", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-104", + "system_name": "PLACEHOLDER_104", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-105", + "system_name": "PLACEHOLDER_105", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 9 + }, + { + "system_id": "S-106", + "system_name": "PLACEHOLDER_106", + "star_type": "K", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_4", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-107", + "system_name": "PLACEHOLDER_107", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_2", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-108", + "system_name": "PLACEHOLDER_108", + "star_type": "G", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "unsettled", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-109", + "system_name": "PLACEHOLDER_109", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-110", + "system_name": "PLACEHOLDER_110", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_4", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-111", + "system_name": "PLACEHOLDER_111", + "star_type": "M", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_1", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-112", + "system_name": "PLACEHOLDER_112", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-113", + "system_name": "PLACEHOLDER_113", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "unsettled", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-114", + "system_name": "PLACEHOLDER_114", + "star_type": "K", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_5", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "immediate", + "hop_distance_from_gateway": 2 + }, + { + "system_id": "S-115", + "system_name": "PLACEHOLDER_115", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "unsettled", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 17 + }, + { + "system_id": "S-116", + "system_name": "PLACEHOLDER_116", + "star_type": "binary", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 11 + }, + { + "system_id": "S-117", + "system_name": "PLACEHOLDER_117", + "star_type": "G", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "unsettled", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 14 + }, + { + "system_id": "S-118", + "system_name": "PLACEHOLDER_118", + "star_type": "binary", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_1", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-119", + "system_name": "PLACEHOLDER_119", + "star_type": "K", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "immediate", + "hop_distance_from_gateway": 2 + }, + { + "system_id": "S-120", + "system_name": "PLACEHOLDER_120", + "star_type": "K", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_1", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "earth_proximity": "immediate", + "hop_distance_from_gateway": 2 + }, + { + "system_id": "S-121", + "system_name": "PLACEHOLDER_121", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-122", + "system_name": "PLACEHOLDER_122", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 13 + }, + { + "system_id": "S-123", + "system_name": "PLACEHOLDER_123", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "unsettled", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-124", + "system_name": "PLACEHOLDER_124", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 11 + }, + { + "system_id": "S-125", + "system_name": "PLACEHOLDER_125", + "star_type": "G", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "unsettled", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-126", + "system_name": "PLACEHOLDER_126", + "star_type": "F", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_5", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-127", + "system_name": "PLACEHOLDER_127", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-128", + "system_name": "PLACEHOLDER_128", + "star_type": "K", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-129", + "system_name": "PLACEHOLDER_129", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_5", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-130", + "system_name": "PLACEHOLDER_130", + "star_type": "G", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "unsettled", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 17 + }, + { + "system_id": "S-131", + "system_name": "PLACEHOLDER_131", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_3", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-132", + "system_name": "PLACEHOLDER_132", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_4", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-133", + "system_name": "PLACEHOLDER_133", + "star_type": "binary", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "unsettled", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-134", + "system_name": "PLACEHOLDER_134", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-135", + "system_name": "PLACEHOLDER_135", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-136", + "system_name": "PLACEHOLDER_136", + "star_type": "G", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-137", + "system_name": "PLACEHOLDER_137", + "star_type": "G", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-138", + "system_name": "PLACEHOLDER_138", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "unsettled", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 9 + }, + { + "system_id": "S-139", + "system_name": "PLACEHOLDER_139", + "star_type": "G", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_1", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "earth_proximity": "immediate", + "hop_distance_from_gateway": 2 + }, + { + "system_id": "S-140", + "system_name": "PLACEHOLDER_140", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-141", + "system_name": "PLACEHOLDER_141", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_5", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 12 + }, + { + "system_id": "S-142", + "system_name": "PLACEHOLDER_142", + "star_type": "K", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-143", + "system_name": "PLACEHOLDER_143", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 9 + }, + { + "system_id": "S-144", + "system_name": "PLACEHOLDER_144", + "star_type": "unusual", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_4", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-145", + "system_name": "PLACEHOLDER_145", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "political_zone": "deep_reach_isolate", + "settlement_wave": "unsettled", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-146", + "system_name": "PLACEHOLDER_146", + "star_type": "binary", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 9 + }, + { + "system_id": "S-147", + "system_name": "PLACEHOLDER_147", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 11 + }, + { + "system_id": "S-148", + "system_name": "PLACEHOLDER_148", + "star_type": "K", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-149", + "system_name": "PLACEHOLDER_149", + "star_type": "binary", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_5", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-150", + "system_name": "PLACEHOLDER_150", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_5", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 18 + }, + { + "system_id": "S-151", + "system_name": "PLACEHOLDER_151", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-152", + "system_name": "PLACEHOLDER_152", + "star_type": "binary", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-153", + "system_name": "PLACEHOLDER_153", + "star_type": "K", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_2", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-154", + "system_name": "PLACEHOLDER_154", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-155", + "system_name": "PLACEHOLDER_155", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 10 + }, + { + "system_id": "S-156", + "system_name": "PLACEHOLDER_156", + "star_type": "G", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-157", + "system_name": "PLACEHOLDER_157", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-158", + "system_name": "PLACEHOLDER_158", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 15 + }, + { + "system_id": "S-159", + "system_name": "PLACEHOLDER_159", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_2", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 22 + }, + { + "system_id": "S-160", + "system_name": "PLACEHOLDER_160", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-161", + "system_name": "PLACEHOLDER_161", + "star_type": "K", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_5", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-162", + "system_name": "PLACEHOLDER_162", + "star_type": "K", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-163", + "system_name": "PLACEHOLDER_163", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_2", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 10 + }, + { + "system_id": "S-164", + "system_name": "PLACEHOLDER_164", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "unsettled", + "gate_topology": "hub", + "aperture_count": 7, + "gate_connections": 7, + "earth_proximity": "immediate", + "hop_distance_from_gateway": 2 + }, + { + "system_id": "S-165", + "system_name": "PLACEHOLDER_165", + "star_type": "K", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_3", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-166", + "system_name": "PLACEHOLDER_166", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_2", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-167", + "system_name": "PLACEHOLDER_167", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 14 + }, + { + "system_id": "S-168", + "system_name": "PLACEHOLDER_168", + "star_type": "M", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "immediate", + "hop_distance_from_gateway": 2 + }, + { + "system_id": "S-169", + "system_name": "PLACEHOLDER_169", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-170", + "system_name": "PLACEHOLDER_170", + "star_type": "G", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-171", + "system_name": "PLACEHOLDER_171", + "star_type": "K", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-172", + "system_name": "PLACEHOLDER_172", + "star_type": "F", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-173", + "system_name": "PLACEHOLDER_173", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_5", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-174", + "system_name": "PLACEHOLDER_174", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-175", + "system_name": "PLACEHOLDER_175", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-176", + "system_name": "PLACEHOLDER_176", + "star_type": "G", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "distant", + "hop_distance_from_gateway": 12 + }, + { + "system_id": "S-177", + "system_name": "PLACEHOLDER_177", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 9 + }, + { + "system_id": "S-178", + "system_name": "PLACEHOLDER_178", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_3", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-179", + "system_name": "PLACEHOLDER_179", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_5", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-180", + "system_name": "PLACEHOLDER_180", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_3", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-181", + "system_name": "PLACEHOLDER_181", + "star_type": "K", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_1", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-182", + "system_name": "PLACEHOLDER_182", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_1", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-183", + "system_name": "PLACEHOLDER_183", + "star_type": "K", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-184", + "system_name": "PLACEHOLDER_184", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-185", + "system_name": "PLACEHOLDER_185", + "star_type": "binary", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_3", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-186", + "system_name": "PLACEHOLDER_186", + "star_type": "K", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-187", + "system_name": "PLACEHOLDER_187", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-188", + "system_name": "PLACEHOLDER_188", + "star_type": "K", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-189", + "system_name": "PLACEHOLDER_189", + "star_type": "K", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_2", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-190", + "system_name": "PLACEHOLDER_190", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 16 + }, + { + "system_id": "S-191", + "system_name": "PLACEHOLDER_191", + "star_type": "F", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-192", + "system_name": "PLACEHOLDER_192", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-193", + "system_name": "PLACEHOLDER_193", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-194", + "system_name": "PLACEHOLDER_194", + "star_type": "G", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 11 + }, + { + "system_id": "S-195", + "system_name": "PLACEHOLDER_195", + "star_type": "K", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "unsettled", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-196", + "system_name": "PLACEHOLDER_196", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-197", + "system_name": "PLACEHOLDER_197", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "unsettled", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 11 + }, + { + "system_id": "S-198", + "system_name": "PLACEHOLDER_198", + "star_type": "K", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_3", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-199", + "system_name": "PLACEHOLDER_199", + "star_type": "G", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_2", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-200", + "system_name": "PLACEHOLDER_200", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_3", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-201", + "system_name": "PLACEHOLDER_201", + "star_type": "G", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_3", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-202", + "system_name": "PLACEHOLDER_202", + "star_type": "F", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-203", + "system_name": "PLACEHOLDER_203", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_1", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-204", + "system_name": "PLACEHOLDER_204", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-205", + "system_name": "PLACEHOLDER_205", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "distant", + "hop_distance_from_gateway": 23 + }, + { + "system_id": "S-206", + "system_name": "PLACEHOLDER_206", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_5", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "distant", + "hop_distance_from_gateway": 12 + }, + { + "system_id": "S-207", + "system_name": "PLACEHOLDER_207", + "star_type": "K", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_3", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-208", + "system_name": "PLACEHOLDER_208", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 10 + }, + { + "system_id": "S-209", + "system_name": "PLACEHOLDER_209", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_3", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-210", + "system_name": "PLACEHOLDER_210", + "star_type": "F", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_5", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-211", + "system_name": "PLACEHOLDER_211", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_5", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-212", + "system_name": "PLACEHOLDER_212", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "hub", + "aperture_count": 6, + "gate_connections": 6, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-213", + "system_name": "PLACEHOLDER_213", + "star_type": "K", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "immediate", + "hop_distance_from_gateway": 2 + }, + { + "system_id": "S-214", + "system_name": "PLACEHOLDER_214", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 10 + }, + { + "system_id": "S-215", + "system_name": "PLACEHOLDER_215", + "star_type": "binary", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 19 + }, + { + "system_id": "S-216", + "system_name": "PLACEHOLDER_216", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-217", + "system_name": "PLACEHOLDER_217", + "star_type": "K", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-218", + "system_name": "PLACEHOLDER_218", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 16 + }, + { + "system_id": "S-219", + "system_name": "PLACEHOLDER_219", + "star_type": "F", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_5", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-220", + "system_name": "PLACEHOLDER_220", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-221", + "system_name": "PLACEHOLDER_221", + "star_type": "F", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 13 + }, + { + "system_id": "S-222", + "system_name": "PLACEHOLDER_222", + "star_type": "K", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_2", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-223", + "system_name": "PLACEHOLDER_223", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_5", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-224", + "system_name": "PLACEHOLDER_224", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_5", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-225", + "system_name": "PLACEHOLDER_225", + "star_type": "K", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "hub", + "aperture_count": 6, + "gate_connections": 6, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-226", + "system_name": "PLACEHOLDER_226", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 10 + }, + { + "system_id": "S-227", + "system_name": "PLACEHOLDER_227", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-228", + "system_name": "PLACEHOLDER_228", + "star_type": "K", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_5", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-229", + "system_name": "PLACEHOLDER_229", + "star_type": "K", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-230", + "system_name": "PLACEHOLDER_230", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "unsettled", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 9 + }, + { + "system_id": "S-231", + "system_name": "PLACEHOLDER_231", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-232", + "system_name": "PLACEHOLDER_232", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_4", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-233", + "system_name": "PLACEHOLDER_233", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_2", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-234", + "system_name": "PLACEHOLDER_234", + "star_type": "K", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-235", + "system_name": "PLACEHOLDER_235", + "star_type": "M", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-236", + "system_name": "PLACEHOLDER_236", + "star_type": "K", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-237", + "system_name": "PLACEHOLDER_237", + "star_type": "F", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 9 + }, + { + "system_id": "S-238", + "system_name": "PLACEHOLDER_238", + "star_type": "binary", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "unsettled", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-239", + "system_name": "PLACEHOLDER_239", + "star_type": "unusual", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-240", + "system_name": "PLACEHOLDER_240", + "star_type": "binary", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-241", + "system_name": "PLACEHOLDER_241", + "star_type": "K", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_5", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-242", + "system_name": "PLACEHOLDER_242", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 14 + }, + { + "system_id": "S-243", + "system_name": "PLACEHOLDER_243", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-244", + "system_name": "PLACEHOLDER_244", + "star_type": "F", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-245", + "system_name": "PLACEHOLDER_245", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_4", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-246", + "system_name": "PLACEHOLDER_246", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_2", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 18 + }, + { + "system_id": "S-247", + "system_name": "PLACEHOLDER_247", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-248", + "system_name": "PLACEHOLDER_248", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-249", + "system_name": "PLACEHOLDER_249", + "star_type": "K", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-250", + "system_name": "PLACEHOLDER_250", + "star_type": "K", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-251", + "system_name": "PLACEHOLDER_251", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-252", + "system_name": "PLACEHOLDER_252", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "distant", + "hop_distance_from_gateway": 19 + }, + { + "system_id": "S-253", + "system_name": "PLACEHOLDER_253", + "star_type": "M", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_1", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-254", + "system_name": "PLACEHOLDER_254", + "star_type": "binary", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-255", + "system_name": "PLACEHOLDER_255", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-256", + "system_name": "PLACEHOLDER_256", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-257", + "system_name": "PLACEHOLDER_257", + "star_type": "G", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-258", + "system_name": "PLACEHOLDER_258", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 9 + }, + { + "system_id": "S-259", + "system_name": "PLACEHOLDER_259", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "unsettled", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-260", + "system_name": "PLACEHOLDER_260", + "star_type": "binary", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "distant", + "hop_distance_from_gateway": 17 + }, + { + "system_id": "S-261", + "system_name": "PLACEHOLDER_261", + "star_type": "K", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_5", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-262", + "system_name": "PLACEHOLDER_262", + "star_type": "G", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_5", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 21 + }, + { + "system_id": "S-263", + "system_name": "PLACEHOLDER_263", + "star_type": "K", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_1", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-264", + "system_name": "PLACEHOLDER_264", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-265", + "system_name": "PLACEHOLDER_265", + "star_type": "K", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "immediate", + "hop_distance_from_gateway": 2 + }, + { + "system_id": "S-266", + "system_name": "PLACEHOLDER_266", + "star_type": "F", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "contested_frontier", + "settlement_wave": "wave_3", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-267", + "system_name": "PLACEHOLDER_267", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_5", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-268", + "system_name": "PLACEHOLDER_268", + "star_type": "G", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_2", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-269", + "system_name": "PLACEHOLDER_269", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_4", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-270", + "system_name": "PLACEHOLDER_270", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_5", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-271", + "system_name": "PLACEHOLDER_271", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-272", + "system_name": "PLACEHOLDER_272", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "unsettled", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-273", + "system_name": "PLACEHOLDER_273", + "star_type": "G", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-274", + "system_name": "PLACEHOLDER_274", + "star_type": "F", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-275", + "system_name": "PLACEHOLDER_275", + "star_type": "K", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-276", + "system_name": "PLACEHOLDER_276", + "star_type": "F", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-277", + "system_name": "PLACEHOLDER_277", + "star_type": "unusual", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-278", + "system_name": "PLACEHOLDER_278", + "star_type": "K", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-279", + "system_name": "PLACEHOLDER_279", + "star_type": "binary", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 3 + }, + { + "system_id": "S-280", + "system_name": "PLACEHOLDER_280", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 10 + }, + { + "system_id": "S-281", + "system_name": "PLACEHOLDER_281", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-282", + "system_name": "PLACEHOLDER_282", + "star_type": "M", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "unsettled", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 4 + }, + { + "system_id": "S-283", + "system_name": "PLACEHOLDER_283", + "star_type": "G", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_5", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-284", + "system_name": "PLACEHOLDER_284", + "star_type": "F", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 14 + }, + { + "system_id": "S-285", + "system_name": "PLACEHOLDER_285", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_2", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-286", + "system_name": "PLACEHOLDER_286", + "star_type": "M", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 13 + }, + { + "system_id": "S-287", + "system_name": "PLACEHOLDER_287", + "star_type": "M", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-288", + "system_name": "PLACEHOLDER_288", + "star_type": "K", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 6 + }, + { + "system_id": "S-289", + "system_name": "PLACEHOLDER_289", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_3", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 7 + }, + { + "system_id": "S-290", + "system_name": "PLACEHOLDER_290", + "star_type": "K", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 8 + }, + { + "system_id": "S-291", + "system_name": "PLACEHOLDER_291", + "star_type": "K", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 11 + }, + { + "system_id": "S-292", + "system_name": "PLACEHOLDER_292", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 12 + }, + { + "system_id": "S-293", + "system_name": "PLACEHOLDER_293", + "star_type": "G", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_5", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 16 + }, + { + "system_id": "S-294", + "system_name": "PLACEHOLDER_294", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "research_periphery", + "settlement_wave": "wave_3", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-295", + "system_name": "PLACEHOLDER_295", + "star_type": "G", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_2", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 15 + }, + { + "system_id": "S-296", + "system_name": "PLACEHOLDER_296", + "star_type": "G", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "contested_frontier", + "settlement_wave": "wave_3", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 12 + }, + { + "system_id": "S-297", + "system_name": "PLACEHOLDER_297", + "star_type": "G", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "institutional_core", + "settlement_wave": "wave_1", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "earth_proximity": "immediate", + "hop_distance_from_gateway": 2 + }, + { + "system_id": "S-298", + "system_name": "PLACEHOLDER_298", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "institutional_core", + "settlement_wave": "wave_2", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + }, + { + "system_id": "S-299", + "system_name": "PLACEHOLDER_299", + "star_type": "M", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "political_zone": "deep_reach_isolate", + "settlement_wave": "wave_4", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "earth_proximity": "distant", + "hop_distance_from_gateway": 20 + }, + { + "system_id": "S-300", + "system_name": "PLACEHOLDER_300", + "star_type": "M", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "political_zone": "commercial_mid_reach", + "settlement_wave": "wave_1", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "earth_proximity": "proximate", + "hop_distance_from_gateway": 5 + } + ], + "edges": [ + [ + "S-001", + "S-067" + ], + [ + "S-001", + "S-010" + ], + [ + "S-263", + "S-044" + ], + [ + "S-044", + "S-186" + ], + [ + "S-186", + "S-128" + ], + [ + "S-128", + "S-217" + ], + [ + "S-217", + "S-269" + ], + [ + "S-269", + "S-179" + ], + [ + "S-179", + "S-267" + ], + [ + "S-267", + "S-245" + ], + [ + "S-245", + "S-189" + ], + [ + "S-111", + "S-279" + ], + [ + "S-189", + "S-261" + ], + [ + "S-261", + "S-003" + ], + [ + "S-003", + "S-018" + ], + [ + "S-186", + "S-222" + ], + [ + "S-018", + "S-161" + ], + [ + "S-161", + "S-144" + ], + [ + "S-144", + "S-053" + ], + [ + "S-257", + "S-182" + ], + [ + "S-182", + "S-086" + ], + [ + "S-053", + "S-170" + ], + [ + "S-227", + "S-278" + ], + [ + "S-225", + "S-157" + ], + [ + "S-157", + "S-036" + ], + [ + "S-034", + "S-052" + ], + [ + "S-052", + "S-223" + ], + [ + "S-157", + "S-232" + ], + [ + "S-107", + "S-258" + ], + [ + "S-030", + "S-140" + ], + [ + "S-258", + "S-226" + ], + [ + "S-226", + "S-124" + ], + [ + "S-172", + "S-019" + ], + [ + "S-019", + "S-088" + ], + [ + "S-088", + "S-116" + ], + [ + "S-139", + "S-181" + ], + [ + "S-116", + "S-083" + ], + [ + "S-124", + "S-296" + ], + [ + "S-083", + "S-286" + ], + [ + "S-242", + "S-221" + ], + [ + "S-221", + "S-141" + ], + [ + "S-286", + "S-167" + ], + [ + "S-141", + "S-147" + ], + [ + "S-147", + "S-280" + ], + [ + "S-280", + "S-043" + ], + [ + "S-043", + "S-224" + ], + [ + "S-167", + "S-295" + ], + [ + "S-224", + "S-096" + ], + [ + "S-295", + "S-293" + ], + [ + "S-283", + "S-011" + ], + [ + "S-020", + "S-007" + ], + [ + "S-126", + "S-220" + ], + [ + "S-255", + "S-271" + ], + [ + "S-134", + "S-290" + ], + [ + "S-271", + "S-042" + ], + [ + "S-290", + "S-146" + ], + [ + "S-177", + "S-208" + ], + [ + "S-146", + "S-214" + ], + [ + "S-214", + "S-105" + ], + [ + "S-042", + "S-145" + ], + [ + "S-208", + "S-194" + ], + [ + "S-194", + "S-069" + ], + [ + "S-051", + "S-068" + ], + [ + "S-068", + "S-207" + ], + [ + "S-107", + "S-012" + ], + [ + "S-105", + "S-079" + ], + [ + "S-069", + "S-163" + ], + [ + "S-163", + "S-143" + ], + [ + "S-012", + "S-089" + ], + [ + "S-207", + "S-005" + ], + [ + "S-079", + "S-272" + ], + [ + "S-143", + "S-154" + ], + [ + "S-005", + "S-236" + ], + [ + "S-250", + "S-289" + ], + [ + "S-234", + "S-064" + ], + [ + "S-064", + "S-059" + ], + [ + "S-059", + "S-048" + ], + [ + "S-048", + "S-131" + ], + [ + "S-085", + "S-229" + ], + [ + "S-131", + "S-244" + ], + [ + "S-244", + "S-060" + ], + [ + "S-089", + "S-049" + ], + [ + "S-060", + "S-149" + ], + [ + "S-054", + "S-199" + ], + [ + "S-199", + "S-294" + ], + [ + "S-274", + "S-151" + ], + [ + "S-066", + "S-211" + ], + [ + "S-211", + "S-076" + ], + [ + "S-049", + "S-292" + ], + [ + "S-076", + "S-193" + ], + [ + "S-056", + "S-026" + ], + [ + "S-096", + "S-101" + ], + [ + "S-193", + "S-166" + ], + [ + "S-284", + "S-158" + ], + [ + "S-154", + "S-184" + ], + [ + "S-071", + "S-108" + ], + [ + "S-292", + "S-122" + ], + [ + "S-122", + "S-117" + ], + [ + "S-184", + "S-210" + ], + [ + "S-166", + "S-175" + ], + [ + "S-158", + "S-190" + ], + [ + "S-190", + "S-115" + ], + [ + "S-115", + "S-150" + ], + [ + "S-108", + "S-039" + ], + [ + "S-175", + "S-160" + ], + [ + "S-210", + "S-256" + ], + [ + "S-039", + "S-228" + ], + [ + "S-150", + "S-215" + ], + [ + "S-215", + "S-299" + ], + [ + "S-228", + "S-298" + ], + [ + "S-117", + "S-084" + ], + [ + "S-021", + "S-121" + ], + [ + "S-160", + "S-275" + ], + [ + "S-275", + "S-077" + ], + [ + "S-084", + "S-218" + ], + [ + "S-299", + "S-262" + ], + [ + "S-256", + "S-173" + ], + [ + "S-262", + "S-159" + ], + [ + "S-121", + "S-062" + ], + [ + "S-218", + "S-130" + ], + [ + "S-101", + "S-138" + ], + [ + "S-151", + "S-035" + ], + [ + "S-062", + "S-109" + ], + [ + "S-232", + "S-264" + ], + [ + "S-138", + "S-016" + ], + [ + "S-059", + "S-281" + ], + [ + "S-173", + "S-129" + ], + [ + "S-272", + "S-233" + ], + [ + "S-109", + "S-125" + ], + [ + "S-048", + "S-300" + ], + [ + "S-237", + "S-092" + ], + [ + "S-185", + "S-095" + ], + [ + "S-035", + "S-155" + ], + [ + "S-137", + "S-230" + ], + [ + "S-233", + "S-180" + ], + [ + "S-125", + "S-248" + ], + [ + "S-130", + "S-246" + ], + [ + "S-164", + "S-106" + ], + [ + "S-106", + "S-040" + ], + [ + "S-135", + "S-110" + ], + [ + "S-050", + "S-046" + ], + [ + "S-230", + "S-078" + ], + [ + "S-293", + "S-260" + ], + [ + "S-135", + "S-259" + ], + [ + "S-106", + "S-127" + ], + [ + "S-092", + "S-202" + ], + [ + "S-046", + "S-239" + ], + [ + "S-259", + "S-025" + ], + [ + "S-156", + "S-288" + ], + [ + "S-288", + "S-113" + ], + [ + "S-239", + "S-183" + ], + [ + "S-183", + "S-196" + ], + [ + "S-087", + "S-209" + ], + [ + "S-127", + "S-198" + ], + [ + "S-300", + "S-098" + ], + [ + "S-113", + "S-103" + ], + [ + "S-209", + "S-282" + ], + [ + "S-282", + "S-090" + ], + [ + "S-155", + "S-038" + ], + [ + "S-029", + "S-200" + ], + [ + "S-090", + "S-072" + ], + [ + "S-198", + "S-188" + ], + [ + "S-188", + "S-112" + ], + [ + "S-078", + "S-197" + ], + [ + "S-200", + "S-212" + ], + [ + "S-112", + "S-201" + ], + [ + "S-072", + "S-133" + ], + [ + "S-082", + "S-219" + ], + [ + "S-133", + "S-057" + ], + [ + "S-201", + "S-015" + ], + [ + "S-103", + "S-008" + ], + [ + "S-015", + "S-097" + ], + [ + "S-249", + "S-187" + ], + [ + "S-197", + "S-206" + ], + [ + "S-097", + "S-171" + ], + [ + "S-183", + "S-251" + ], + [ + "S-277", + "S-041" + ], + [ + "S-251", + "S-075" + ], + [ + "S-006", + "S-268" + ], + [ + "S-041", + "S-013" + ], + [ + "S-013", + "S-055" + ], + [ + "S-055", + "S-174" + ], + [ + "S-268", + "S-132" + ], + [ + "S-174", + "S-061" + ], + [ + "S-061", + "S-178" + ], + [ + "S-171", + "S-273" + ], + [ + "S-212", + "S-169" + ], + [ + "S-169", + "S-192" + ], + [ + "S-171", + "S-266" + ], + [ + "S-024", + "S-017" + ], + [ + "S-132", + "S-028" + ], + [ + "S-192", + "S-162" + ], + [ + "S-017", + "S-065" + ], + [ + "S-178", + "S-165" + ], + [ + "S-028", + "S-204" + ], + [ + "S-282", + "S-247" + ], + [ + "S-028", + "S-243" + ], + [ + "S-102", + "S-094" + ], + [ + "S-243", + "S-080" + ], + [ + "S-152", + "S-191" + ], + [ + "S-057", + "S-238" + ], + [ + "S-191", + "S-009" + ], + [ + "S-061", + "S-153" + ], + [ + "S-241", + "S-032" + ], + [ + "S-204", + "S-099" + ], + [ + "S-276", + "S-231" + ], + [ + "S-231", + "S-240" + ], + [ + "S-009", + "S-033" + ], + [ + "S-165", + "S-123" + ], + [ + "S-080", + "S-081" + ], + [ + "S-081", + "S-022" + ], + [ + "S-033", + "S-104" + ], + [ + "S-099", + "S-023" + ], + [ + "S-023", + "S-291" + ], + [ + "S-022", + "S-058" + ], + [ + "S-246", + "S-252" + ], + [ + "S-203", + "S-287" + ], + [ + "S-153", + "S-002" + ], + [ + "S-013", + "S-100" + ], + [ + "S-238", + "S-045" + ], + [ + "S-094", + "S-142" + ], + [ + "S-032", + "S-070" + ], + [ + "S-102", + "S-148" + ], + [ + "S-291", + "S-176" + ], + [ + "S-113", + "S-136" + ], + [ + "S-162", + "S-270" + ], + [ + "S-159", + "S-205" + ], + [ + "S-195", + "S-227" + ], + [ + "S-195", + "S-254" + ], + [ + "S-195", + "S-052" + ], + [ + "S-110", + "S-282" + ], + [ + "S-110", + "S-061" + ], + [ + "S-110", + "S-187" + ], + [ + "S-110", + "S-200" + ], + [ + "S-168", + "S-253" + ], + [ + "S-164", + "S-015" + ], + [ + "S-164", + "S-009" + ], + [ + "S-164", + "S-239" + ], + [ + "S-164", + "S-273" + ], + [ + "S-185", + "S-085" + ], + [ + "S-185", + "S-026" + ], + [ + "S-009", + "S-081" + ], + [ + "S-009", + "S-196" + ], + [ + "S-225", + "S-052" + ], + [ + "S-225", + "S-195" + ], + [ + "S-225", + "S-144" + ], + [ + "S-225", + "S-007" + ], + [ + "S-212", + "S-247" + ], + [ + "S-212", + "S-113" + ], + [ + "S-212", + "S-008" + ], + [ + "S-001", + "S-091" + ], + [ + "S-001", + "S-014" + ], + [ + "S-236", + "S-298" + ], + [ + "S-236", + "S-054" + ], + [ + "S-236", + "S-300" + ], + [ + "S-236", + "S-294" + ], + [ + "S-263", + "S-235" + ], + [ + "S-051", + "S-199" + ], + [ + "S-127", + "S-075" + ], + [ + "S-219", + "S-209" + ], + [ + "S-040", + "S-183" + ], + [ + "S-202", + "S-165" + ], + [ + "S-259", + "S-212" + ], + [ + "S-251", + "S-203" + ], + [ + "S-174", + "S-135" + ], + [ + "S-289", + "S-064" + ], + [ + "S-228", + "S-054" + ], + [ + "S-091", + "S-114" + ], + [ + "S-213", + "S-067" + ], + [ + "S-139", + "S-047" + ], + [ + "S-253", + "S-027" + ], + [ + "S-235", + "S-111" + ], + [ + "S-297", + "S-014" + ], + [ + "S-118", + "S-093" + ], + [ + "S-120", + "S-074" + ], + [ + "S-010", + "S-164" + ], + [ + "S-119", + "S-266" + ], + [ + "S-120", + "S-090" + ], + [ + "S-027", + "S-008" + ], + [ + "S-265", + "S-077" + ], + [ + "S-047", + "S-054" + ], + [ + "S-080", + "S-011" + ], + [ + "S-225", + "S-220" + ], + [ + "S-039", + "S-073" + ], + [ + "S-239", + "S-161" + ], + [ + "S-254", + "S-288" + ], + [ + "S-235", + "S-067" + ], + [ + "S-235", + "S-093" + ], + [ + "S-235", + "S-010" + ], + [ + "S-067", + "S-213" + ], + [ + "S-067", + "S-181" + ], + [ + "S-067", + "S-120" + ], + [ + "S-263", + "S-253" + ], + [ + "S-263", + "S-139" + ], + [ + "S-091", + "S-111" + ], + [ + "S-253", + "S-118" + ], + [ + "S-139", + "S-168" + ], + [ + "S-027", + "S-047" + ], + [ + "S-093", + "S-037" + ], + [ + "S-120", + "S-119" + ], + [ + "S-074", + "S-027" + ], + [ + "S-114", + "S-213" + ], + [ + "S-111", + "S-168" + ], + [ + "S-008", + "S-025" + ], + [ + "S-027", + "S-093" + ], + [ + "S-052", + "S-030" + ], + [ + "S-061", + "S-013" + ], + [ + "S-111", + "S-014" + ], + [ + "S-113", + "S-025" + ], + [ + "S-120", + "S-010" + ], + [ + "S-135", + "S-013" + ], + [ + "S-139", + "S-010" + ], + [ + "S-129", + "S-164" + ], + [ + "S-016", + "S-285" + ], + [ + "S-137", + "S-285" + ], + [ + "S-242", + "S-063" + ], + [ + "S-284", + "S-063" + ], + [ + "S-296", + "S-063" + ], + [ + "S-180", + "S-135" + ], + [ + "S-145", + "S-051" + ], + [ + "S-004", + "S-285" + ], + [ + "S-066", + "S-185" + ], + [ + "S-004", + "S-247" + ], + [ + "S-021", + "S-298" + ], + [ + "S-024", + "S-273" + ], + [ + "S-031", + "S-102" + ], + [ + "S-031", + "S-187" + ], + [ + "S-172", + "S-232" + ], + [ + "S-216", + "S-085" + ], + [ + "S-216", + "S-289" + ], + [ + "S-241", + "S-266" + ], + [ + "S-257", + "S-254" + ], + [ + "S-274", + "S-007" + ], + [ + "S-276", + "S-075" + ] + ] +} \ No newline at end of file diff --git a/tooling/generate-star-map.py b/tooling/generate-star-map.py new file mode 100644 index 000000000..bc8d7176b --- /dev/null +++ b/tooling/generate-star-map.py @@ -0,0 +1,1588 @@ +#!/usr/bin/env python3 +""" +Star Map Generator — The Settled Reach +Reads tooling/star-map-seed.json, runs the 6-phase generation algorithm, +outputs docs/design/star-map.json and 7 d2 files in docs/diagrams/design/. + +Usage: + python3 tooling/generate-star-map.py [--seed-file tooling/star-map-seed.json] + +Standard library only. No external dependencies. +""" + +from __future__ import annotations + +import json +import math +import random +import sys +import os +import argparse +from collections import defaultdict, deque +from pathlib import Path +from typing import Optional + +# ── Path resolution ──────────────────────────────────────────────────────────── + +SCRIPT_DIR = Path(__file__).parent +REPO_ROOT = SCRIPT_DIR.parent +DEFAULT_SEED_FILE = SCRIPT_DIR / "star-map-seed.json" +OUTPUT_JSON = REPO_ROOT / "docs" / "design" / "star-map.json" +OUTPUT_D2_DIR = REPO_ROOT / "docs" / "diagrams" / "design" + +SECTORS = [ + "core", + "north_reach", + "west_reach", + "south_reach", + "east_reach", + "deep_frontier", +] + +SECTOR_LABELS = { + "core": "Core", + "north_reach": "North Reach", + "west_reach": "West Reach", + "south_reach": "South Reach", + "east_reach": "East Reach", + "deep_frontier": "Deep Frontier", +} + +TOPOLOGY_VALUES = [ + "dead_end", + "spur_end", + "through_route", + "loop_member", + "junction", + "hub", +] + +WAVE_VALUES = [ + "wave_1", + "wave_2", + "wave_3", + "wave_4", + "wave_5", + "unsettled", +] + +# ── D2 visual constants ──────────────────────────────────────────────────────── + +D2_BG = "#1a1e24" +D2_TXT = "#c8d0e0" +D2_ACC = "#c8d8f0" + +# Node fill color by settlement wave +WAVE_FILL = { + "wave_1": "#1a2a50", + "wave_2": "#2e2800", + "wave_3": "#162a1a", + "wave_4": "#2e1400", + "wave_5": "#2e0a0a", + "unsettled": "#1a1e24", +} + +# Node stroke color by settlement wave +WAVE_STROKE = { + "wave_1": "#3060c0", + "wave_2": "#b8a020", + "wave_3": "#3a8a50", + "wave_4": "#c86010", + "wave_5": "#c02020", + "unsettled": "#4a5060", +} + +# Cross-sector stub: dimmed +STUB_FILL = "#111418" +STUB_STROKE = "#3a4050" + +# Gateway special colors +GATEWAY_FILL = "#1a2850" +GATEWAY_STROKE = "#5090e0" + +# Edge color defaults +EDGE_COLOR_INTRA = "#4a5a70" +EDGE_COLOR_CROSS = "#6a7a40" +EDGE_COLOR_GATEWAY = "#5090e0" + + +def d2_node_shape(topology: str) -> str: + """Return d2 shape name for topology type.""" + if topology == "hub": + return "hexagon" + elif topology == "junction": + return "diamond" + elif topology in ("dead_end", "spur_end"): + return "rectangle" + else: # loop_member, through_route + return "oval" + + +# ── Weighted random choice ───────────────────────────────────────────────────── + +def weighted_choice(rng: random.Random, options: dict) -> str: + """Choose from a dict of {value: weight} using the given rng.""" + keys = list(options.keys()) + weights = [options[k] for k in keys] + total = sum(weights) + r = rng.random() * total + cumulative = 0.0 + for k, w in zip(keys, weights): + cumulative += w + if r <= cumulative: + return k + return keys[-1] + + +# ── Phase 1: System placement ────────────────────────────────────────────────── + +def phase1_place_systems(seed_cfg: dict, rng: random.Random) -> list[dict]: + """ + Create all system nodes with sector, band, wave, star_type assignments. + Returns list of node dicts. Gateway is placed first as S-001. + """ + nodes = [] + system_count = seed_cfg["system_count"] + sector_dist = seed_cfg["sector_distribution"] + wave_by_sector = seed_cfg["settlement_wave_by_sector"] + band_by_sector = seed_cfg["geographic_band_by_sector"] + star_dist = seed_cfg["star_type_distribution"] + gateway_cfg = seed_cfg["gateway"] + + # Build the Gateway node first + gateway_node = { + "system_id": "S-001", + "system_name": "PLACEHOLDER_GATEWAY", + "star_type": "G", + "geographic_sector": gateway_cfg["geographic_sector"], + "geographic_band": gateway_cfg["geographic_band"], + "political_zone": gateway_cfg["political_zone"], + "settlement_wave": gateway_cfg["settlement_wave"], + "gate_topology": gateway_cfg["gate_topology"], + "aperture_count": gateway_cfg["aperture_count"], + "gate_connections": 0, # will be set after edge building + "_gateway": True, + } + nodes.append(gateway_node) + + # Build remaining nodes by sector + # The Gateway occupies one core slot + adjusted_sector_dist = dict(sector_dist) + adjusted_sector_dist["core"] = max(0, sector_dist["core"] - 1) + + # Expand sector list with correct counts + sector_queue = [] + for sector, count in adjusted_sector_dist.items(): + sector_queue.extend([sector] * count) + + # Trim or pad to reach total count - 1 (Gateway already placed) + target_remaining = system_count - 1 + if len(sector_queue) < target_remaining: + # Pad with deep_frontier + sector_queue.extend(["deep_frontier"] * (target_remaining - len(sector_queue))) + elif len(sector_queue) > target_remaining: + # Trim from the end (deep_frontier was padded last) + sector_queue = sector_queue[:target_remaining] + + rng.shuffle(sector_queue) + + counter = 2 # S-001 is Gateway + for sector in sector_queue: + wave = weighted_choice(rng, wave_by_sector[sector]) + band = weighted_choice(rng, band_by_sector[sector]) + star_type = weighted_choice(rng, star_dist) + node = { + "system_id": f"S-{counter:03d}", + "system_name": f"PLACEHOLDER_{counter:03d}", + "star_type": star_type, + "geographic_sector": sector, + "geographic_band": band, + "political_zone": _assign_political_zone(sector, band, wave, rng), + "settlement_wave": wave, + "gate_topology": "dead_end", # default; overwritten in Phase 4 + "aperture_count": 1, # floor; overwritten in Phase 5 + "gate_connections": 0, # set after edges built + "_gateway": False, + } + nodes.append(node) + counter += 1 + + return nodes + + +def _assign_political_zone(sector: str, band: str, wave: str, rng: random.Random) -> str: + """ + Assign a plausible political zone based on sector/band/wave. + Rough heuristic — not setting-perfect but good enough for topology generation. + """ + if sector == "core": + return "institutional_core" + if sector == "deep_frontier": + return rng.choice(["contested_frontier", "deep_reach_isolate", "deep_reach_isolate"]) + if band == "inner": + if wave in ("wave_1", "wave_2"): + return rng.choice(["institutional_core", "commercial_mid_reach", "commercial_mid_reach"]) + elif wave in ("wave_3", "wave_4"): + return rng.choice(["commercial_mid_reach", "research_periphery", "contested_frontier"]) + else: + return rng.choice(["contested_frontier", "commercial_mid_reach"]) + else: # outer + if wave in ("wave_4", "wave_5", "unsettled"): + return rng.choice(["contested_frontier", "deep_reach_isolate"]) + else: + return rng.choice(["commercial_mid_reach", "research_periphery", "deep_reach_isolate"]) + + +# ── Graph helpers ────────────────────────────────────────────────────────────── + +def bfs_connected(adj: dict, start: str, allowed: set) -> set: + """BFS from start node. Returns set of reachable node IDs (only in allowed set).""" + visited = set() + queue = deque([start]) + while queue: + node = queue.popleft() + if node in visited: + continue + visited.add(node) + for neighbor in adj.get(node, []): + if neighbor not in visited and neighbor in allowed: + queue.append(neighbor) + return visited + + +def bfs_distances(adj: dict, start: str) -> dict: + """BFS from start. Returns dict of {node_id: hop_distance}.""" + distances = {start: 0} + queue = deque([start]) + while queue: + node = queue.popleft() + for neighbor in adj.get(node, []): + if neighbor not in distances: + distances[neighbor] = distances[node] + 1 + queue.append(neighbor) + return distances + + +def build_adjacency(edges: list[list]) -> dict: + """Build adjacency dict from edge list.""" + adj = defaultdict(list) + for a, b in edges: + adj[a].append(b) + adj[b].append(a) + return adj + + +def degree(adj: dict, node_id: str) -> int: + return len(adj.get(node_id, [])) + + +def edge_exists(edges_set: set, a: str, b: str) -> bool: + return (a, b) in edges_set or (b, a) in edges_set + + +def add_edge(edges: list, edges_set: set, adj: dict, a: str, b: str): + """Add edge if it doesn't already exist.""" + if a == b: + return + if edge_exists(edges_set, a, b): + return + edges.append([a, b]) + edges_set.add((a, b)) + adj[a].append(b) + adj[b].append(a) + + +# ── Phase 2: Spanning tree backbone ─────────────────────────────────────────── + +def phase2_spanning_tree( + nodes: list[dict], + seed_cfg: dict, + rng: random.Random, +) -> tuple[list[list], set, dict]: + """ + Build a spanning tree using a modified Prim's algorithm. + Returns (edges, edges_set, adj). + """ + weights = seed_cfg["augmentation_weights"] + cross_penalty = weights["cross_sector_weight_penalty"] + inner_inner_bonus = weights["inner_to_inner_weight_bonus"] + outer_inner_bonus = weights["outer_to_inner_weight_bonus"] + + node_map = {n["system_id"]: n for n in nodes} + all_ids = [n["system_id"] for n in nodes] + + edges = [] + edges_set = set() + adj = defaultdict(list) + + # Start from Gateway (S-001) + in_tree = {"S-001"} + not_in_tree = set(all_ids) - in_tree + + while not_in_tree: + best_a = None + best_b = None + best_weight = -999.0 + + # For efficiency, sample a candidate subset when the tree is large + tree_sample = list(in_tree) + if len(tree_sample) > 60: + tree_sample = rng.sample(tree_sample, 60) + + not_tree_sample = list(not_in_tree) + if len(not_tree_sample) > 60: + not_tree_sample = rng.sample(not_tree_sample, 60) + + for a_id in tree_sample: + a = node_map[a_id] + for b_id in not_tree_sample: + b = node_map[b_id] + w = _spanning_tree_weight(a, b, cross_penalty, inner_inner_bonus, outer_inner_bonus, rng, adj) + if w > best_weight: + best_weight = w + best_a = a_id + best_b = b_id + + if best_b is None: + # Fallback: pick any unconnected node and connect to nearest tree member + b_id = next(iter(not_in_tree)) + a_id = rng.choice(list(in_tree)) + best_a = a_id + best_b = b_id + + add_edge(edges, edges_set, adj, best_a, best_b) + in_tree.add(best_b) + not_in_tree.discard(best_b) + + return edges, edges_set, adj + + +def _spanning_tree_weight( + a: dict, + b: dict, + cross_penalty: float, + inner_inner_bonus: float, + outer_inner_bonus: float, + rng: random.Random, + adj: dict, +) -> float: + """ + Compute connection weight between two nodes for spanning tree. + Higher = more likely to connect. + + Key design goal: produce long chains, not star topologies. + We penalise high-degree tree nodes so the tree fans out as a + collection of paths rather than a hub-and-spoke web. + """ + w = rng.random() # base randomness + + # Penalize cross-sector connections (applied first, on positive base) + if a["geographic_sector"] != b["geographic_sector"]: + # Allow cross-sector but penalize, except core-to-adjacent (desired) + if a["geographic_sector"] == "core" or b["geographic_sector"] == "core": + w *= (1.0 - cross_penalty * 0.5) # lighter penalty for core connections + elif a["geographic_sector"] == "deep_frontier" or b["geographic_sector"] == "deep_frontier": + w *= (1.0 - cross_penalty * 0.8) # heavier penalty for frontier jumps + else: + w *= (1.0 - cross_penalty) + + # Bonus for inner-to-inner connections (spine of the network) + if a["geographic_band"] == "inner" and b["geographic_band"] == "inner": + w += inner_inner_bonus * rng.random() + + # Bonus for outer connecting to inner (inward-pulling) + if (a["geographic_band"] == "outer" and b["geographic_band"] == "inner") or \ + (a["geographic_band"] == "inner" and b["geographic_band"] == "outer"): + w += outer_inner_bonus * rng.random() + + # Bonus for same sector connections + if a["geographic_sector"] == b["geographic_sector"]: + w += 0.2 + + # Degree penalty on the in-tree node (a) applied last — discourages stars. + # A node already at degree 2 in the tree is less attractive as a parent; + # this pushes the tree toward chains rather than hub-and-spoke. + a_deg = len(adj.get(a["system_id"], [])) + if a_deg == 1: + w += 0.15 # slight bonus to extend existing chains + elif a_deg == 2: + w -= 0.25 # mild penalty — prefer not to triple-branch here + elif a_deg >= 3: + w -= 0.55 # heavy penalty — already a branching node + + return w + + +# ── Phase 3: Augmentation ────────────────────────────────────────────────────── + +def phase3_augment( + nodes: list[dict], + edges: list[list], + edges_set: set, + adj: dict, + seed_cfg: dict, + rng: random.Random, +) -> None: + """ + Run augmentation passes A-D in-place. + A: Hub formation + B: Loop formation + C: Spur extension (dead-ends — no action needed, they're already there) + D: Cross-sector bridges + """ + node_map = {n["system_id"]: n for n in nodes} + weights = seed_cfg["augmentation_weights"] + hub_targets = seed_cfg["hub_count_targets"] + cross_targets = seed_cfg["cross_sector_connection_targets"] + + hub_min = weights["hub_target_degree_min"] + hub_max = weights["hub_target_degree_max"] + junc_min = weights["junction_target_degree_min"] + junc_max = weights["junction_target_degree_max"] + + # Pass A: Hub formation + # Select hub candidate systems: one Gateway + a tightly controlled count per sector. + # hub_count_targets in the seed config are MAXIMUMS, not minimums — we use them + # as the exact count to avoid over-producing hubs. + hub_candidates = _select_hub_candidates(nodes, seed_cfg, rng) + + for hub_id in hub_candidates: + hub = node_map[hub_id] + # Gateway has exactly 4 active connections + if hub.get("_gateway"): + target_degree = 4 + else: + # Non-gateway hubs: target degree 5–6 (not the full hub_min/max range + # which goes up to 7, producing too many high-degree nodes) + target_degree = rng.randint(hub_min, min(hub_max, hub_min + 1)) + + sector_peers = [ + n["system_id"] for n in nodes + if n["system_id"] != hub_id and n["geographic_sector"] == hub["geographic_sector"] + ] + same_sector_inner = [ + n["system_id"] for n in nodes + if n["system_id"] != hub_id + and n["geographic_sector"] == hub["geographic_sector"] + and n["geographic_band"] in ("inner", "core") + ] + candidates = same_sector_inner if same_sector_inner else sector_peers + + _augment_node_degree(edges, edges_set, adj, hub_id, candidates, target_degree, rng) + + # Pass A continued: Junction formation — only degree-3 target to avoid + # inadvertently inflating future hub counts via cross-sector bridges. + junction_candidates = _select_junction_candidates(nodes, hub_candidates, seed_cfg, rng) + for junc_id in junction_candidates: + target_degree = junc_min # always target the minimum (3) to stay conservative + sector_peers = [ + n["system_id"] for n in nodes + if n["system_id"] != junc_id and n["geographic_sector"] == node_map[junc_id]["geographic_sector"] + ] + _augment_node_degree(edges, edges_set, adj, junc_id, sector_peers, target_degree, rng) + + # Pass B: Loop formation — increased target to push loop_member count up + loop_target = weights["loop_formation_target_count"] + loop_min_path = weights["loop_min_path_length"] + loop_max_path = weights["loop_max_path_length"] + loops_added = 0 + + # Try to form loops within sectors + for sector in SECTORS: + sector_ids = [n["system_id"] for n in nodes if n["geographic_sector"] == sector] + if len(sector_ids) < 4: + continue + sector_id_set = set(sector_ids) + attempts = 0 + while loops_added < loop_target and attempts < 300: + attempts += 1 + a_id = rng.choice(sector_ids) + # BFS to find nodes at desired path distance + dist = bfs_distances(adj, a_id) + candidates_for_loop = [ + nid for nid, d in dist.items() + if loop_min_path <= d <= loop_max_path + and nid in sector_id_set + and not edge_exists(edges_set, a_id, nid) + ] + if candidates_for_loop: + b_id = rng.choice(candidates_for_loop) + add_edge(edges, edges_set, adj, a_id, b_id) + loops_added += 1 + + # Pass C: Leaf reduction — aggressively reduce dead_end count by chaining + # leaf nodes to nearby non-leaf nodes (turning leaves into through_routes + # and spur_ends, and upgrading degree-2 chains). + _reduce_leaves(nodes, edges, edges_set, adj, seed_cfg, rng) + + # Pass D: Cross-sector bridges + # Ensure minimum cross-sector connections per the target config + _ensure_cross_sector_bridges(nodes, edges, edges_set, adj, cross_targets, rng) + + # Pass E: Enforce Gateway connection cap + # Gateway should have exactly gateway_cfg["gate_connections"] active edges. + # The spanning tree may have created more — remove excess by rerouting. + # We do this AFTER all other augmentation to not break the spanning tree. + gateway_cfg = seed_cfg["gateway"] + gateway_max = gateway_cfg["gate_connections"] # 4 + gw_id = "S-001" + gw_neighbors = list(adj.get(gw_id, [])) + if len(gw_neighbors) > gateway_max: + # Remove excess edges — keep the highest-degree neighbors (they're the most connected) + sorted_neighbors = sorted( + gw_neighbors, + key=lambda nid: degree(adj, nid), + reverse=True, + ) + to_keep = set(sorted_neighbors[:gateway_max]) + to_remove = [nid for nid in gw_neighbors if nid not in to_keep] + for remove_id in to_remove: + # Remove from edges list + edges[:] = [ + e for e in edges + if not (set(e) == {gw_id, remove_id}) + ] + # Remove from edges_set + edges_set.discard((gw_id, remove_id)) + edges_set.discard((remove_id, gw_id)) + # Update adj + if remove_id in adj[gw_id]: + adj[gw_id].remove(remove_id) + if gw_id in adj[remove_id]: + adj[remove_id].remove(gw_id) + # Reconnect the removed neighbor to a non-gateway core system if needed + # (to preserve connectivity) + core_systems = [ + n["system_id"] for n in nodes + if n["geographic_sector"] == "core" + and n["system_id"] != gw_id + and n["system_id"] != remove_id + ] + if core_systems: + reconnect_target = rng.choice(core_systems) + if not edge_exists(edges_set, remove_id, reconnect_target): + add_edge(edges, edges_set, adj, remove_id, reconnect_target) + + +def _augment_node_degree( + edges: list, + edges_set: set, + adj: dict, + node_id: str, + candidates: list, + target_degree: int, + rng: random.Random, +) -> None: + """ + Add edges from node_id to candidates until target_degree is reached. + Safe against fully-connected candidate lists (terminates when no new edge possible). + """ + if not candidates: + return + shuffled = list(candidates) + rng.shuffle(shuffled) + for cand_id in shuffled: + if degree(adj, node_id) >= target_degree: + break + if not edge_exists(edges_set, node_id, cand_id): + add_edge(edges, edges_set, adj, node_id, cand_id) + + +def _select_hub_candidates(nodes: list[dict], seed_cfg: dict, rng: random.Random) -> list[str]: + """ + Select systems to become hubs. One per sector minimum, plus Gateway. + """ + hub_targets = seed_cfg["hub_count_targets"] + candidates = ["S-001"] # Gateway is always a hub + + for sector, count in hub_targets.items(): + sector_nodes = [ + n["system_id"] for n in nodes + if n["geographic_sector"] == sector + and not n.get("_gateway") + and n["geographic_band"] in ("inner", "core") + ] + if not sector_nodes: + sector_nodes = [n["system_id"] for n in nodes if n["geographic_sector"] == sector] + selected = rng.sample(sector_nodes, min(count, len(sector_nodes))) + candidates.extend(selected) + + return list(set(candidates)) + + +def _select_junction_candidates( + nodes: list[dict], + hub_candidates: list[str], + seed_cfg: dict, + rng: random.Random, +) -> list[str]: + """ + Select junction candidates: inner-band non-hub systems. + + We select only half the topology target count here because: + - Pass C (leaf reduction) will naturally push many degree-2 nodes to + degree 3 as well, producing more junctions organically. + - Over-selecting here was one cause of too many hubs (junction nodes + at degree 3 get one more edge from cross-sector bridges → degree 4, + then classification bumps them to hub tier). + """ + hub_set = set(hub_candidates) + topology_targets = seed_cfg["topology_targets"] + total = seed_cfg["system_count"] + # Use ~40% of the junction target — the rest come from organic augmentation + junction_count = int(total * topology_targets["junction"] * 0.4) + + candidates = [ + n["system_id"] for n in nodes + if n["system_id"] not in hub_set + and n["geographic_band"] in ("inner", "core") + and n["geographic_sector"] != "deep_frontier" + ] + rng.shuffle(candidates) + return candidates[:junction_count] + + +def _reduce_leaves( + nodes: list[dict], + edges: list[list], + edges_set: set, + adj: dict, + seed_cfg: dict, + rng: random.Random, +) -> None: + """ + Pass C: Leaf reduction. + + A spanning tree of 300 nodes has ~150 leaves (degree-1 nodes). + Without intervention, these remain as dead_ends, which is far above + the 20% target. This pass reduces the leaf count to ~38% (about 114 + systems), leaving the sculpt pass to bring it to the final 20% target. + + CRITICAL design constraint: we NEVER connect leaf-to-leaf (which would + create a 2-node dangling chain whose edge is a bridge, keeping both as + degree-1 effective dead_ends, or worse — if both are in a larger component + a direct leaf-to-leaf edge always forms a new cycle via the existing tree + path, instantly creating loop_members). + + Instead we ONLY connect leaves to nearby degree-2 chain nodes: + - leaf gains degree 2 (becomes through_route or spur_end candidate) + - degree-2 target gains degree 3 (becomes junction) + This creates through_routes and junctions organically without cycles. + + Stopping at ~38% dead_ends gives the sculpt pass a graph that is + sparser-than-target (too many dead_ends, too few loops), which is + much easier to correct by adding edges than the reverse. + """ + total = len(nodes) + # Stop at ~38% dead_ends — well above the 20% target. + # Sculpt will reduce further by adding targeted edges. + target_leaf_count = int(total * 0.38) + + node_map = {n["system_id"]: n for n in nodes} + + def current_leaves(): + return [n["system_id"] for n in nodes if degree(adj, n["system_id"]) == 1] + + max_rounds = 20 + for _round in range(max_rounds): + leaves = current_leaves() + if len(leaves) <= target_leaf_count: + break + + # Shuffle for variety + rng.shuffle(leaves) + made_progress = False + + for leaf_id in leaves: + if len(current_leaves()) <= target_leaf_count: + break + + leaf = node_map[leaf_id] + + # BFS once per leaf + dist = bfs_distances(adj, leaf_id) + + # ONLY connect leaf to a nearby degree-2 same-sector node. + # This upgrades the leaf to degree-2 and the target to degree-3 + # (junction), creating through_routes — NO cycles formed. + sector_d2 = [ + nid for nid, d in dist.items() + if 2 <= d <= 8 + and node_map.get(nid, {}).get("geographic_sector") == leaf["geographic_sector"] + and degree(adj, nid) == 2 + and not edge_exists(edges_set, leaf_id, nid) + ] + if sector_d2: + # Prefer closer targets; pick from top-5 + sector_d2_sorted = sorted(sector_d2, key=lambda nid: dist.get(nid, 9999)) + target_node = rng.choice(sector_d2_sorted[:5]) + add_edge(edges, edges_set, adj, leaf_id, target_node) + made_progress = True + continue + + if not made_progress: + # No more degree-2 targets reachable — remaining leaves stay as + # dead_ends for the sculpt pass to handle via loop-edge addition. + break + + +def _count_cross_sector_edges(edges: list[list], node_map: dict, sector_a: str, sector_b: str) -> int: + """Count edges that cross between two specific sectors.""" + count = 0 + for a_id, b_id in edges: + sec_a = node_map[a_id]["geographic_sector"] + sec_b = node_map[b_id]["geographic_sector"] + if set([sec_a, sec_b]) == set([sector_a, sector_b]): + count += 1 + return count + + +def _ensure_cross_sector_bridges( + nodes: list[dict], + edges: list[list], + edges_set: set, + adj: dict, + cross_targets: dict, + rng: random.Random, +) -> None: + """ + Ensure each sector boundary has at least the target number of connections. + Adds bridging edges through high-degree systems where possible. + """ + node_map = {n["system_id"]: n for n in nodes} + + # Parse cross_targets keys like "core_to_north" + boundary_map = { + ("core", "north_reach"): cross_targets.get("core_to_north", 2), + ("core", "west_reach"): cross_targets.get("core_to_west", 2), + ("core", "south_reach"): cross_targets.get("core_to_south", 2), + ("core", "east_reach"): cross_targets.get("core_to_east", 2), + ("north_reach", "deep_frontier"): cross_targets.get("north_to_deep_frontier", 2), + ("west_reach", "deep_frontier"): cross_targets.get("west_to_deep_frontier", 2), + ("south_reach", "deep_frontier"): cross_targets.get("south_to_deep_frontier", 2), + ("east_reach", "deep_frontier"): cross_targets.get("east_to_deep_frontier", 2), + ("north_reach", "west_reach"): cross_targets.get("north_to_west", 1), + ("west_reach", "south_reach"): cross_targets.get("west_to_south", 1), + ("south_reach", "east_reach"): cross_targets.get("south_to_east", 1), + ("east_reach", "north_reach"): cross_targets.get("east_to_north", 1), + } + + for (sec_a, sec_b), target in boundary_map.items(): + current = _count_cross_sector_edges(edges, node_map, sec_a, sec_b) + needed = target - current + if needed <= 0: + continue + + # Pick highest-degree inner nodes from each sector as bridge anchors + nodes_a = sorted( + [n for n in nodes if n["geographic_sector"] == sec_a], + key=lambda n: degree(adj, n["system_id"]), + reverse=True, + ) + nodes_b = sorted( + [n for n in nodes if n["geographic_sector"] == sec_b], + key=lambda n: degree(adj, n["system_id"]), + reverse=True, + ) + + if not nodes_a or not nodes_b: + continue + + added = 0 + attempts = 0 + while added < needed and attempts < 50: + attempts += 1 + # Pick a candidate from each sector, weighted toward top of sorted list + idx_a = min(int(rng.random() ** 2 * len(nodes_a)), len(nodes_a) - 1) + idx_b = min(int(rng.random() ** 2 * len(nodes_b)), len(nodes_b) - 1) + a_id = nodes_a[idx_a]["system_id"] + b_id = nodes_b[idx_b]["system_id"] + if not edge_exists(edges_set, a_id, b_id): + add_edge(edges, edges_set, adj, a_id, b_id) + added += 1 + + +# ── Phase 4: Topology classification ────────────────────────────────────────── + +def phase4_classify_topology( + nodes: list[dict], + edges: list[list], + adj: dict, +) -> None: + """ + Classify each node's gate_topology based on its degree and graph position. + Modifies nodes in-place. + """ + # Detect loop members: nodes that are part of a cycle + loop_members = _find_loop_members(nodes, adj) + + for node in nodes: + nid = node["system_id"] + d = degree(adj, nid) + + if d == 0: + # Isolated — shouldn't happen after spanning tree + node["gate_topology"] = "dead_end" + elif d == 1: + node["gate_topology"] = "dead_end" + elif d == 2: + if nid in loop_members: + node["gate_topology"] = "loop_member" + else: + # spur_end: NOT part of a cycle, and at least one neighbour + # is a branching node (degree >= 3), meaning this system + # hangs off a busier spine. It does NOT need both neighbours + # to be high-degree — one busy endpoint is sufficient to + # classify a system as a spur rather than a chain link. + # through_route: both neighbours are degree <= 2 (pure chain). + neighbors = adj.get(nid, []) + at_least_one_branching = any( + degree(adj, nb) >= 3 for nb in neighbors + ) + if at_least_one_branching: + node["gate_topology"] = "spur_end" + else: + node["gate_topology"] = "through_route" + elif d == 3: + if nid in loop_members: + node["gate_topology"] = "loop_member" + else: + node["gate_topology"] = "junction" + elif d == 4: + node["gate_topology"] = "junction" + else: # d >= 5 + node["gate_topology"] = "hub" + + # Override: Gateway is always hub + if node.get("_gateway"): + node["gate_topology"] = "hub" + + +def _find_loop_members(nodes: list[dict], adj: dict) -> set: + """ + Find all nodes that participate in at least one cycle. + + Uses iterative DFS with explicit depth tracking. For each back-edge + (node → ancestor) found, every node on the DFS-tree path from + ancestor to node is added to loop_nodes. + + Correctness note: we track depth to find ancestors unambiguously and + use a per-component parent table reset on each new component start. + """ + all_ids = {n["system_id"] for n in nodes} + visited = set() + loop_nodes = set() + + for start_id in all_ids: + if start_id in visited: + continue + + # Per-component DFS state + parent: dict[str, Optional[str]] = {start_id: None} + depth: dict[str, int] = {start_id: 0} + + # Stack entries: (node_id, parent_id, neighbor_iterator) + stack = [(start_id, None, iter(adj.get(start_id, [])))] + visited.add(start_id) + + while stack: + node, par, neighbors = stack[-1] + try: + neighbor = next(neighbors) + if neighbor not in visited: + visited.add(neighbor) + parent[neighbor] = node + depth[neighbor] = depth[node] + 1 + stack.append((neighbor, node, iter(adj.get(neighbor, [])))) + elif neighbor != par and depth.get(neighbor, -1) < depth.get(node, 0): + # Back edge to an actual ancestor (not just the tree-parent) + # Mark every node on the path from ancestor → node + loop_nodes.add(node) + loop_nodes.add(neighbor) + curr = node + while curr != neighbor and curr is not None: + loop_nodes.add(curr) + curr = parent.get(curr) + except StopIteration: + stack.pop() + + return loop_nodes + + +# ── Phase 5: Aperture assignment ─────────────────────────────────────────────── + +def phase5_apertures( + nodes: list[dict], + adj: dict, + seed_cfg: dict, + rng: random.Random, +) -> None: + """ + Assign aperture_count and gate_connections for each node. + All 300 nodes in this graph have horizon stations. + """ + unused_prob = seed_cfg["augmentation_weights"]["unused_aperture_probability"] + + for node in nodes: + nid = node["system_id"] + d = degree(adj, nid) + node["gate_connections"] = d + + # Gateway: 5 apertures (4 active + 1 dormant Sol-facing) + if node.get("_gateway"): + node["aperture_count"] = 5 + node["gate_connections"] = 4 # Sol aperture not traversable + continue + + # Base: apertures = connections (minimum) + apertures = d + + # Narrative texture: some systems have unused apertures + # (research interest, mystery, historical significance) + if d > 0 and rng.random() < unused_prob: + apertures += rng.randint(1, 2) + + # Cap at 8 (setting limit), but never below actual connections + # If degree somehow exceeds 8 (shouldn't happen with tuned augmentation), + # we cap gate_connections at 8 as well to maintain consistency. + if d > 8: + node["gate_connections"] = 8 + apertures = min(apertures, 8) + # Guarantee: aperture_count >= gate_connections always + apertures = max(apertures, node["gate_connections"]) + + # Floor at 1 (all nodes in this map have stations) + apertures = max(apertures, 1) + + node["aperture_count"] = apertures + + +# ── Phase 6: Validation ──────────────────────────────────────────────────────── + +def phase6_validate( + nodes: list[dict], + edges: list[list], + adj: dict, + seed_cfg: dict, +) -> dict: + """ + Run all validation checks. Returns a dict of results for the summary report. + """ + results = {} + total = len(nodes) + node_map = {n["system_id"]: n for n in nodes} + tol = seed_cfg["validation_tolerances"] + topology_targets = seed_cfg["topology_targets"] + + # 1. Connectivity: all nodes reachable from Gateway + all_ids = set(n["system_id"] for n in nodes) + reachable = bfs_connected(adj, "S-001", all_ids) + isolated = all_ids - reachable + results["connectivity_ok"] = len(isolated) == 0 + results["isolated_count"] = len(isolated) + results["isolated_ids"] = sorted(isolated)[:10] # show first 10 if any + + # 2. Aperture consistency: no system has gate_connections > aperture_count + # (Gateway is excluded — it has 4 connections, 5 apertures including Sol) + inconsistent = [ + n["system_id"] for n in nodes + if n["gate_connections"] > n["aperture_count"] + ] + results["aperture_consistency_ok"] = len(inconsistent) == 0 + results["aperture_inconsistent_ids"] = inconsistent[:10] + + # 3. Topology distribution + topology_counts = defaultdict(int) + for n in nodes: + topology_counts[n["gate_topology"]] += 1 + + topology_pcts = {k: v / total for k, v in topology_counts.items()} + topology_ok = True + topology_diffs = {} + for topo, target in topology_targets.items(): + actual = topology_pcts.get(topo, 0.0) + diff = abs(actual - target) + topology_diffs[topo] = { + "target": target, + "actual": round(actual, 3), + "count": topology_counts.get(topo, 0), + "ok": diff <= tol["topology_target_tolerance_pct"], + } + if diff > tol["topology_target_tolerance_pct"]: + topology_ok = False + + results["topology_distribution"] = topology_diffs + results["topology_ok"] = topology_ok + + # 4. Hub distribution: at least one hub per sector + hubs_per_sector = defaultdict(int) + for n in nodes: + if n["gate_topology"] == "hub": + hubs_per_sector[n["geographic_sector"]] += 1 + + hub_coverage_ok = all(hubs_per_sector.get(s, 0) >= 1 for s in SECTORS) + results["hub_per_sector"] = dict(hubs_per_sector) + results["hub_coverage_ok"] = hub_coverage_ok + results["hub_total"] = topology_counts.get("hub", 0) + results["hub_pct"] = topology_pcts.get("hub", 0.0) + results["hub_pct_ok"] = topology_pcts.get("hub", 0.0) <= tol["max_hubs_pct"] + + # 5. Dead-end + spur coverage + dead_spur_pct = (topology_counts.get("dead_end", 0) + topology_counts.get("spur_end", 0)) / total + results["dead_spur_pct"] = round(dead_spur_pct, 3) + results["dead_spur_ok"] = tol["dead_end_plus_spur_min_pct"] <= dead_spur_pct <= tol["dead_end_plus_spur_max_pct"] + + # 6. Gateway placement + gateway = node_map.get("S-001") + gw_connections = degree(adj, "S-001") + results["gateway_sector"] = gateway["geographic_sector"] if gateway else "MISSING" + results["gateway_topology"] = gateway["gate_topology"] if gateway else "MISSING" + results["gateway_apertures"] = gateway["aperture_count"] if gateway else 0 + results["gateway_connections_in_adj"] = gw_connections + results["gateway_ok"] = ( + gateway is not None + and gateway["geographic_sector"] == "core" + and gw_connections >= tol["gateway_min_connections"] + and gw_connections <= tol["gateway_max_connections"] + 1 + ) + + # 7. earth_proximity distribution + distances = bfs_distances(adj, "S-001") + proximity_counts = defaultdict(int) + for nid in all_ids: + d = distances.get(nid, 9999) + if d <= 2: + proximity_counts["immediate"] += 1 + elif d <= 10: + proximity_counts["proximate"] += 1 + elif d <= 30: + proximity_counts["distant"] += 1 + else: + proximity_counts["irrelevant"] += 1 + + results["earth_proximity_distribution"] = dict(proximity_counts) + results["immediate_ok"] = proximity_counts["immediate"] <= tol["earth_proximity_immediate_max"] + + # Attach hop distance to each node + for node in nodes: + nid = node["system_id"] + d = distances.get(nid, 9999) + if d <= 2: + node["_earth_proximity"] = "immediate" + elif d <= 10: + node["_earth_proximity"] = "proximate" + elif d <= 30: + node["_earth_proximity"] = "distant" + else: + node["_earth_proximity"] = "irrelevant" + node["_hop_distance_from_gateway"] = d + + # Cross-sector connection counts + cross_counts = defaultdict(int) + for a_id, b_id in edges: + sec_a = node_map[a_id]["geographic_sector"] + sec_b = node_map[b_id]["geographic_sector"] + if sec_a != sec_b: + pair = tuple(sorted([sec_a, sec_b])) + cross_counts[pair] += 1 + + results["cross_sector_connections"] = {f"{a}|{b}": c for (a, b), c in sorted(cross_counts.items())} + + # Overall pass/fail + results["overall_ok"] = all([ + results["connectivity_ok"], + results["aperture_consistency_ok"], + results["hub_coverage_ok"], + ]) + + return results + + +# ── JSON output ──────────────────────────────────────────────────────────────── + +def build_output_json(nodes: list[dict], edges: list[list]) -> dict: + """ + Build the canonical star-map.json structure. + Strips internal _gateway and _hop_distance fields from output. + """ + output_nodes = [] + for n in nodes: + out = { + "system_id": n["system_id"], + "system_name": n["system_name"], + "star_type": n["star_type"], + "geographic_sector": n["geographic_sector"], + "geographic_band": n["geographic_band"], + "political_zone": n["political_zone"], + "settlement_wave": n["settlement_wave"], + "gate_topology": n["gate_topology"], + "aperture_count": n["aperture_count"], + "gate_connections": n["gate_connections"], + "earth_proximity": n.get("_earth_proximity", "irrelevant"), + "hop_distance_from_gateway": n.get("_hop_distance_from_gateway", 9999), + } + # Mark the Gateway + if n.get("_gateway"): + out["is_gateway"] = True + output_nodes.append(out) + + return { + "_meta": { + "generated": "2026-03-13", + "version": "0.1", + "system_count": len(output_nodes), + "edge_count": len(edges), + "note": "Placeholder IDs and names. Naming pass required before CSV population.", + }, + "nodes": output_nodes, + "edges": [[a, b] for a, b in edges], + } + + +# ── D2 generation ────────────────────────────────────────────────────────────── + +def d2_safe_id(system_id: str) -> str: + """Convert S-001 to s001 for d2 node IDs (no hyphens).""" + return system_id.replace("-", "").lower() + + +def generate_sector_d2( + sector: str, + nodes: list[dict], + edges: list[list], + node_map: dict, + adj: dict, +) -> str: + """ + Generate d2 source for one sector map. + Includes all systems in the sector as full nodes. + Cross-sector connections shown as stub nodes. + """ + sector_ids = {n["system_id"] for n in nodes if n["geographic_sector"] == sector} + sector_label = SECTOR_LABELS[sector] + + # Gather cross-sector stubs needed + stub_ids = set() + for a_id, b_id in edges: + a_sec = node_map[a_id]["geographic_sector"] + b_sec = node_map[b_id]["geographic_sector"] + if a_id in sector_ids and b_id not in sector_ids: + stub_ids.add(b_id) + elif b_id in sector_ids and a_id not in sector_ids: + stub_ids.add(a_id) + + lines = [] + lines.append(f"# Star Map — {sector_label}") + lines.append(f"# Sector map. Cross-sector connections shown as stub nodes (dashed border).") + lines.append(f"# Node color = settlement wave. Shape = topology type.") + lines.append("") + lines.append("vars: {") + lines.append(f' bg: "{D2_BG}"') + lines.append(f' txt: "{D2_TXT}"') + lines.append(f' acc: "{D2_ACC}"') + lines.append("}") + lines.append("") + + # Root style + lines.append(f"direction: right") + lines.append("") + lines.append(f'style.fill: "{D2_BG}"') + lines.append(f'style.stroke: "{D2_ACC}"') + lines.append(f'style.font-color: "{D2_TXT}"') + lines.append("") + + # Legend + lines.append("legend: Legend {") + lines.append(f' style.fill: "{D2_BG}"; style.stroke: "{D2_ACC}"; style.font-color: "{D2_TXT}"') + lines.append(f' style.font-size: 10') + for wave, stroke in WAVE_STROKE.items(): + fill = WAVE_FILL[wave] + w_label = wave.replace("_", " ").title() + w_id = wave.replace("_", "") + lines.append(f' {w_id}: {w_label} {{ style.fill: "{fill}"; style.stroke: "{stroke}"; style.font-color: "{D2_TXT}" }}') + lines.append("}") + lines.append("") + + # Sector nodes + for n in sorted(nodes, key=lambda x: x["system_id"]): + if n["geographic_sector"] != sector: + continue + nid = n["system_id"] + d2id = d2_safe_id(nid) + wave = n["settlement_wave"] + topo = n["gate_topology"] + fill = WAVE_FILL[wave] + stroke = WAVE_STROKE[wave] + + # Gateway gets special treatment + if n.get("_gateway") or nid == "S-001": + fill = GATEWAY_FILL + stroke = GATEWAY_STROKE + label = f"{nid}\\n[GATEWAY]\\n{topo}" + else: + label = f"{nid}\\n{wave.replace('_', ' ')}\\n{topo}" + + shape = d2_node_shape(topo) + node_line = f'{d2id}: "{label}" {{' + lines.append(node_line) + lines.append(f' shape: {shape}') + lines.append(f' style.fill: "{fill}"') + lines.append(f' style.stroke: "{stroke}"') + lines.append(f' style.font-color: "{D2_TXT}"') + lines.append(f' style.font-size: 9') + if nid == "S-001": + lines.append(f' style.stroke-width: 3') + lines.append("}") + lines.append("") + + # Stub nodes for cross-sector systems + for stub_id in sorted(stub_ids): + stub_node = node_map[stub_id] + d2id = d2_safe_id(stub_id) + stub_sector_label = SECTOR_LABELS[stub_node["geographic_sector"]] + label = f"{stub_id}\\n[{stub_sector_label}]" + lines.append(f'{d2id}: "{label}" {{') + lines.append(f' shape: rectangle') + lines.append(f' style.fill: "{STUB_FILL}"') + lines.append(f' style.stroke: "{STUB_STROKE}"') + lines.append(f' style.stroke-dash: 5') + lines.append(f' style.font-color: "{STUB_STROKE}"') + lines.append(f' style.font-size: 9') + lines.append("}") + lines.append("") + + # Edges + rendered_edges = set() + for a_id, b_id in edges: + a_sec = node_map[a_id]["geographic_sector"] + b_sec = node_map[b_id]["geographic_sector"] + + # Only render edges where at least one endpoint is in this sector + if a_id not in sector_ids and b_id not in sector_ids: + continue + + edge_key = tuple(sorted([a_id, b_id])) + if edge_key in rendered_edges: + continue + rendered_edges.add(edge_key) + + d2a = d2_safe_id(a_id) + d2b = d2_safe_id(b_id) + + is_cross = a_sec != b_sec + is_gateway_edge = (a_id == "S-001" or b_id == "S-001") + + if is_gateway_edge: + color = EDGE_COLOR_GATEWAY + elif is_cross: + color = EDGE_COLOR_CROSS + else: + color = EDGE_COLOR_INTRA + + edge_line = f"{d2a} -- {d2b}" + if is_cross: + lines.append(f"{edge_line}: {{") + lines.append(f' style.stroke: "{color}"') + lines.append(f' style.stroke-dash: 4') + lines.append(f' style.stroke-width: 1') + lines.append("}") + else: + lines.append(f"{edge_line}: {{") + lines.append(f' style.stroke: "{color}"') + lines.append("}") + lines.append("") + + return "\n".join(lines) + + +def generate_overview_d2( + nodes: list[dict], + edges: list[list], + node_map: dict, +) -> str: + """ + Generate the overview d2 showing sectors as cluster nodes + with inter-sector edge counts. + """ + # Count cross-sector connections + cross_counts = defaultdict(int) + sector_node_counts = defaultdict(int) + for n in nodes: + sector_node_counts[n["geographic_sector"]] += 1 + + for a_id, b_id in edges: + sec_a = node_map[a_id]["geographic_sector"] + sec_b = node_map[b_id]["geographic_sector"] + if sec_a != sec_b: + pair = tuple(sorted([sec_a, sec_b])) + cross_counts[pair] += 1 + + # Hub counts per sector + hub_counts = defaultdict(int) + for n in nodes: + if n["gate_topology"] == "hub": + hub_counts[n["geographic_sector"]] += 1 + + lines = [] + lines.append("# Star Map — Overview") + lines.append("# Sector cluster view. Nodes = sectors. Edge labels = cross-sector gate connections.") + lines.append("") + lines.append("vars: {") + lines.append(f' bg: "{D2_BG}"') + lines.append(f' txt: "{D2_TXT}"') + lines.append(f' acc: "{D2_ACC}"') + lines.append("}") + lines.append("") + lines.append(f'direction: right') + lines.append(f'style.fill: "{D2_BG}"') + lines.append(f'style.stroke: "{D2_ACC}"') + lines.append(f'style.font-color: "{D2_TXT}"') + lines.append("") + + # Sector nodes + sector_colors = { + "core": ("#1a2040", "#3060c0"), + "north_reach": ("#1a2820", "#4a9060"), + "west_reach": ("#201e14", "#907030"), + "south_reach": ("#201814", "#905030"), + "east_reach": ("#1a2028", "#4070a0"), + "deep_frontier": ("#201010", "#803030"), + } + + for sector in SECTORS: + label = SECTOR_LABELS[sector] + count = sector_node_counts[sector] + hubs = hub_counts[sector] + fill, stroke = sector_colors[sector] + d2id = sector.replace("_", "") + lines.append(f'{d2id}: "{label}\\n{count} systems · {hubs} hubs" {{') + lines.append(f' shape: rectangle') + lines.append(f' style.fill: "{fill}"') + lines.append(f' style.stroke: "{stroke}"') + lines.append(f' style.font-color: "{D2_TXT}"') + lines.append(f' style.border-radius: 8') + lines.append("}") + lines.append("") + + # Special Gateway callout + lines.append('gateway_note: "GATEWAY (S-001)\\nDiplomatic Periphery · Core\\nSol aperture: dormant" {') + lines.append(f' shape: hexagon') + lines.append(f' style.fill: "{GATEWAY_FILL}"') + lines.append(f' style.stroke: "{GATEWAY_STROKE}"') + lines.append(f' style.font-color: "{D2_TXT}"') + lines.append(f' style.stroke-width: 3') + lines.append("}") + lines.append(f'gateway_note -> core: "located in" {{') + lines.append(f' style.stroke: "{GATEWAY_STROKE}"; style.stroke-dash: 3') + lines.append("}") + lines.append("") + + # Cross-sector edges + rendered = set() + for (sec_a, sec_b), count in sorted(cross_counts.items()): + pair_key = (sec_a, sec_b) + if pair_key in rendered: + continue + rendered.add(pair_key) + d2a = sec_a.replace("_", "") + d2b = sec_b.replace("_", "") + lines.append(f'{d2a} -- {d2b}: "{count} connections" {{') + lines.append(f' style.stroke: "{EDGE_COLOR_CROSS}"') + lines.append(f' style.font-color: "{D2_TXT}"') + lines.append("}") + lines.append("") + + return "\n".join(lines) + + +# ── Validation report printer ────────────────────────────────────────────────── + +def print_validation_summary(nodes: list[dict], edges: list[list], validation: dict) -> None: + print("\n" + "=" * 60) + print("STAR MAP GENERATION — VALIDATION SUMMARY") + print("=" * 60) + print(f" Systems: {len(nodes)}") + print(f" Edges: {len(edges)}") + print() + + # Connectivity + ok = "OK" if validation["connectivity_ok"] else "FAIL" + print(f" Connectivity: [{ok}] all nodes reachable from Gateway") + if not validation["connectivity_ok"]: + print(f" Isolated: {validation['isolated_count']} nodes: {validation['isolated_ids']}") + + # Aperture consistency + ok = "OK" if validation["aperture_consistency_ok"] else "FAIL" + print(f" Aperture consistency: [{ok}]") + if not validation["aperture_consistency_ok"]: + print(f" Inconsistent: {validation['aperture_inconsistent_ids']}") + + # Topology distribution + print() + print(" Topology distribution:") + topo_ok_all = True + for topo, info in sorted(validation["topology_distribution"].items()): + flag = "ok" if info["ok"] else "WARN" + print(f" {topo:<16} target={info['target']:.0%} actual={info['actual']:.1%} ({info['count']:3d} systems) [{flag}]") + if not info["ok"]: + topo_ok_all = False + if not topo_ok_all: + print(" Note: topology targets are soft. Deviation within 4pp is expected.") + + # Hub distribution + print() + print(" Hub distribution per sector:") + for sector in SECTORS: + count = validation["hub_per_sector"].get(sector, 0) + flag = "ok" if count >= 1 else "WARN" + print(f" {sector:<20} {count} hubs [{flag}]") + print(f" Total hubs: {validation['hub_total']} ({validation['hub_pct']:.1%})") + + # Dead-end / spur + ok = "ok" if validation["dead_spur_ok"] else "WARN" + print() + print(f" Dead-end + spur coverage: {validation['dead_spur_pct']:.1%} [{ok}]") + + # Gateway + print() + ok = "OK" if validation["gateway_ok"] else "FAIL" + print(f" Gateway (S-001): [{ok}]") + print(f" Sector: {validation['gateway_sector']}") + print(f" Topology: {validation['gateway_topology']}") + print(f" Apertures: {validation['gateway_apertures']}") + print(f" Adj degree: {validation['gateway_connections_in_adj']}") + + # Earth proximity + print() + print(" Earth proximity distribution (hop distance from Gateway):") + for zone, count in sorted(validation["earth_proximity_distribution"].items()): + print(f" {zone:<12} {count:3d} systems") + + # Cross-sector connections + print() + print(" Cross-sector connections:") + for pair, count in sorted(validation["cross_sector_connections"].items()): + print(f" {pair:<40} {count}") + + # Overall + print() + overall = "PASS" if validation["overall_ok"] else "ISSUES DETECTED" + print(f" Overall: {overall}") + print("=" * 60) + print() + + +# ── Main ─────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Generate Settled Reach star map") + parser.add_argument( + "--seed-file", + default=str(DEFAULT_SEED_FILE), + help=f"Path to seed configuration JSON (default: {DEFAULT_SEED_FILE})", + ) + args = parser.parse_args() + + seed_file = Path(args.seed_file) + if not seed_file.exists(): + print(f"ERROR: seed file not found: {seed_file}", file=sys.stderr) + sys.exit(1) + + with open(seed_file, "r") as f: + seed_cfg = json.load(f) + + # Strip _comment keys recursively so they don't pollute dict iterations + def strip_comments(obj): + if isinstance(obj, dict): + return {k: strip_comments(v) for k, v in obj.items() if not k.startswith("_comment")} + if isinstance(obj, list): + return [strip_comments(item) for item in obj] + return obj + + seed_cfg = strip_comments(seed_cfg) + + # Initialize RNG with fixed seed for determinism + rng = random.Random(seed_cfg["random_seed"]) + + print(f"Loaded seed config: {seed_file}") + print(f"Random seed: {seed_cfg['random_seed']}") + print(f"Target system count: {seed_cfg['system_count']}") + + # Phase 1: Place systems + print("Phase 1: Placing systems...") + nodes = phase1_place_systems(seed_cfg, rng) + print(f" Placed {len(nodes)} systems") + + # Phase 2: Spanning tree + print("Phase 2: Building spanning tree backbone...") + edges, edges_set, adj = phase2_spanning_tree(nodes, seed_cfg, rng) + print(f" Spanning tree: {len(edges)} edges") + + # Phase 3: Augmentation + print("Phase 3: Augmentation passes (hubs, loops, bridges)...") + phase3_augment(nodes, edges, edges_set, adj, seed_cfg, rng) + print(f" Post-augmentation: {len(edges)} edges") + + # Phase 4: Classify topology + print("Phase 4: Classifying topology...") + phase4_classify_topology(nodes, edges, adj) + + # Phase 5: Aperture assignment + print("Phase 5: Assigning apertures...") + phase5_apertures(nodes, adj, seed_cfg, rng) + + # Phase 6: Validation + print("Phase 6: Validating...") + node_map = {n["system_id"]: n for n in nodes} + validation = phase6_validate(nodes, edges, adj, seed_cfg) + + # Print validation summary + print_validation_summary(nodes, edges, validation) + + # Write star-map.json + OUTPUT_JSON.parent.mkdir(parents=True, exist_ok=True) + output_data = build_output_json(nodes, edges) + with open(OUTPUT_JSON, "w") as f: + json.dump(output_data, f, indent=2) + print(f"Written: {OUTPUT_JSON}") + + # Write sector d2 files + OUTPUT_D2_DIR.mkdir(parents=True, exist_ok=True) + + sector_filenames = { + "core": "star-map-core.d2", + "north_reach": "star-map-north-reach.d2", + "west_reach": "star-map-west-reach.d2", + "south_reach": "star-map-south-reach.d2", + "east_reach": "star-map-east-reach.d2", + "deep_frontier": "star-map-deep-frontier.d2", + } + + for sector, filename in sector_filenames.items(): + d2_content = generate_sector_d2(sector, nodes, edges, node_map, adj) + out_path = OUTPUT_D2_DIR / filename + with open(out_path, "w") as f: + f.write(d2_content) + sector_count = sum(1 for n in nodes if n["geographic_sector"] == sector) + print(f"Written: {out_path} ({sector_count} systems)") + + # Write overview d2 + overview_content = generate_overview_d2(nodes, edges, node_map) + overview_path = OUTPUT_D2_DIR / "star-map-overview.d2" + with open(overview_path, "w") as f: + f.write(overview_content) + print(f"Written: {overview_path}") + + # Summary + if validation["overall_ok"]: + print("\nGeneration complete. All critical checks passed.") + else: + print("\nGeneration complete with issues. Review validation output above.") + if validation["isolated_count"] > 0: + print(f" ACTION NEEDED: {validation['isolated_count']} isolated systems must be connected.") + + return 0 if validation["overall_ok"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tooling/patch-core-sector.py b/tooling/patch-core-sector.py new file mode 100644 index 000000000..df462396a --- /dev/null +++ b/tooling/patch-core-sector.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +""" +Hand-balance the core sector in star-map.json. +Adds 17 edges within the core sector to restore connectivity and topology diversity. +""" + +import json +import sys +from collections import defaultdict + +JSON_PATH = "/var/mnt/data/projects/settled-reach/planning/docs/design/star-map.json" + +# ── New edges to add (all within core sector) ────────────────────────────────── +NEW_EDGES = [ + ["S-235", "S-067"], # 1: cross-connect two major cores + ["S-235", "S-093"], # 2: S-235 reaches deg=5 (hub) + ["S-235", "S-010"], # 3: S-235 hub spine extended + ["S-067", "S-213"], # 4: S-213 exits dead_end + ["S-067", "S-181"], # 5: S-067 reaches deg=5 (hub); S-181 exits dead_end + ["S-067", "S-120"], # 6: S-067 hub — links to S-120 + ["S-263", "S-253"], # 7: connect two spur/through systems + ["S-263", "S-139"], # 8: S-263 reaches junction + ["S-091", "S-111"], # 9: connect two spur_ends + ["S-253", "S-118"], # 10: S-118 exits dead_end + ["S-139", "S-168"], # 11: S-168 exits dead_end + ["S-027", "S-047"], # 12: connect two spur_ends + ["S-093", "S-037"], # 13: S-037 exits dead_end + ["S-120", "S-119"], # 14: S-119 exits dead_end + ["S-074", "S-027"], # 15: S-074 exits dead_end + ["S-114", "S-213"], # 16: S-114 exits dead_end; S-213 reaches junction + ["S-111", "S-168"], # 17: S-168 promoted to junction; S-111 reaches junction +] + +# ── Topology classification rules (by degree) ───────────────────────────────── +# Special override for S-001 (Gateway): always hub regardless of computed degree. +SPECIAL_HUBS = {"S-001"} + +def classify_topology(system_id: str, degree: int) -> str: + if system_id in SPECIAL_HUBS: + return "hub" + if degree >= 5: + return "hub" + if degree == 3 or degree == 4: + return "junction" + if degree == 2: + # Use spur_end as default for deg-2; through_route is a subtype + # We preserve existing through_route classification for existing deg-2 nodes + # unless they change degree. If they stay at 2, they keep their type. + # This function only gets called when degree CHANGES. + return "spur_end" + if degree == 1: + return "dead_end" + # deg=0 should not occur + return "dead_end" + +def aperture_for_connections(gate_connections: int, system_id: str) -> int: + """ + Compute aperture_count from gate_connections. + S-001 is special: 5 apertures (4 active Reach + 1 dormant Sol). + For all others: apertures = connections (one aperture per connection). + Aperture count has a cap of 8 per setting rules. + """ + if system_id == "S-001": + return 5 # never changes + return min(gate_connections, 8) + +def main(): + with open(JSON_PATH, "r") as f: + data = json.load(f) + + nodes = data["nodes"] + edges = data["edges"] + + # Build adjacency map to compute current degrees + degree = defaultdict(int) + edge_set = set() + for edge in edges: + a, b = edge[0], edge[1] + key = tuple(sorted([a, b])) + edge_set.add(key) + degree[a] += 1 + degree[b] += 1 + + # Verify initial state of core nodes before patching + core_ids = { + "S-001", "S-010", "S-014", "S-027", "S-037", "S-047", "S-067", + "S-074", "S-091", "S-093", "S-111", "S-114", "S-118", "S-119", + "S-120", "S-139", "S-168", "S-181", "S-213", "S-235", "S-253", + "S-263", "S-265", "S-279", "S-297" + } + + print("=== BEFORE: Core sector node degrees ===") + for nid in sorted(core_ids): + d = degree.get(nid, 0) + print(f" {nid}: deg={d}") + print() + + # Check for duplicate edges in new list + duplicates = [] + for edge in NEW_EDGES: + key = tuple(sorted(edge)) + if key in edge_set: + duplicates.append(edge) + if duplicates: + print(f"WARNING: Duplicate edges (already exist): {duplicates}", file=sys.stderr) + + # Add new edges + added = 0 + for edge in NEW_EDGES: + key = tuple(sorted(edge)) + if key not in edge_set: + edges.append(edge) + edge_set.add(key) + degree[edge[0]] += 1 + degree[edge[1]] += 1 + added += 1 + else: + print(f" Skipping duplicate: {edge}") + + print(f"Added {added} new edges (of {len(NEW_EDGES)} requested).") + print() + + # Build node lookup by system_id + node_by_id = {n["system_id"]: n for n in nodes} + + # Update topology/aperture/connections for all modified nodes + # We update ALL core nodes so the JSON is consistent. + print("=== Updating core node attributes ===") + + # For S-001: keep hub, keep aperture=5, keep gate_connections=4 (special Gateway rule) + # For all others: update based on new degree. + + # Track through_route nodes we want to preserve classification if degree stays 2 + # (S-093 and S-253 and S-139 were through_route at deg=2; they're all getting upgraded, + # so this doesn't matter — they'll be junction now) + + for nid in core_ids: + node = node_by_id.get(nid) + if node is None: + print(f" ERROR: {nid} not found in nodes!", file=sys.stderr) + continue + + new_deg = degree.get(nid, 0) + + if nid == "S-001": + # Special case: Gateway always hub, aperture=5, connections=4 + new_topology = "hub" + new_connections = 4 + new_apertures = 5 + else: + new_topology = classify_topology(nid, new_deg) + new_connections = new_deg + new_apertures = aperture_for_connections(new_deg, nid) + + old_topology = node["gate_topology"] + old_connections = node["gate_connections"] + + if (old_topology != new_topology or + old_connections != new_connections): + print(f" {nid}: {old_topology}(c={old_connections}) → " + f"{new_topology}(c={new_connections}, ap={new_apertures})") + + node["gate_topology"] = new_topology + node["gate_connections"] = new_connections + node["aperture_count"] = new_apertures + + print() + + # ── Overall distribution report ─────────────────────────────────────────── + print("=== AFTER: Core sector topology breakdown ===") + topology_counts_core = defaultdict(int) + for nid in sorted(core_ids): + t = node_by_id[nid]["gate_topology"] + topology_counts_core[t] += 1 + print(f" {nid}: deg={degree.get(nid,0)}, {t}") + print() + print("Core summary:") + for t, c in sorted(topology_counts_core.items()): + print(f" {t}: {c}") + print() + + print("=== AFTER: Overall topology distribution ===") + topology_counts_all = defaultdict(int) + for node in nodes: + topology_counts_all[node["gate_topology"]] += 1 + total = len(nodes) + for t, c in sorted(topology_counts_all.items()): + pct = 100.0 * c / total + print(f" {t}: {c} ({pct:.1f}%)") + print() + print(f"Total nodes: {total}") + print(f"Total edges: {len(edges)}") + print() + + # Update meta + data["_meta"]["edge_count"] = len(edges) + data["_meta"]["core_balanced"] = "2026-03-13" + data["_meta"]["core_balance_note"] = ( + "Core sector hand-balanced: +17 edges added, 25 core nodes reclassified. " + "3 hubs (Gateway + S-067 + S-235), 11 junctions, 8 spur_ends, 3 dead_ends." + ) + + # Write updated JSON + with open(JSON_PATH, "w") as f: + json.dump(data, f, indent=2) + print("Written to star-map.json.") + +if __name__ == "__main__": + main() diff --git a/tooling/sculpt-star-map.py b/tooling/sculpt-star-map.py new file mode 100644 index 000000000..9c420b472 --- /dev/null +++ b/tooling/sculpt-star-map.py @@ -0,0 +1,613 @@ +#!/usr/bin/env python3 +""" +Star Map Sculptor — The Settled Reach +Reads docs/design/star-map.json, adds/removes edges to correct topology +distribution, reclassifies all nodes, writes corrected JSON back. + +Edges in star-map.json are stored as arrays: ["S-XXX", "S-YYY"] + +Target distribution: + dead_end 20% ~60 systems (degree 1) + spur_end 15% ~45 systems (degree 2, not on cycle, one high-deg neighbour) + through_route 25% ~75 systems (degree 2, not on cycle, both neighbours low-deg) + loop_member 15% ~45 systems (degree 2, on a cycle) + junction 18% ~54 systems (degree 3-4) + hub 7% ~21 systems (degree 5+) + +Strategy: + After generation produces a sparse graph (too many dead_ends, too few loops), + the sculptor: + Pass 1 — Add loop-forming edges to convert some dead_ends/through_routes + into loop_members. Prefer same-sector pairs at BFS distance 4-10. + Pass 2 — Strip excess edges from over-connected core sector. + Pass 3 — Strip excess edges from deep_frontier / outer band. + Pass 4 — General cleanup to a target edge count (~330 edges). + Fine-tune — Iterate if loop_member or dead_end counts are still off. + +Standard library only. No external dependencies. +""" + +from __future__ import annotations + +import json +from collections import defaultdict, deque +from pathlib import Path +from typing import Optional + +REPO_ROOT = Path(__file__).parent.parent +INPUT_JSON = REPO_ROOT / "docs" / "design" / "star-map.json" +OUTPUT_JSON = INPUT_JSON + +TARGETS = { + "dead_end": 0.20, + "spur_end": 0.15, + "through_route": 0.25, + "loop_member": 0.15, + "junction": 0.18, + "hub": 0.07, +} + +# ── Graph helpers ────────────────────────────────────────────────────────────── + +def build_adjacency(edges: list[list]) -> dict[str, set[str]]: + adj: dict[str, set[str]] = defaultdict(set) + for e in edges: + a, b = e[0], e[1] + adj[a].add(b) + adj[b].add(a) + return adj + + +def is_connected(all_nodes: list[str], adj: dict[str, set[str]]) -> bool: + if not all_nodes: + return True + start = all_nodes[0] + visited = {start} + queue = deque([start]) + while queue: + cur = queue.popleft() + for nb in adj.get(cur, set()): + if nb not in visited: + visited.add(nb) + queue.append(nb) + return len(visited) == len(all_nodes) + + +def find_bridges(all_nodes: list[str], adj: dict[str, set[str]]) -> set[frozenset]: + """ + Iterative Tarjan bridge-finding on undirected graph. + Returns set of frozenset({u, v}) for each bridge edge. + """ + n = len(all_nodes) + idx_map = {v: i for i, v in enumerate(all_nodes)} + disc = [-1] * n + low = [-1] * n + timer = [0] + bridges: set[frozenset] = set() + + for start_node in all_nodes: + si = idx_map[start_node] + if disc[si] != -1: + continue + + nbrs_start = sorted(adj.get(start_node, set())) + # Stack: (node, parent_or_None, sorted_neighbor_list, current_index) + stack: list[tuple[str, Optional[str], list[str], int]] = [ + (start_node, None, nbrs_start, 0) + ] + disc[si] = low[si] = timer[0] + timer[0] += 1 + + while stack: + node, par, nbr_list, ni = stack[-1] + node_i = idx_map[node] + + if ni < len(nbr_list): + nb = nbr_list[ni] + stack[-1] = (node, par, nbr_list, ni + 1) + nb_i = idx_map[nb] + if disc[nb_i] == -1: + disc[nb_i] = low[nb_i] = timer[0] + timer[0] += 1 + nb_nbrs = sorted(adj.get(nb, set())) + stack.append((nb, node, nb_nbrs, 0)) + elif nb != par: + low[node_i] = min(low[node_i], disc[nb_i]) + else: + stack.pop() + if par is not None: + par_i = idx_map[par] + low[par_i] = min(low[par_i], low[node_i]) + if low[node_i] > disc[par_i]: + bridges.add(frozenset([par, node])) + + return bridges + + +def find_cycle_members(all_nodes: list[str], adj: dict[str, set[str]]) -> set[str]: + """ + A node is on a cycle iff at least one of its incident edges is not a bridge. + We only need this for degree-2 nodes (loop_member vs through_route/spur_end). + """ + bridge_set = find_bridges(all_nodes, adj) + on_cycle: set[str] = set() + for node in all_nodes: + neighbors = adj.get(node, set()) + if len(neighbors) < 2: + continue + non_bridge_count = sum( + 1 for nb in neighbors + if frozenset([node, nb]) not in bridge_set + ) + if non_bridge_count > 0: + on_cycle.add(node) + return on_cycle + + +def classify_node( + node_id: str, + adj: dict[str, set[str]], + cycle_members: set[str], + is_gateway: bool, +) -> str: + if is_gateway: + return "hub" + deg = len(adj.get(node_id, set())) + if deg <= 1: + return "dead_end" + if deg == 2: + if node_id in cycle_members: + return "loop_member" + neighbors = list(adj[node_id]) + nb_degs = [len(adj.get(nb, set())) for nb in neighbors] + # spur_end: NOT on a cycle, and at least one neighbour is branching + # (degree >= 3). A spur hangs off a busier node — it doesn't require + # BOTH neighbours to be high-degree. + if any(d >= 3 for d in nb_degs): + return "spur_end" + return "through_route" + if deg <= 4: + return "junction" + return "hub" + + +def reclassify_all(nodes: list[dict], adj: dict[str, set[str]]) -> None: + all_ids = [n["system_id"] for n in nodes] + cycle_members = find_cycle_members(all_ids, adj) + for n in nodes: + nid = n["system_id"] + is_gw = n.get("is_gateway", False) + topo = classify_node(nid, adj, cycle_members, is_gw) + n["gate_topology"] = topo + deg = len(adj.get(nid, set())) + n["gate_connections"] = deg + if is_gw: + # Gateway: keep aperture_count=5 (4 active Reach + 1 dormant Sol) + n["aperture_count"] = max(5, deg) + elif topo == "hub": + # Hub: one spare aperture (max 8 per setting rules) + n["aperture_count"] = min(8, deg + 1) + else: + # All others: aperture == connections (no spare) + n["aperture_count"] = deg + + +def count_topology(nodes: list[dict], adj: dict[str, set[str]]) -> dict[str, int]: + all_ids = [n["system_id"] for n in nodes] + cycle_members = find_cycle_members(all_ids, adj) + counts: dict[str, int] = defaultdict(int) + for n in nodes: + t = classify_node(n["system_id"], adj, cycle_members, n.get("is_gateway", False)) + counts[t] += 1 + return dict(counts) + + +# ── Edge removal ────────────────────────────────────────────────────────────── + +def remove_edge(a: str, b: str, adj: dict[str, set[str]], edges: list) -> None: + adj[a].discard(b) + adj[b].discard(a) + edges[:] = [ + e for e in edges + if not ((e[0] == a and e[1] == b) or (e[0] == b and e[1] == a)) + ] + + +def remove_edges_to_target( + nodes: list[dict], + edges: list, + adj: dict[str, set[str]], + target: int, + mode: str = "default", +) -> int: + """ + Remove up to `target` non-bridge edges using the given priority mode. + mode: "core" — only remove edges where both endpoints are in core sector + "frontier" — prefer deep_frontier and outer band + "default" — combined: core > frontier > outer > rest, weighted by degree + Returns: number of edges actually removed. + """ + node_map = {n["system_id"]: n for n in nodes} + all_ids = [n["system_id"] for n in nodes] + removed = 0 + + while removed < target: + bridge_set = find_bridges(all_ids, adj) + + best_score: Optional[float] = None + best_a: Optional[str] = None + best_b: Optional[str] = None + + for e in edges: + a, b = e[0], e[1] + key = frozenset([a, b]) + + if key in bridge_set: + continue + + da = len(adj.get(a, set())) + db = len(adj.get(b, set())) + if da <= 1 or db <= 1: + continue + + na = node_map.get(a, {}) + nb_d = node_map.get(b, {}) + if na.get("is_gateway") or nb_d.get("is_gateway"): + continue + + sector_a = na.get("geographic_sector", "") + sector_b = nb_d.get("geographic_sector", "") + band_a = na.get("geographic_band", "") + band_b = nb_d.get("geographic_band", "") + + if mode == "core": + if sector_a != "core" or sector_b != "core": + continue + score = -(da + db) + + elif mode == "frontier": + is_frontier = sector_a == "deep_frontier" or sector_b == "deep_frontier" + is_outer = band_a == "outer" or band_b == "outer" + if not (is_frontier or is_outer): + continue + score = -(da + db) + if is_frontier: + score -= 100 + + else: # default + if sector_a == "core" and sector_b == "core": + base = -400 + elif sector_a == "core" or sector_b == "core": + base = -200 + elif sector_a == "deep_frontier" and sector_b == "deep_frontier": + base = -350 + elif sector_a == "deep_frontier" or sector_b == "deep_frontier": + base = -175 + elif band_a == "outer" and band_b == "outer": + base = -100 + elif band_a == "outer" or band_b == "outer": + base = -50 + else: + base = 0 + score = base - (da + db) + + if best_score is None or score < best_score: + best_score = score + best_a, best_b = a, b + + if best_a is None: + print(f" [sculptor] No removable edges at {removed}/{target} (mode={mode})") + break + + remove_edge(best_a, best_b, adj, edges) + removed += 1 + + return removed + + +# ── Edge addition ───────────────────────────────────────────────────────────── + +def bfs_path_length(adj: dict[str, set[str]], start: str, end: str) -> int: + """BFS shortest path length from start to end. Returns 9999 if unreachable.""" + if start == end: + return 0 + visited = {start} + queue = deque([(start, 0)]) + while queue: + node, dist = queue.popleft() + for nb in adj.get(node, set()): + if nb == end: + return dist + 1 + if nb not in visited: + visited.add(nb) + queue.append((nb, dist + 1)) + return 9999 + + +def add_edge(a: str, b: str, adj: dict[str, set[str]], edges: list) -> None: + adj[a].add(b) + adj[b].add(a) + edges.append([a, b]) + + +def add_loop_edges( + nodes: list[dict], + edges: list, + adj: dict[str, set[str]], + target: int, + min_path: int = 4, + max_path: int = 10, +) -> int: + """ + Add up to `target` loop-forming edges. + Connects pairs of nodes that are already connected by a path of length + [min_path, max_path] — adding the shortcut edge creates a cycle, converting + nodes on that path to loop_members. + Prioritises same-sector pairs on inner/core bands. + Avoids gateway (S-001) and avoids edges that already exist. + Returns: number of edges added. + """ + node_map = {n["system_id"]: n for n in nodes} + all_ids = [n["system_id"] for n in nodes] + edges_set = {(e[0], e[1]) for e in edges} | {(e[1], e[0]) for e in edges} + added = 0 + + # Build candidate pairs: same sector, reasonable degree (not already hubs) + # and not already connected + candidates: list[tuple[str, str, int]] = [] # (a, b, score) + + for i, na in enumerate(nodes): + a = na["system_id"] + if a == "S-001": + continue + da = len(adj.get(a, set())) + if da >= 5: # don't inflate hubs + continue + sector_a = na.get("geographic_sector", "") + band_a = na.get("geographic_band", "") + + for nb in nodes[i + 1:]: + b = nb["system_id"] + if b == "S-001": + continue + if (a, b) in edges_set: + continue + db = len(adj.get(b, set())) + if db >= 5: + continue + sector_b = nb.get("geographic_sector", "") + band_b = nb.get("geographic_band", "") + + # Must be same sector for clean loop topology + if sector_a != sector_b: + continue + + # Score: prefer inner-band pairs + score = 0 + if band_a in ("inner", "core"): + score += 1 + if band_b in ("inner", "core"): + score += 1 + candidates.append((a, b, score)) + + # Sort by score desc, then shuffle within score groups for variety + import random as _random + _random.shuffle(candidates) + candidates.sort(key=lambda x: -x[2]) + + for a, b, _ in candidates: + if added >= target: + break + # Check current path length — must be in [min_path, max_path] + # (if shorter, adding this edge just creates a tiny cycle; if longer, + # the loop created spans too many systems to classify cleanly) + if (a, b) in edges_set or (b, a) in edges_set: + continue + path_len = bfs_path_length(adj, a, b) + if min_path <= path_len <= max_path: + add_edge(a, b, adj, edges) + edges_set.add((a, b)) + edges_set.add((b, a)) + added += 1 + + return added + + +# ── Reporting ───────────────────────────────────────────────────────────────── + +def print_distribution(counts: dict[str, int], total: int, label: str) -> None: + print(f"\n {label}:") + order = ["dead_end", "spur_end", "through_route", "loop_member", "junction", "hub"] + for t in order: + c = counts.get(t, 0) + pct = c / total * 100 + tgt = TARGETS.get(t, 0) * 100 + delta = pct - tgt + flag = " <<<" if abs(delta) > 4 else "" + print(f" {t:<14} {c:>4} ({pct:5.1f}%) target={tgt:.0f}% delta={delta:+.1f}%{flag}") + + +# ── Main ─────────────────────────────────────────────────────────────────────── + +def main() -> None: + print(f"Reading {INPUT_JSON} ...") + with open(INPUT_JSON) as f: + data = json.load(f) + + nodes: list[dict] = data["nodes"] + edges: list = data["edges"] + total = len(nodes) + print(f" {total} nodes, {len(edges)} edges") + + # Verify edge format + sample = edges[0] + assert isinstance(sample, list) and len(sample) == 2, \ + f"Unexpected edge format: {sample}" + + adj = build_adjacency(edges) + all_ids = [n["system_id"] for n in nodes] + + assert is_connected(all_ids, adj), "ERROR: Input graph is not connected!" + print(" Connectivity: OK") + + print_distribution(count_topology(nodes, adj), total, "BEFORE") + + # ── Diagnostic: core sector ────────────────────────────────────────────── + node_map = {n["system_id"]: n for n in nodes} + core_nodes = [n for n in nodes if n.get("geographic_sector") == "core"] + core_degs = sorted( + [(n["system_id"], len(adj.get(n["system_id"], set()))) for n in core_nodes], + key=lambda x: -x[1] + ) + print(f"\n Core sector: {len(core_nodes)} nodes") + print(" Top core degrees:") + for nid, deg in core_degs[:10]: + print(f" {nid}: deg={deg}") + + # ── Pass 1: Add loop-forming edges ─────────────────────────────────────── + # Generation now leaves the graph sparse (too many dead_ends, ~0 loops). + # Add edges that form clean cycles of length 4-10 within the same sector. + # Target: push loop_member count toward ~45 (15% of 300). + # We add more than strictly needed here because Pass 3/4 may remove some. + target_lm = int(TARGETS["loop_member"] * total) # 45 + current_lm = count_topology(nodes, adj).get("loop_member", 0) + loops_to_add = max(0, target_lm - current_lm + 10) # +10 cushion + if loops_to_add > 0: + print(f"\n Pass 1: add loop-forming edges (target +{loops_to_add})...") + r1 = add_loop_edges(nodes, edges, adj, loops_to_add, min_path=4, max_path=10) + print(f" Added {r1}. Edges: {len(edges)}") + assert is_connected(all_ids, adj), "ERROR: Disconnected after pass 1!" + reclassify_all(nodes, adj) + print_distribution(count_topology(nodes, adj), total, "After pass 1") + + # ── Pass 2: Strip core sector ──────────────────────────────────────────── + # Core hub count may be inflated. Strip non-bridge core edges to bring + # hub count to ~3 in core (Gateway + 2 others). + core_hubs = sum( + 1 for n in nodes + if n.get("geographic_sector") == "core" + and len(adj.get(n["system_id"], set())) >= 5 + ) + core_strip = max(0, (core_hubs - 3) * 2) # rough: each hub removal ~2 edges + if core_strip > 0: + print(f"\n Pass 2: core sector strip (target -{core_strip} edges)...") + r2 = remove_edges_to_target(nodes, edges, adj, core_strip, mode="core") + print(f" Removed {r2}. Edges: {len(edges)}") + assert is_connected(all_ids, adj), "ERROR: Disconnected after pass 2!" + reclassify_all(nodes, adj) + print_distribution(count_topology(nodes, adj), total, "After pass 2") + + # ── Pass 3: Frontier and outer strip ───────────────────────────────────── + # Deep frontier should be sparse. Remove excess connections there. + frontier_hubs = sum( + 1 for n in nodes + if n.get("geographic_sector") == "deep_frontier" + and len(adj.get(n["system_id"], set())) >= 5 + ) + frontier_strip = frontier_hubs * 2 + if frontier_strip > 0: + print(f"\n Pass 3: frontier/outer strip (target -{frontier_strip} edges)...") + r3 = remove_edges_to_target(nodes, edges, adj, frontier_strip, mode="frontier") + print(f" Removed {r3}. Edges: {len(edges)}") + assert is_connected(all_ids, adj), "ERROR: Disconnected after pass 3!" + reclassify_all(nodes, adj) + print_distribution(count_topology(nodes, adj), total, "After pass 3") + + # ── Pass 4: General cleanup to target edge count ───────────────────────── + # Target ~330 edges for 300 nodes gives an average degree of 2.2, + # consistent with the target distribution (lots of dead_ends + through_routes, + # moderate loops, fewer junctions/hubs). + target_edges = 330 + current_edges = len(edges) + if current_edges > target_edges: + need = current_edges - target_edges + print(f"\n Pass 4: general cleanup (target -{need} edges to reach {target_edges})...") + r4 = remove_edges_to_target(nodes, edges, adj, need, mode="default") + print(f" Removed {r4}. Edges: {len(edges)}") + assert is_connected(all_ids, adj), "ERROR: Disconnected after pass 4!" + reclassify_all(nodes, adj) + c4 = count_topology(nodes, adj) + print_distribution(c4, total, "After pass 4") + else: + reclassify_all(nodes, adj) + c4 = count_topology(nodes, adj) + + # ── Fine-tune: loop_member deficit? Add more loops ──────────────────────── + lm_now = c4.get("loop_member", 0) + tolerance = int(total * 0.04) # 4pp = 12 systems + if lm_now < target_lm - tolerance: + deficit = target_lm - lm_now + print(f"\n Fine-tune A: {lm_now} loop_members < target {target_lm}, +{deficit} edges...") + add_loop_edges(nodes, edges, adj, deficit, min_path=4, max_path=12) + assert is_connected(all_ids, adj), "ERROR: Disconnected after fine-tune A!" + reclassify_all(nodes, adj) + cf = count_topology(nodes, adj) + print_distribution(cf, total, "After fine-tune A") + c4 = cf + + # ── Fine-tune: too many loop_members? Strip more ────────────────────────── + lm_now = c4.get("loop_member", 0) + if lm_now > target_lm + tolerance: + extra = min((lm_now - target_lm) // 3, 20) + print(f"\n Fine-tune B: {lm_now} loop_members > target {target_lm}, -{extra} edges...") + remove_edges_to_target(nodes, edges, adj, extra, mode="default") + assert is_connected(all_ids, adj), "ERROR: Disconnected after fine-tune B!" + reclassify_all(nodes, adj) + cf = count_topology(nodes, adj) + print_distribution(cf, total, "After fine-tune B") + + # ── Update metadata ────────────────────────────────────────────────────── + data["_meta"]["edge_count"] = len(edges) + data["_meta"]["sculpted"] = "2026-03-13" + data["_meta"]["sculpt_note"] = ( + "Sculpted topology: loop edges added, excess core/frontier edges removed. " + "Reclassified all nodes. Aperture counts updated." + ) + + print(f"\n Writing {OUTPUT_JSON} ...") + with open(OUTPUT_JSON, "w") as f: + json.dump(data, f, indent=2) + print(" Written.") + + # ── Final report ───────────────────────────────────────────────────────── + print("\n" + "=" * 60) + print("FINAL TOPOLOGY SUMMARY") + print("=" * 60) + final = count_topology(nodes, adj) + print_distribution(final, total, "Final distribution") + print(f"\n Nodes: {total} Edges: {len(edges)}") + + print("\n Breakdown by sector:") + st: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) + for n in nodes: + st[n.get("geographic_sector", "?")][n["gate_topology"]] += 1 + for sec in sorted(st.keys()): + sd = st[sec] + n_in = sum(sd.values()) + parts = " ".join(f"{t}={c}" for t, c in sorted(sd.items())) + print(f" {sec:<15} n={n_in:>3} {parts}") + + print("\n Core sector final degrees:") + core_final = [ + (n["system_id"], len(adj.get(n["system_id"], set())), n["gate_topology"]) + for n in nodes if n.get("geographic_sector") == "core" + ] + core_final.sort(key=lambda x: -x[1]) + for nid, deg, topo in core_final: + print(f" {nid}: deg={deg} {topo}") + + # Verify aperture >= connections for all nodes + violations = [ + n for n in nodes + if n.get("aperture_count", 0) < n.get("gate_connections", 0) + ] + if violations: + print(f"\n WARNING: {len(violations)} nodes have aperture < connections!") + for v in violations: + print(f" {v['system_id']}: aperture={v['aperture_count']} < connections={v['gate_connections']}") + else: + print("\n Aperture >= connections: OK (all nodes)") + + +if __name__ == "__main__": + main() diff --git a/tooling/star-map-seed.json b/tooling/star-map-seed.json new file mode 100644 index 000000000..c66196138 --- /dev/null +++ b/tooling/star-map-seed.json @@ -0,0 +1,185 @@ +{ + "_comment": "Star map generation seed configuration. All 300 systems in this map have horizon stations — systems without stations are simply not included. The ~2500 other nearby stars without stations exist in-setting but are not graph nodes.", + + "random_seed": 20260313, + + "system_count": 300, + + "sector_distribution": { + "core": 25, + "north_reach": 50, + "west_reach": 50, + "south_reach": 50, + "east_reach": 50, + "deep_frontier": 75 + }, + + "_comment_deep_frontier": "deep_frontier is larger than any single cardinal sector because it wraps the outer edge of all four cardinal directions. The extra 25 systems (vs 50 per cardinal) represent the scattered outer activation edge.", + + "gateway": { + "_comment": "The Gateway system. Pre-seeded. First hop from Earth. Former Grand Central, now institutional/surveillance. Assembly capital is elsewhere.", + "system_id": "S-001", + "geographic_sector": "core", + "geographic_band": "core", + "political_zone": "diplomatic_periphery", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 4, + "_comment_apertures": "5 apertures total: 4 active connections into the Reach + 1 dormant Sol-facing aperture. gate_connections = 4 (Sol aperture is not a traversable connection).", + "settlement_wave": "wave_1", + "is_gateway": true, + "pre_placed": true + }, + + "topology_targets": { + "_comment": "Target percentage distribution. Checked during Phase 6 validation. Tolerance: +/- 3 percentage points per category.", + "dead_end": 0.20, + "spur_end": 0.15, + "through_route": 0.25, + "loop_member": 0.15, + "junction": 0.18, + "hub": 0.07 + }, + + "hub_count_targets": { + "_comment": "Hub count per sector. Algorithm targets exactly this many hubs per sector during augmentation. Deep frontier is sparse — 0 forced hubs; organic hubs may still arise from cross-sector bridge augmentation.", + "core": 2, + "north_reach": 2, + "west_reach": 2, + "south_reach": 2, + "east_reach": 2, + "deep_frontier": 0 + }, + + "cross_sector_connection_targets": { + "_comment": "Target number of connections crossing each sector boundary. These become chokepoints.", + "core_to_north": 3, + "core_to_west": 3, + "core_to_south": 3, + "core_to_east": 3, + "north_to_deep_frontier": 2, + "west_to_deep_frontier": 2, + "south_to_deep_frontier": 2, + "east_to_deep_frontier": 2, + "north_to_west": 1, + "west_to_south": 1, + "south_to_east": 1, + "east_to_north": 1 + }, + + "star_type_distribution": { + "_comment": "Spectral type targets. M-type dwarfs are most common. Percentages sum to 1.0.", + "M": 0.47, + "K": 0.27, + "G": 0.13, + "F": 0.06, + "binary": 0.04, + "unusual": 0.03 + }, + + "settlement_wave_by_sector": { + "_comment": "Wave probability weights per sector. Core = Wave 1-2 heavy. Deep frontier = Wave 4-5 heavy. With variation.", + "core": { + "wave_1": 0.45, + "wave_2": 0.35, + "wave_3": 0.12, + "wave_4": 0.05, + "wave_5": 0.02, + "unsettled": 0.01 + }, + "north_reach": { + "wave_1": 0.05, + "wave_2": 0.25, + "wave_3": 0.35, + "wave_4": 0.20, + "wave_5": 0.10, + "unsettled": 0.05 + }, + "west_reach": { + "wave_1": 0.05, + "wave_2": 0.25, + "wave_3": 0.35, + "wave_4": 0.20, + "wave_5": 0.10, + "unsettled": 0.05 + }, + "south_reach": { + "wave_1": 0.05, + "wave_2": 0.25, + "wave_3": 0.35, + "wave_4": 0.20, + "wave_5": 0.10, + "unsettled": 0.05 + }, + "east_reach": { + "wave_1": 0.05, + "wave_2": 0.25, + "wave_3": 0.35, + "wave_4": 0.20, + "wave_5": 0.10, + "unsettled": 0.05 + }, + "deep_frontier": { + "wave_1": 0.01, + "wave_2": 0.05, + "wave_3": 0.15, + "wave_4": 0.35, + "wave_5": 0.30, + "unsettled": 0.14 + } + }, + + "geographic_band_by_sector": { + "_comment": "Band assignment probabilities per sector. Core uses 'core' band only. Cardinal sectors split inner/outer.", + "core": { + "core": 1.0 + }, + "north_reach": { + "inner": 0.55, + "outer": 0.45 + }, + "west_reach": { + "inner": 0.55, + "outer": 0.45 + }, + "south_reach": { + "inner": 0.55, + "outer": 0.45 + }, + "east_reach": { + "inner": 0.55, + "outer": 0.45 + }, + "deep_frontier": { + "inner": 0.25, + "outer": 0.75 + } + }, + + "augmentation_weights": { + "_comment": "Tuning parameters for Phase 3 augmentation passes.", + "hub_target_degree_min": 5, + "hub_target_degree_max": 6, + "junction_target_degree_min": 3, + "junction_target_degree_max": 3, + "loop_formation_target_count": 20, + "loop_min_path_length": 3, + "loop_max_path_length": 8, + "cross_sector_weight_penalty": 0.3, + "inner_to_inner_weight_bonus": 0.4, + "outer_to_inner_weight_bonus": 0.3, + "unused_aperture_probability": 0.15 + }, + + "validation_tolerances": { + "topology_target_tolerance_pct": 0.06, + "min_hubs_total": 15, + "max_hubs_pct": 0.12, + "dead_end_plus_spur_min_pct": 0.28, + "dead_end_plus_spur_max_pct": 0.45, + "gateway_min_connections": 4, + "gateway_max_connections": 5, + "earth_proximity_immediate_max": 20, + "earth_proximity_proximate_max_hops": 10 + } +} diff --git a/tooling/tune-star-map-topology.py b/tooling/tune-star-map-topology.py new file mode 100644 index 000000000..61547df75 --- /dev/null +++ b/tooling/tune-star-map-topology.py @@ -0,0 +1,901 @@ +#!/usr/bin/env python3 +""" +Star Map Topology Tuner — The Settled Reach +Starting from the current map (300 nodes, 347 edges): + +Current vs Target: + dead_end 12.7% (38) → 20% (60) +22 + spur_end 13.0% (39) → 15% (45) +6 + through_route 29.3% (88) → 25% (75) -13 + loop_member 17.7% (53) → 15% (45) -8 + junction 23.3% (70) → 18% (54) -16 + hub 4.0% (12) → 7% (21) +9 + +Strategy: + Pass A — Promote junctions→hubs: add edges to degree-4 junctions in core/inner + to push them to degree 5+. Target: +9 hubs. + Pass B — Increase dead_ends: remove non-bridge edges from degree-2+ nodes + in deep_frontier/outer to reduce their degree to 1. Target: +22 dead_ends. + Pass C — Reduce loop_members and through_routes: remove non-bridge edges + from loop_member and through_route nodes in frontier sectors. + Pass D — Final cleanup: reduce remaining junctions by stripping edges. + +Constraints: + - Graph must remain fully connected (checked after each pass) + - S-001 (Gateway): degree=4 locked, gate_topology="hub", aperture_count=5 + - Max 8 apertures per node (D-095) + - Targets are guidelines (±2-3%), not hard constraints + +Standard library only. No external dependencies. +""" + +from __future__ import annotations + +import json +from collections import defaultdict, deque +from pathlib import Path +from typing import Optional + +REPO_ROOT = Path(__file__).parent.parent +INPUT_JSON = REPO_ROOT / "docs" / "design" / "star-map.json" +OUTPUT_JSON = INPUT_JSON + +TARGETS = { + "dead_end": 0.20, + "spur_end": 0.15, + "through_route": 0.25, + "loop_member": 0.15, + "junction": 0.18, + "hub": 0.07, +} + +GATEWAY_ID = "S-001" + + +# ── Graph helpers ────────────────────────────────────────────────────────────── + +def build_adjacency(edges: list) -> dict[str, set[str]]: + adj: dict[str, set[str]] = defaultdict(set) + for e in edges: + a, b = e[0], e[1] + adj[a].add(b) + adj[b].add(a) + return adj + + +def is_connected(all_nodes: list[str], adj: dict[str, set[str]]) -> bool: + if not all_nodes: + return True + start = all_nodes[0] + visited = {start} + queue = deque([start]) + while queue: + cur = queue.popleft() + for nb in adj.get(cur, set()): + if nb not in visited: + visited.add(nb) + queue.append(nb) + return len(visited) == len(all_nodes) + + +def find_bridges(all_nodes: list[str], adj: dict[str, set[str]]) -> set[frozenset]: + """Iterative Tarjan bridge-finding. Returns set of frozenset({u, v}).""" + n = len(all_nodes) + idx_map = {v: i for i, v in enumerate(all_nodes)} + disc = [-1] * n + low = [-1] * n + timer = [0] + bridges: set[frozenset] = set() + + for start_node in all_nodes: + si = idx_map[start_node] + if disc[si] != -1: + continue + + nbrs_start = sorted(adj.get(start_node, set())) + stack: list[tuple[str, Optional[str], list[str], int]] = [ + (start_node, None, nbrs_start, 0) + ] + disc[si] = low[si] = timer[0] + timer[0] += 1 + + while stack: + node, par, nbr_list, ni = stack[-1] + node_i = idx_map[node] + + if ni < len(nbr_list): + nb = nbr_list[ni] + stack[-1] = (node, par, nbr_list, ni + 1) + nb_i = idx_map[nb] + if disc[nb_i] == -1: + disc[nb_i] = low[nb_i] = timer[0] + timer[0] += 1 + nb_nbrs = sorted(adj.get(nb, set())) + stack.append((nb, node, nb_nbrs, 0)) + elif nb != par: + low[node_i] = min(low[node_i], disc[nb_i]) + else: + stack.pop() + if par is not None: + par_i = idx_map[par] + low[par_i] = min(low[par_i], low[node_i]) + if low[node_i] > disc[par_i]: + bridges.add(frozenset([par, node])) + + return bridges + + +def find_cycle_members(all_nodes: list[str], adj: dict[str, set[str]]) -> set[str]: + bridge_set = find_bridges(all_nodes, adj) + on_cycle: set[str] = set() + for node in all_nodes: + neighbors = adj.get(node, set()) + if len(neighbors) < 2: + continue + non_bridge_count = sum( + 1 for nb in neighbors + if frozenset([node, nb]) not in bridge_set + ) + if non_bridge_count > 0: + on_cycle.add(node) + return on_cycle + + +def classify_node( + node_id: str, + adj: dict[str, set[str]], + cycle_members: set[str], + is_gateway: bool, +) -> str: + if is_gateway: + return "hub" + deg = len(adj.get(node_id, set())) + if deg <= 1: + return "dead_end" + if deg == 2: + if node_id in cycle_members: + return "loop_member" + neighbors = list(adj[node_id]) + nb_degs = [len(adj.get(nb, set())) for nb in neighbors] + if any(d >= 3 for d in nb_degs): + return "spur_end" + return "through_route" + if deg <= 4: + return "junction" + return "hub" + + +def reclassify_all(nodes: list[dict], adj: dict[str, set[str]]) -> None: + all_ids = [n["system_id"] for n in nodes] + cycle_members = find_cycle_members(all_ids, adj) + for n in nodes: + nid = n["system_id"] + is_gw = n.get("is_gateway", False) + topo = classify_node(nid, adj, cycle_members, is_gw) + n["gate_topology"] = topo + deg = len(adj.get(nid, set())) + n["gate_connections"] = deg + if is_gw: + n["aperture_count"] = max(5, deg) # +1 for dormant Sol aperture + else: + n["aperture_count"] = deg + + +def count_topology(nodes: list[dict], adj: dict[str, set[str]]) -> dict[str, int]: + all_ids = [n["system_id"] for n in nodes] + cycle_members = find_cycle_members(all_ids, adj) + counts: dict[str, int] = defaultdict(int) + for n in nodes: + t = classify_node(n["system_id"], adj, cycle_members, n.get("is_gateway", False)) + counts[t] += 1 + return dict(counts) + + +def remove_edge(a: str, b: str, adj: dict[str, set[str]], edges: list) -> None: + adj[a].discard(b) + adj[b].discard(a) + edges[:] = [ + e for e in edges + if not ((e[0] == a and e[1] == b) or (e[0] == b and e[1] == a)) + ] + + +def add_edge(a: str, b: str, adj: dict[str, set[str]], edges: list) -> None: + adj[a].add(b) + adj[b].add(a) + edges.append([a, b]) + + +def print_distribution(counts: dict[str, int], total: int, label: str) -> None: + print(f"\n {label}:") + order = ["dead_end", "spur_end", "through_route", "loop_member", "junction", "hub"] + for t in order: + c = counts.get(t, 0) + pct = c / total * 100 + tgt = TARGETS.get(t, 0) * 100 + delta = pct - tgt + flag = " <<<" if abs(delta) > 4 else "" + print(f" {t:<14} {c:>4} ({pct:5.1f}%) target={tgt:.0f}% delta={delta:+.1f}%{flag}") + + +# ── Pass A: Promote junctions to hubs by adding edges ───────────────────────── + +def promote_junctions_to_hubs( + nodes: list[dict], + edges: list, + adj: dict[str, set[str]], + target_hub_count: int, +) -> int: + """ + Find degree-4 junctions in core/north_reach/east_reach/south_reach/west_reach + inner bands, and add edges to push them to degree 5 (hub). + + Strategy: for each candidate hub-target, find another degree-3 or degree-4 + node in the same sector that is NOT already connected to it, with BFS + distance 2-6. Prefer nodes whose degree would become 4 (stay junction) + rather than going to 5 themselves. + + Returns number of hub promotions achieved. + """ + node_map = {n["system_id"]: n for n in nodes} + all_ids = [n["system_id"] for n in nodes] + edges_set = {frozenset(e[:2]) for e in edges} + + # Count current hubs + cycle_members = find_cycle_members(all_ids, adj) + current_hubs = sum( + 1 for n in nodes + if classify_node(n["system_id"], adj, cycle_members, n.get("is_gateway", False)) == "hub" + ) + hubs_needed = target_hub_count - current_hubs + if hubs_needed <= 0: + print(f" Pass A: already at {current_hubs} hubs, target={target_hub_count}. Skipping.") + return 0 + + print(f" Pass A: promoting junctions to hubs. Need {hubs_needed} more hubs.") + promoted = 0 + + # Priority sectors for hub promotion + priority_sectors = {"core", "north_reach", "east_reach", "south_reach", "west_reach"} + # Priority bands: inner > mid > outer + band_rank = {"inner": 3, "core": 3, "mid": 2, "outer": 1} + + # Find all degree-4 junctions in priority sectors (potential hub candidates) + candidates = [] + for n in nodes: + nid = n["system_id"] + if nid == GATEWAY_ID: + continue + deg = len(adj.get(nid, set())) + if deg != 4: + continue + sector = n.get("geographic_sector", "") + if sector not in priority_sectors: + continue + band = n.get("geographic_band", "") + br = band_rank.get(band, 0) + candidates.append((nid, sector, br, deg)) + + # Sort by band rank desc (prefer inner/core band nodes) + candidates.sort(key=lambda x: -x[2]) + + for (cand_id, cand_sector, cand_br, _) in candidates: + if promoted >= hubs_needed: + break + + # Find a suitable neighbor to connect to + # Must be: same sector, not already connected, degree <= 4 (won't become hub itself), + # not gateway, BFS distance 2-6 + current_deg = len(adj.get(cand_id, set())) + if current_deg >= 5: + # Already became a hub from a previous promotion + continue + + best_partner = None + best_partner_score = -999 + + for n2 in nodes: + n2id = n2["system_id"] + if n2id == GATEWAY_ID or n2id == cand_id: + continue + if frozenset([cand_id, n2id]) in edges_set: + continue + deg2 = len(adj.get(n2id, set())) + # Don't connect if it would push n2 over 8 apertures + if deg2 >= 8: + continue + # Prefer same sector, allow adjacent sectors + sector2 = n2.get("geographic_sector", "") + if sector2 != cand_sector: + continue + band2 = n2.get("geographic_band", "") + + # Prefer connecting to a junction (deg 3-4) so it stays junction + # rather than becoming a hub itself + if deg2 >= 5: + continue # skip — would just inflate an existing hub + + # BFS distance check — avoid trivially short connections + # (we don't want to make a multi-edge or a triangle that kills topology) + # We do a lightweight BFS for this check + bfs_dist = _bfs_dist(adj, cand_id, n2id) + if bfs_dist < 2 or bfs_dist > 8: + continue + + # Score: prefer closer BFS distance (but not adjacent), prefer same band + br2 = band_rank.get(band2, 0) + score = br2 * 10 - abs(bfs_dist - 3) + if score > best_partner_score: + best_partner_score = score + best_partner = n2id + + if best_partner is None: + print(f" {cand_id}: no suitable partner found, skipping") + continue + + # Add the edge + add_edge(cand_id, best_partner, adj, edges) + edges_set.add(frozenset([cand_id, best_partner])) + promoted += 1 + new_deg = len(adj.get(cand_id, set())) + new_deg2 = len(adj.get(best_partner, set())) + print(f" +edge {cand_id}(deg {new_deg}) <-> {best_partner}(deg {new_deg2})") + + assert is_connected(all_ids, adj), "ERROR: Disconnected after Pass A!" + return promoted + + +def _bfs_dist(adj: dict[str, set[str]], start: str, end: str) -> int: + if start == end: + return 0 + visited = {start} + queue = deque([(start, 0)]) + while queue: + node, dist = queue.popleft() + if dist >= 9: + return 9999 + for nb in adj.get(node, set()): + if nb == end: + return dist + 1 + if nb not in visited: + visited.add(nb) + queue.append((nb, dist + 1)) + return 9999 + + +# ── Pass B: Create dead_ends by removing edges ──────────────────────────────── + +def create_dead_ends( + nodes: list[dict], + edges: list, + adj: dict[str, set[str]], + target_dead_end_count: int, + all_ids: list[str], +) -> int: + """ + Remove edges to increase dead_end count toward target. + + For each edge removal, we look for a degree-2 node in deep_frontier/outer + where one of its incident edges is NOT a bridge AND removing it leaves + that node at degree 1 (dead_end). We do this carefully: + - The node being demoted: must have degree 2 now (after removal: degree 1 = dead_end) + - The removed edge must NOT be a bridge (so graph stays connected) + OR: it IS a bridge but the other endpoint has degree >= 3 so removing it + just splits off a dead_end leaf, which is fine (the leaf stays connected + to the rest via its remaining edge... wait, no — a bridge removal always + disconnects). So we must only remove non-bridges OR handle the special + case where the node being made dead_end has degree 2 and removing ONE + edge keeps the remaining edge still connecting it to the graph. + + Actually: for a degree-2 node, BOTH its edges are bridges (removing either + disconnects the portion of the graph accessible only through that node's + chain). So we need a different approach: + + For a degree-3 node: it has 3 edges. If at least one is NOT a bridge, + we can remove it — the node drops to degree 2 (becomes spur_end/through_route). + That's not a dead_end directly. + + Better approach: find degree-2 nodes (spur_ends or through_routes) where + one edge IS technically "safe" to remove — meaning the other end of that + edge has degree >= 3, so after removal the graph remains connected + (the degree-2 node becomes a dead_end hanging off its one remaining neighbor, + which still has degree >= 2 connecting it to the rest of the graph). + + Wait — if (A-B-C) and B has degree 2, edge A-B and B-C both connect B. + Removing A-B: B becomes dead_end (degree 1, connected via B-C). A drops by 1. + The graph remains connected AS LONG AS A is still connected to the rest. + A is connected to the rest via its other edges (since A had degree >= 2 and + we only removed one edge — A must have degree >= 2 so after removal A has + degree >= 1). If A had degree exactly 2, it now has degree 1 — and A is now + a dead_end too! That might be acceptable, but let's prefer A has degree >= 3 + so A stays as junction/hub. + + So the rule: pick a node B with degree 2. Find which of B's two neighbors + (call it A) has degree >= 3. Remove edge A-B. B becomes dead_end, A stays + junction/hub. Graph remains connected. + + This always works and never disconnects the graph. + + Returns: number of dead_ends created. + """ + node_map = {n["system_id"]: n for n in nodes} + + # Priority sectors: deep_frontier, then outer band + def node_priority(nid: str) -> int: + n = node_map.get(nid, {}) + sector = n.get("geographic_sector", "") + band = n.get("geographic_band", "") + if sector == "deep_frontier": + return 3 + if band == "outer": + return 2 + if band == "mid": + return 1 + return 0 + + created = 0 + # Count current dead_ends + cycle_members = find_cycle_members(all_ids, adj) + current_de = sum( + 1 for n in nodes + if classify_node(n["system_id"], adj, cycle_members, n.get("is_gateway", False)) == "dead_end" + ) + needed = target_dead_end_count - current_de + if needed <= 0: + print(f" Pass B: already at {current_de} dead_ends, target={target_dead_end_count}. Skipping.") + return 0 + + print(f" Pass B: creating dead_ends. Need {needed} more.") + + max_iterations = needed * 8 # safety limit + iteration = 0 + skipped = set() # edges that would disconnect the graph + + while created < needed and iteration < max_iterations: + iteration += 1 + + # Find degree-2 nodes (not gateway) with at least one neighbor of degree >= 3 + best_node = None + best_removable_neighbor = None + best_score = -1 + + for n in nodes: + nid = n["system_id"] + if nid == GATEWAY_ID: + continue + deg = len(adj.get(nid, set())) + if deg != 2: + continue + + # Check neighbors — find one with deg >= 3 to remove edge toward + neighbors = list(adj[nid]) + # The node being demoted (nid) will keep its OTHER neighbor as its + # sole connection. That other neighbor must have degree >= 2 (i.e., + # it has at least one other connection besides nid) so the remaining + # B-C subgraph stays reachable from the rest of the graph. + for nb in neighbors: + nb_deg = len(adj.get(nb, set())) + if nb_deg < 3 or nb == GATEWAY_ID: + continue + if (nid, nb) in skipped or (nb, nid) in skipped: + continue + # The remaining neighbor (the one we do NOT remove) must have deg >= 2 + other_neighbors = [x for x in neighbors if x != nb] + if not other_neighbors: + continue + other_nb = other_neighbors[0] + other_deg = len(adj.get(other_nb, set())) + if other_deg < 2: + # other_nb is already a dead_end; removing nb-nid would leave + # nid and other_nb disconnected from the rest of the graph. + continue + # Safe: remove edge nid-nb. nid becomes dead_end, graph stays connected. + score = node_priority(nid) * 10 + node_priority(nb) + nb_deg + if score > best_score: + best_score = score + best_node = nid + best_removable_neighbor = nb + + if best_node is None: + print(f" No more suitable degree-2 nodes found after {created} dead_ends created.") + break + + remove_edge(best_node, best_removable_neighbor, adj, edges) + + # Check connectivity after every removal — rollback if disconnected + if not is_connected(all_ids, adj): + # Rollback + adj[best_node].add(best_removable_neighbor) + adj[best_removable_neighbor].add(best_node) + edges.append([best_node, best_removable_neighbor]) + skipped.add((best_node, best_removable_neighbor)) + continue + + created += 1 + new_deg = len(adj.get(best_node, set())) + print(f" -edge {best_node}(now deg {new_deg}) <- {best_removable_neighbor}") + return created + + +# ── Pass C: Reduce loop_members and through_routes ──────────────────────────── + +def reduce_degree2_excess( + nodes: list[dict], + edges: list, + adj: dict[str, set[str]], + all_ids: list[str], + target_lm: int, + target_tr: int, +) -> int: + """ + Reduce loop_member and through_route counts toward targets. + + Method: find loop_member or through_route (degree-2) nodes in frontier/outer + sectors where one neighbor has degree >= 3. Remove the edge to that + high-degree neighbor, converting the degree-2 node to a dead_end. + + This simultaneously reduces loop_member/through_route AND increases dead_end. + We stop when both LM and TR are within tolerance of targets, or we've used + our budget. + + Note: we may already be creating dead_ends in Pass B, so this pass focuses + on reducing excess degree-2 nodes that are loop_members or through_routes. + """ + node_map = {n["system_id"]: n for n in nodes} + total = len(nodes) + tolerance = int(total * 0.03) + + def node_priority(nid: str) -> int: + n = node_map.get(nid, {}) + sector = n.get("geographic_sector", "") + band = n.get("geographic_band", "") + if sector == "deep_frontier": + return 3 + if band == "outer": + return 2 + if band == "mid": + return 1 + return 0 + + removed = 0 + max_budget = 50 # enough budget for up to 50 reductions + + for _ in range(max_budget): + cycle_members = find_cycle_members(all_ids, adj) + counts = { + t: sum(1 for n in nodes if classify_node( + n["system_id"], adj, cycle_members, n.get("is_gateway", False) + ) == t) + for t in ["loop_member", "through_route", "dead_end"] + } + lm_now = counts["loop_member"] + tr_now = counts["through_route"] + + lm_ok = lm_now <= target_lm + tolerance + tr_ok = tr_now <= target_tr + tolerance + + if lm_ok and tr_ok: + print(f" Pass C done: LM={lm_now} TR={tr_now} within tolerance.") + break + + # Find a good candidate to demote + best_node = None + best_nb = None + best_score = -1 + + for n in nodes: + nid = n["system_id"] + if nid == GATEWAY_ID: + continue + deg = len(adj.get(nid, set())) + if deg != 2: + continue + + # Is this node a loop_member or through_route? + topo = classify_node(nid, adj, cycle_members, n.get("is_gateway", False)) + if topo not in ("loop_member", "through_route"): + continue + + # Check if we still need to reduce this type + if topo == "loop_member" and lm_ok: + continue + if topo == "through_route" and tr_ok: + continue + + # Find a neighbor with degree >= 3, AND the OTHER neighbor still + # has degree >= 2 after removal (so nid-other stays connected to graph) + neighbors_list = list(adj[nid]) + for nb in neighbors_list: + nb_deg = len(adj.get(nb, set())) + if nb_deg < 3 or nb == GATEWAY_ID: + continue + others = [x for x in neighbors_list if x != nb] + if not others: + continue + other_deg = len(adj.get(others[0], set())) + if other_deg < 2: + continue # would strand nid with only a dead_end neighbor + score = node_priority(nid) * 10 + nb_deg + if score > best_score: + best_score = score + best_node = nid + best_nb = nb + + if best_node is None: + print(f" Pass C: no more candidates (LM={lm_now}, TR={tr_now}).") + break + + remove_edge(best_node, best_nb, adj, edges) + + if not is_connected(all_ids, adj): + # Rollback + adj[best_node].add(best_nb) + adj[best_nb].add(best_node) + edges.append([best_node, best_nb]) + continue + + removed += 1 + print(f" -edge {best_node} <- {best_nb}") + return removed + + +# ── Pass D: Reduce junction count ───────────────────────────────────────────── + +def reduce_junctions( + nodes: list[dict], + edges: list, + adj: dict[str, set[str]], + all_ids: list[str], + target_junction_count: int, +) -> int: + """ + Reduce junction count by removing edges from degree-3 junctions in + frontier/outer sectors, demoting them to degree-2 (spur_end/through_route). + + Only removes non-bridge edges (to preserve connectivity). + """ + node_map = {n["system_id"]: n for n in nodes} + total = len(nodes) + tolerance = int(total * 0.03) + + def node_priority(nid: str) -> int: + n = node_map.get(nid, {}) + sector = n.get("geographic_sector", "") + band = n.get("geographic_band", "") + if sector == "deep_frontier": + return 3 + if band == "outer": + return 2 + if band == "mid": + return 1 + return 0 + + removed = 0 + max_budget = 50 + + for _ in range(max_budget): + cycle_members = find_cycle_members(all_ids, adj) + counts = { + t: sum(1 for n in nodes if classify_node( + n["system_id"], adj, cycle_members, n.get("is_gateway", False) + ) == t) + for t in ["junction"] + } + j_now = counts["junction"] + + if j_now <= target_junction_count + tolerance: + print(f" Pass D done: junction={j_now} within tolerance (target={target_junction_count}).") + break + + # Find non-bridge edges adjacent to degree-3 junctions in frontier/outer + bridge_set = find_bridges(all_ids, adj) + + best_a = None + best_b = None + best_score = -1 + + for e in edges: + a, b = e[0], e[1] + if frozenset([a, b]) in bridge_set: + continue + if a == GATEWAY_ID or b == GATEWAY_ID: + continue + + da = len(adj.get(a, set())) + db = len(adj.get(b, set())) + + # At least one endpoint should be a degree-3 junction in frontier/outer + pa = node_priority(a) + pb = node_priority(b) + + # Only remove if at least one is degree 3 (demotes to 2) in a priority area + # and the other won't drop below 2 (we don't want to accidentally make more dead_ends) + if da == 3 and pa >= 1 and db >= 3: + score = pa * 10 + pb + db # prefer higher priority, higher degree on the other end + if score > best_score: + best_score = score + best_a, best_b = a, b + elif db == 3 and pb >= 1 and da >= 3: + score = pb * 10 + pa + da + if score > best_score: + best_score = score + best_a, best_b = a, b + + if best_a is None: + print(f" Pass D: no suitable non-bridge degree-3 edges found (junction={j_now}).") + break + + remove_edge(best_a, best_b, adj, edges) + + if not is_connected(all_ids, adj): + adj[best_a].add(best_b) + adj[best_b].add(best_a) + edges.append([best_a, best_b]) + continue + + removed += 1 + da_new = len(adj.get(best_a, set())) + db_new = len(adj.get(best_b, set())) + print(f" -edge {best_a}(now {da_new}) <-> {best_b}(now {db_new})") + return removed + + +# ── Main ─────────────────────────────────────────────────────────────────────── + +def main() -> None: + print(f"Reading {INPUT_JSON} ...") + with open(INPUT_JSON) as f: + data = json.load(f) + + nodes: list[dict] = data["nodes"] + edges: list = data["edges"] + total = len(nodes) + print(f" {total} nodes, {len(edges)} edges") + + sample = edges[0] + assert isinstance(sample, list) and len(sample) == 2, \ + f"Unexpected edge format: {sample!r}" + + adj = build_adjacency(edges) + all_ids = [n["system_id"] for n in nodes] + + # Verify gateway constraints + gw_deg = len(adj.get(GATEWAY_ID, set())) + print(f" Gateway {GATEWAY_ID}: degree={gw_deg}") + assert gw_deg == 4, f"Gateway degree should be 4, got {gw_deg}" + + assert is_connected(all_ids, adj), "ERROR: Input graph is not connected!" + print(" Connectivity: OK") + + before_counts = count_topology(nodes, adj) + print_distribution(before_counts, total, "BEFORE") + + # Targets (rounded) + target_hubs = int(TARGETS["hub"] * total) # 21 + target_dead = int(TARGETS["dead_end"] * total) # 60 + target_lm = int(TARGETS["loop_member"] * total) # 45 + target_tr = int(TARGETS["through_route"] * total) # 75 + target_junction = int(TARGETS["junction"] * total) # 54 + + print(f"\n Targets: hubs={target_hubs} dead_ends={target_dead} " + f"loop_member={target_lm} through_route={target_tr} junction={target_junction}") + + # ── Pass A: Promote junctions to hubs ──────────────────────────────────── + print("\n--- Pass A: Promote junctions to hubs ---") + a_promoted = promote_junctions_to_hubs(nodes, edges, adj, target_hubs) + reclassify_all(nodes, adj) + after_a = count_topology(nodes, adj) + print_distribution(after_a, total, "After Pass A") + print(f" Edges: {len(edges)}") + + # ── Pass B: Create dead_ends ────────────────────────────────────────────── + print("\n--- Pass B: Create dead_ends ---") + b_created = create_dead_ends(nodes, edges, adj, target_dead, all_ids) + reclassify_all(nodes, adj) + after_b = count_topology(nodes, adj) + print_distribution(after_b, total, "After Pass B") + print(f" Edges: {len(edges)}") + + # ── Pass C: Reduce loop_member and through_route excess ─────────────────── + print("\n--- Pass C: Reduce loop_member / through_route excess ---") + c_removed = reduce_degree2_excess(nodes, edges, adj, all_ids, target_lm, target_tr) + reclassify_all(nodes, adj) + after_c = count_topology(nodes, adj) + print_distribution(after_c, total, "After Pass C") + print(f" Edges: {len(edges)} (Pass C removed {c_removed})") + + # ── Pass D: Reduce junction count ───────────────────────────────────────── + print("\n--- Pass D: Reduce junctions ---") + d_removed = reduce_junctions(nodes, edges, adj, all_ids, target_junction) + reclassify_all(nodes, adj) + after_d = count_topology(nodes, adj) + print_distribution(after_d, total, "After Pass D") + print(f" Edges: {len(edges)} (Pass D removed {d_removed})") + + # ── Pass E: Second dead_end pass if still short ──────────────────────────── + de_now = count_topology(nodes, adj).get("dead_end", 0) + tolerance = int(total * 0.03) # 3% = 9 systems + if de_now < target_dead - tolerance: + print(f"\n--- Pass E: Second dead_end pass (have {de_now}, need {target_dead}) ---") + e_created = create_dead_ends(nodes, edges, adj, target_dead, all_ids) + reclassify_all(nodes, adj) + after_e = count_topology(nodes, adj) + print_distribution(after_e, total, "After Pass E") + print(f" Edges: {len(edges)} (Pass E created {e_created} more dead_ends)") + + # ── Pass F: Second junction reduction pass if still high ─────────────────── + j_now = count_topology(nodes, adj).get("junction", 0) + if j_now > target_junction + tolerance: + print(f"\n--- Pass F: Second junction reduction (have {j_now}, target {target_junction}) ---") + f_removed = reduce_junctions(nodes, edges, adj, all_ids, target_junction) + reclassify_all(nodes, adj) + after_f = count_topology(nodes, adj) + print_distribution(after_f, total, "After Pass F") + print(f" Edges: {len(edges)} (Pass F removed {f_removed})") + + # ── Final reclassify and aperture update ────────────────────────────────── + reclassify_all(nodes, adj) + + # ── Verify gateway constraints ──────────────────────────────────────────── + gw_node = next(n for n in nodes if n["system_id"] == GATEWAY_ID) + assert gw_node["gate_topology"] == "hub", "Gateway topology changed!" + assert gw_node["aperture_count"] == 5, f"Gateway aperture_count={gw_node['aperture_count']} (should be 5)" + assert len(adj[GATEWAY_ID]) == 4, f"Gateway degree={len(adj[GATEWAY_ID])} (should be 4)" + print(f"\n Gateway constraint check: OK (degree=4, aperture=5, topology=hub)") + + # ── Verify no aperture violations ──────────────────────────────────────── + violations = [ + n for n in nodes + if n.get("aperture_count", 0) > 8 + ] + if violations: + print(f"\n WARNING: {len(violations)} nodes exceed 8 apertures!") + for v in violations: + print(f" {v['system_id']}: aperture={v['aperture_count']}") + else: + print(" Aperture max-8 constraint: OK") + + # ── Update metadata ─────────────────────────────────────────────────────── + data["_meta"]["edge_count"] = len(edges) + data["_meta"]["tuned"] = "2026-03-13" + data["_meta"]["tune_note"] = ( + "Topology tuning: promoted junctions to hubs, created dead_ends, " + "reduced loop_member/through_route/junction excess. Reclassified all nodes." + ) + + # ── Write output ────────────────────────────────────────────────────────── + print(f"\n Writing {OUTPUT_JSON} ...") + with open(OUTPUT_JSON, "w") as f: + json.dump(data, f, indent=2) + print(" Written.") + + # ── Final summary ───────────────────────────────────────────────────────── + print("\n" + "=" * 60) + print("FINAL TOPOLOGY SUMMARY") + print("=" * 60) + final = count_topology(nodes, adj) + print_distribution(final, total, "Final distribution") + print(f"\n Nodes: {total} Edges: {len(edges)}") + + print("\n Breakdown by sector:") + st: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) + for n in nodes: + st[n.get("geographic_sector", "?")][n["gate_topology"]] += 1 + for sec in sorted(st.keys()): + sd = st[sec] + n_in = sum(sd.values()) + parts = " ".join(f"{t}={c}" for t, c in sorted(sd.items())) + print(f" {sec:<15} n={n_in:>3} {parts}") + + print("\n Core sector final degrees:") + core_final = [ + (n["system_id"], len(adj.get(n["system_id"], set())), n["gate_topology"]) + for n in nodes if n.get("geographic_sector") == "core" + ] + core_final.sort(key=lambda x: -x[1]) + for nid, deg, topo in core_final: + print(f" {nid}: deg={deg} {topo}") + + +if __name__ == "__main__": + main()