diff --git a/.claude/rules/ticket-cli.md b/.claude/rules/ticket-cli.md new file mode 100644 index 000000000..0bbb2e741 --- /dev/null +++ b/.claude/rules/ticket-cli.md @@ -0,0 +1,39 @@ +# Ticket CLI + +**Use `tooling/db/ticket`** for all ticket operations. Never use `sqlite3` directly (crashes in Claude Code). + +## Positional arguments — not flags + +`ticket create` uses **positional** arguments for `type` and `title`. There is no `--title` flag. + +```bash +# CORRECT — type and title are positional +tooling/db/ticket create story "My ticket title" --description "Details here" --team server --priority low + +# WRONG — --title does not exist, gets absorbed into the title string +tooling/db/ticket create story --title "My ticket title" --description "Details here" +# Creates a ticket titled: "--title My ticket title" +``` + +## Full usage + +```bash +# Create +tooling/db/ticket create [--parent N] [--priority P] [--decision D] [--team T] [--description TEXT] +# type: initiative | epic | story | task | bug +# priority: critical | high | medium | low + +# Read +tooling/db/ticket show <id> +tooling/db/ticket list [--sprint N] [--team T] [--status S] + +# Update +tooling/db/ticket assign <id> <agent> +``` + +## Key rules + +- **Type and title are positional** — everything else is a flag +- **Quote the title** — always wrap in double quotes to handle spaces +- **Never use `sqlite3` CLI** — it crashes (std::bad_alloc). Use `tooling/db/sqlite-query` or `tooling/db/sqlite-exec` for raw SQL +- **Verify after create** — run `tooling/db/ticket show <id>` to confirm the title is clean diff --git a/CHANGELOG.md b/CHANGELOG.md index eb0726d3a..59f4ede16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,20 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ### Added - `corridor-status` subcommand for atlas CLI — shows remaining unfinished systems grouped by geographic sector and hop distance (#744) +- Star map insert module — concentric hop-ring view of 301 systems, sector-colored, click-to-select with info panel, pan/zoom (#674) +- BoneAttachment3D overhead anchor above Head bone for future floating UI elements (#712) +- CharacterVisualDescriptor wired into startup IPC and snapshot restore for save/load persistence (#718) +- Display-only hair highlight swatch (auto-derived from primary tint) in character creation (#719) +- Asset manifest fully populated (11 body types, 14 hair, 4 heads, 4 eyebrows, 8 clothing) with regeneration script (#720) +- Sprint 30 acceptance test suite (27 tests across all 5 tickets) ### Fixed - `habitable_planet_count` filter now accepts both "breathable" and "standard" atmosphere values — previously all committed systems reported 0 habitable planets (#762). Systems committed before this fix may have stale `habitable_planet_count = 0`; re-commit to update. - `corridor-status` uses LEFT JOIN so systems without gate records are included in counts - `generate_body_matrix` now emits `atmosphere: "standard"` (was "breathable") to match committed-system conventions +- DirAccess asset scanning replaced with manifest JSON — fixes character creation in exported PCK builds (#720) +- Star map set_insert_active() no longer auto-shows the modal panel (#674) +- Star map insert state propagation wired into main.gd (#674) ## [v0.1.29] — 2026-04-03 diff --git a/CLAUDE.md b/CLAUDE.md index 3be167f61..bae2d468f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,16 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha - Three test tiers: (1) Live server — highest fidelity, (2) MessagePack replay via `Protocol.decode_snapshot()` — for unreachable rooms, (3) TestHarness mock — for UI-only tests where fog data doesn't matter. - `make fixtures-gauntlet` regenerates real server snapshot fixtures from the Gauntlet world. +### GDScript conventions + +**Autoload parse-order rule:** Autoload scripts (`client/scripts/autoloads/`) compile before global `class_name` scripts are registered. Referencing a `class_name` type directly in an autoload causes a parse-time "not declared" error. Pattern: +- Declare fields untyped: `var my_field = null` (comment the intended type) +- Do **not** reference `class_name` types at the top level or in `_ready()` of autoloads +- In method bodies called at runtime (e.g. `apply_snapshot`), use `load()` inline — by then the script is cached and `load()` returns the cached resource without reloading: `var CVD := load("res://scripts/rendering/character_visual_descriptor.gd")` +- Do **not** cache the `load()` result in `_ready()` — `_ready()` fires during autoload init, before the target script is in the resource cache, causing an actual file reload that breaks self-references in scripts using their own `class_name` + +`game_state.gd` (`character_visual_descriptor` field) and `sim_bridge.gd` (`harness` field) follow this pattern. + ### File conventions - Decisions: domain files in `decisions/` (see `decisions/README.md` for index) - Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected) diff --git a/Makefile b/Makefile index 779edff74..e9deea17a 100644 --- a/Makefile +++ b/Makefile @@ -7,11 +7,12 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \ pre-pr-server pre-pr-client pre-pr-content \ fixtures-client fixtures-gauntlet golden-diff golden-update \ - checklist-validate checklist-generate \ + checklist-validate checklist-generate check-star-map \ build-sr-voice run-sr-voice test-voice-mock test-voice-real \ perf-baseline debug-schedule \ test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark \ - screenshot visual-movie test-visual visual-update + screenshot visual-movie test-visual visual-update \ + manifest # --- Configuration --- @@ -62,6 +63,7 @@ help: @echo " make test-visual Run visual golden regression tests" @echo " make visual-update Regenerate visual goldens and stage for commit" @echo "" + @echo " make manifest Regenerate assets/characters/manifest.json from asset dirs (#720)" @echo " make pre-pr Run all pre-PR checks (lint, build, test, validate, fixtures)" @echo " make pre-pr-server Server-scoped pre-PR (lint, build, test, fixtures)" @echo " make pre-pr-client Client-scoped pre-PR (lint, build, test)" @@ -345,6 +347,9 @@ checklist-validate: checklist-generate: @tooling/validate-checklist +check-star-map: + @python3 tooling/generate-star-map-data.py --check + perf-baseline: @tooling/perf-baseline @@ -399,6 +404,12 @@ test-voice-real: cd server && cargo test --test voice_pipeline -- --nocapture @echo "Results: .tmp/voice-test/results.txt" +# --- Asset manifest --- + +manifest: + @tooling/generate-character-manifest + @echo "Manifest regenerated — commit client/assets/characters/manifest.json if changed." + # --- Clean --- clean: diff --git a/client/assets/characters/manifest.json b/client/assets/characters/manifest.json index b2876b0ce..83623dc69 100644 --- a/client/assets/characters/manifest.json +++ b/client/assets/characters/manifest.json @@ -1,13 +1,75 @@ { - "body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "teen_m", "teen_f"], - "heads": [], - "hair": ["bob", "buns", "buzzed", "long", "ponytail", "bald"], - "facial_hair": ["beard", "moustache", "mutton_chops"], - "eyebrows": [], + "body_types": [ + "average_f", + "average_m", + "child", + "heavy_f", + "heavy_m", + "muscular_f", + "muscular_m", + "teen_f", + "teen_m", + "thin_f", + "thin_m" + ], + "heads": [ + "head_001", + "head_002", + "head_003", + "head_004" + ], + "hair": [ + "bald", + "balding", + "bob", + "buns", + "buzzed", + "buzzed_female", + "dreads", + "long", + "long_dreads", + "mohawk", + "ponytail", + "ponytail_f", + "simple_parted", + "slick_back" + ], + "facial_hair": [ + "beard", + "moustache", + "mutton_chops" + ], + "eyebrows": [ + "female", + "regular", + "teen", + "thick" + ], "clothing": { - "peasant_tunic": {"slot": "torso"}, - "peasant_pants": {"slot": "legs"}, - "peasant_shoes": {"slot": "feet"} + "boots_work": { + "slot": "feet" + }, + "coveralls_basic": { + "slot": "torso" + }, + "jacket_utility": { + "slot": "torso" + }, + "pants_cargo": { + "slot": "legs" + }, + "peasant_pants": { + "slot": "legs" + }, + "peasant_shoes": { + "slot": "feet" + }, + "peasant_tunic": { + "slot": "torso" + }, + "shirt_henley": { + "slot": "torso" + } }, "accessories": [] } diff --git a/client/data/star_map_data.json b/client/data/star_map_data.json new file mode 100644 index 000000000..a8a40c261 --- /dev/null +++ b/client/data/star_map_data.json @@ -0,0 +1,4363 @@ +{ + "_meta": { + "generated_from": "star-map.json + systems.db", + "system_count": 301, + "edge_count": 335, + "note": "Client-side star map data. Regenerate with: tooling/generate-star-map-data.py" + }, + "nodes": [ + { + "system_id": "GJ 71", + "proper_name": "Tau Ceti", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "hop_distance": 0, + "is_gateway": true + }, + { + "system_id": "GJ 0", + "proper_name": "Sol", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "spur_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 1 + }, + { + "system_id": "GJ 144", + "proper_name": "Ran", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 1 + }, + { + "system_id": "GJ 244A", + "proper_name": "Sirius", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "hop_distance": 1 + }, + { + "system_id": "GJ 559B", + "proper_name": "ACB", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "hop_distance": 1 + }, + { + "system_id": "GJ 725B", + "proper_name": "Struve", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 1 + }, + { + "system_id": "GJ 1221", + "proper_name": "GJ 1221", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 2 + }, + { + "system_id": "GJ 166A", + "proper_name": "Keid", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 2 + }, + { + "system_id": "GJ 251", + "proper_name": "Renaissance", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "hop_distance": 2 + }, + { + "system_id": "GJ 280A", + "proper_name": "Parallax", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 2 + }, + { + "system_id": "GJ 3522", + "proper_name": "Sindri", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 2 + }, + { + "system_id": "GJ 380", + "proper_name": "Groombridge", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 2 + }, + { + "system_id": "GJ 3877", + "proper_name": "Tessera", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "hop_distance": 2 + }, + { + "system_id": "GJ 551", + "proper_name": "Proxima", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "hop_distance": 2 + }, + { + "system_id": "GJ 674", + "proper_name": "Meridian", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 2 + }, + { + "system_id": "GJ 699", + "proper_name": "Barnard's Star", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 2 + }, + { + "system_id": "GJ 702B", + "proper_name": "Prometheus", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 2 + }, + { + "system_id": "GJ 820B", + "proper_name": "Cygni B", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "hop_distance": 2 + }, + { + "system_id": "GJ 845", + "proper_name": "Cairn", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "hub", + "aperture_count": 7, + "gate_connections": 7, + "hop_distance": 2 + }, + { + "system_id": "GJ 1156", + "proper_name": "Travessia", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "hop_distance": 3 + }, + { + "system_id": "GJ 1286", + "proper_name": "Kensho", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "hop_distance": 3 + }, + { + "system_id": "GJ 139", + "proper_name": "Okafor Station", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 3 + }, + { + "system_id": "GJ 15A", + "proper_name": "Lu Ban", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 3 + }, + { + "system_id": "GJ 338B", + "proper_name": "Arbour", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 3 + }, + { + "system_id": "GJ 34A", + "proper_name": "Tanegashima", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 3 + }, + { + "system_id": "GJ 34B", + "proper_name": "Yongjin", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "hop_distance": 3 + }, + { + "system_id": "GJ 406", + "proper_name": "Wolf 359", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "hop_distance": 3 + }, + { + "system_id": "GJ 411", + "proper_name": "Lalande", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "hop_distance": 3 + }, + { + "system_id": "GJ 4274", + "proper_name": "Suzhou", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 3 + }, + { + "system_id": "GJ 447", + "proper_name": "Ross 128", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 3 + }, + { + "system_id": "GJ 570A", + "proper_name": "Bastion", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "hop_distance": 3 + }, + { + "system_id": "GJ 661A", + "proper_name": "Crown's Hollow", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "hop_distance": 3 + }, + { + "system_id": "GJ 663A", + "proper_name": "Johannsen's Star", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 3 + }, + { + "system_id": "GJ 664", + "proper_name": "Caldwell Point", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 3 + }, + { + "system_id": "GJ 729", + "proper_name": "Ross 154", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 3 + }, + { + "system_id": "GJ 764", + "proper_name": "Nova Roma", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "hop_distance": 3 + }, + { + "system_id": "GJ 768", + "proper_name": "Altair", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 3 + }, + { + "system_id": "GJ 780", + "proper_name": "Puerto Rivera", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 3 + }, + { + "system_id": "GJ 783A", + "proper_name": "Aurelius", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "hop_distance": 3 + }, + { + "system_id": "GJ 866A", + "proper_name": "EZ Aqr", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 3 + }, + { + "system_id": "GJ 887", + "proper_name": "Lacaille", + "geographic_sector": "core", + "geographic_band": "core", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 3 + }, + { + "system_id": "GJ 892", + "proper_name": "Cairnside", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 3 + }, + { + "system_id": "GJ 1", + "proper_name": "Grünfeld", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 4 + }, + { + "system_id": "GJ 1002", + "proper_name": "Caparica", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 4 + }, + { + "system_id": "GJ 105A", + "proper_name": "Nová Tržnice", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 4 + }, + { + "system_id": "GJ 1061", + "proper_name": "Crux Station", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "hop_distance": 4 + }, + { + "system_id": "GJ 1111", + "proper_name": "Zenzele", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "hop_distance": 4 + }, + { + "system_id": "GJ 1230A", + "proper_name": "Uusi Vantaa", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 4 + }, + { + "system_id": "GJ 1245A", + "proper_name": "Okoro's Claim", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 4 + }, + { + "system_id": "GJ 1245B", + "proper_name": "Koeberg", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 4 + }, + { + "system_id": "GJ 19", + "proper_name": "Solheim", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 4 + }, + { + "system_id": "GJ 191", + "proper_name": "Kapteyn's Star", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 4 + }, + { + "system_id": "GJ 234A", + "proper_name": "Carvalhais", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 4 + }, + { + "system_id": "GJ 273", + "proper_name": "Fremantle", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 4 + }, + { + "system_id": "GJ 33", + "proper_name": "96 Piscium", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 4 + }, + { + "system_id": "GJ 3618", + "proper_name": "Pemberton's Loop", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 4 + }, + { + "system_id": "GJ 3622", + "proper_name": "Havnfjord", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 4 + }, + { + "system_id": "GJ 412A", + "proper_name": "Morales' End", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 4 + }, + { + "system_id": "GJ 412B", + "proper_name": "Korhonen", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 4 + }, + { + "system_id": "GJ 473B", + "proper_name": "Nkosi's Bridge", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 4 + }, + { + "system_id": "GJ 53A", + "proper_name": "Bridgepoint", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 4 + }, + { + "system_id": "GJ 54.1", + "proper_name": "Ribeiro's Star", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "hop_distance": 4 + }, + { + "system_id": "GJ 566A", + "proper_name": "Haodu", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "hop_distance": 4 + }, + { + "system_id": "GJ 566B", + "proper_name": "Confluência", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "hub", + "aperture_count": 6, + "gate_connections": 6, + "hop_distance": 4 + }, + { + "system_id": "GJ 628", + "proper_name": "Brennan's Drift", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 4 + }, + { + "system_id": "GJ 65A", + "proper_name": "UV Ceti", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 4 + }, + { + "system_id": "GJ 667A", + "proper_name": "New Ballarat", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 4 + }, + { + "system_id": "GJ 667B", + "proper_name": "Kampala Gate", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "hop_distance": 4 + }, + { + "system_id": "GJ 66B", + "proper_name": "Voss", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 4 + }, + { + "system_id": "GJ 673", + "proper_name": "Lindström Station", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 4 + }, + { + "system_id": "GJ 68", + "proper_name": "Schuilhoek", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 4 + }, + { + "system_id": "GJ 687", + "proper_name": "Matsue", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 4 + }, + { + "system_id": "GJ 825", + "proper_name": "Lacaille", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 4 + }, + { + "system_id": "GJ 860A", + "proper_name": "Kruger 60", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 4 + }, + { + "system_id": "GJ 860B", + "proper_name": "Changwon", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 4 + }, + { + "system_id": "GJ 876", + "proper_name": "Koskinen's Reach", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 4 + }, + { + "system_id": "GJ 879", + "proper_name": "Singh's Landing", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 4 + }, + { + "system_id": "GJ 1005A", + "proper_name": "Port Desrochers", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 5 + }, + { + "system_id": "GJ 1116A", + "proper_name": "Mabaso Junction", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 5 + }, + { + "system_id": "GJ 1116B", + "proper_name": "Kraków Junction", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 5 + }, + { + "system_id": "GJ 1289", + "proper_name": "Athabasca", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 5 + }, + { + "system_id": "GJ 178", + "proper_name": "Ashfield", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 5 + }, + { + "system_id": "GJ 183", + "proper_name": "Waterkant", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 5 + }, + { + "system_id": "GJ 205", + "proper_name": "Kurashiki", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 5 + }, + { + "system_id": "GJ 213", + "proper_name": "Kaapse Baai", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 5 + }, + { + "system_id": "GJ 216B", + "proper_name": "Gemini Point", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 5 + }, + { + "system_id": "GJ 222A", + "proper_name": "Karoo", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 5 + }, + { + "system_id": "GJ 229", + "proper_name": "Port Mäkelä", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 5 + }, + { + "system_id": "GJ 250A", + "proper_name": "Carrington's Post", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 5 + }, + { + "system_id": "GJ 285", + "proper_name": "Fenwick's Lantern", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 5 + }, + { + "system_id": "GJ 300", + "proper_name": "Dunmore Stillwater", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 5 + }, + { + "system_id": "GJ 3379", + "proper_name": "Shenwei", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 5 + }, + { + "system_id": "GJ 3820", + "proper_name": "Uusi Vantaa", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 5 + }, + { + "system_id": "GJ 388", + "proper_name": "The Lantern", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "hop_distance": 5 + }, + { + "system_id": "GJ 3991", + "proper_name": "Dunbar", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 5 + }, + { + "system_id": "GJ 4053", + "proper_name": "Dzimbahwe", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 5 + }, + { + "system_id": "GJ 438", + "proper_name": "Relay 7", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 5 + }, + { + "system_id": "GJ 445", + "proper_name": "Jinghu", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 5 + }, + { + "system_id": "GJ 475", + "proper_name": "Braemar", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 5 + }, + { + "system_id": "GJ 506", + "proper_name": "Dokkum", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 5 + }, + { + "system_id": "GJ 526", + "proper_name": "Neustadt", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 5 + }, + { + "system_id": "GJ 54", + "proper_name": "Inhambane", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 5 + }, + { + "system_id": "GJ 588", + "proper_name": "Harrowfield", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 5 + }, + { + "system_id": "GJ 644C", + "proper_name": "Moçâmedes", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 5 + }, + { + "system_id": "GJ 66A", + "proper_name": "p Eridani", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 5 + }, + { + "system_id": "GJ 682", + "proper_name": "Taishan", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 5 + }, + { + "system_id": "GJ 693", + "proper_name": "Mwangaza", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 5 + }, + { + "system_id": "GJ 695A", + "proper_name": "GJ 695A", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 5 + }, + { + "system_id": "GJ 713A", + "proper_name": "Samuels Reach", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 5 + }, + { + "system_id": "GJ 752A", + "proper_name": "Ribeirão", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 5 + }, + { + "system_id": "GJ 754", + "proper_name": "Miryang", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 5 + }, + { + "system_id": "GJ 785", + "proper_name": "Porto Fechado", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 5 + }, + { + "system_id": "GJ 832", + "proper_name": "Hansestadt", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "hub", + "aperture_count": 6, + "gate_connections": 6, + "hop_distance": 5 + }, + { + "system_id": "GJ 873", + "proper_name": "Ashworth Junction", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 5 + }, + { + "system_id": "GJ 877", + "proper_name": "Holmgren's Passage", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 5 + }, + { + "system_id": "GJ 884", + "proper_name": "Matamba", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 5 + }, + { + "system_id": "GJ 905", + "proper_name": "Isibaya", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 5 + }, + { + "system_id": "GJ 908", + "proper_name": "Thornfield Station", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 5 + }, + { + "system_id": "GJ 1125", + "proper_name": "Hwaseong", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 1128", + "proper_name": "Devereux Point", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 137", + "proper_name": "Novaya Zarya", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "hop_distance": 6 + }, + { + "system_id": "GJ 150", + "proper_name": "Rana", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 6 + }, + { + "system_id": "GJ 17", + "proper_name": "Binh Trang", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 6 + }, + { + "system_id": "GJ 2097", + "proper_name": "Nascente", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 216A", + "proper_name": "Kelburn Crossing", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 268", + "proper_name": "Yeongwol", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 299", + "proper_name": "Várzea", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 6 + }, + { + "system_id": "GJ 3135", + "proper_name": "Whitfield Crossing", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 3323", + "proper_name": "Entroncamento", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 6 + }, + { + "system_id": "GJ 3325", + "proper_name": "Calloway Reach", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 3454", + "proper_name": "Eisentor", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 4, + "gate_connections": 4, + "hop_distance": 6 + }, + { + "system_id": "GJ 35", + "proper_name": "Vuurkloof", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 357", + "proper_name": "Altmark", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "hop_distance": 6 + }, + { + "system_id": "GJ 3988", + "proper_name": "GJ 3988", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 402", + "proper_name": "Prospect Hill", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 408", + "proper_name": "Oude Kraal", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 4285", + "proper_name": "Lam's Prospect", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 6 + }, + { + "system_id": "GJ 432A", + "proper_name": "Kaur's Observatory", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 434", + "proper_name": "GJ 434", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 442A", + "proper_name": "Torsby", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 451A", + "proper_name": "Groombridge 1830", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 6 + }, + { + "system_id": "GJ 473A", + "proper_name": "Passagem", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 49", + "proper_name": "Dernier Quai", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 6 + }, + { + "system_id": "GJ 502", + "proper_name": "Meridian", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 6 + }, + { + "system_id": "GJ 536", + "proper_name": "Retiro", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 6 + }, + { + "system_id": "GJ 555", + "proper_name": "Tanaka Station", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 6 + }, + { + "system_id": "GJ 581", + "proper_name": "Rødvik", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 625", + "proper_name": "Songjiang", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 631", + "proper_name": "Bakshi Spur", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 638", + "proper_name": "Greymantle", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 6 + }, + { + "system_id": "GJ 643", + "proper_name": "Marktfeld", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 644A", + "proper_name": "Mavela", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 661B", + "proper_name": "Breëvlei", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 667C", + "proper_name": "Gliese 667", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 6 + }, + { + "system_id": "GJ 680", + "proper_name": "Carnarvon", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 784", + "proper_name": "Kovács Loop", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 809", + "proper_name": "Seiðrmaðr", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 6 + }, + { + "system_id": "GJ 827", + "proper_name": "Solandar", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 6 + }, + { + "system_id": "GJ 829", + "proper_name": "Posto Avançado", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 83.1", + "proper_name": "Farol", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 6 + }, + { + "system_id": "GJ 880", + "proper_name": "Holgersen's Landing", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 6 + }, + { + "system_id": "GJ 896A", + "proper_name": "Nowa Huta", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 6 + }, + { + "system_id": "GJ 103", + "proper_name": "Bestevaer", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 7 + }, + { + "system_id": "GJ 1075", + "proper_name": "Espinho", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 7 + }, + { + "system_id": "GJ 107A", + "proper_name": "Tình Yên", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 7 + }, + { + "system_id": "GJ 117", + "proper_name": "GJ 117", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 1232", + "proper_name": "Gammelstad", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 1235", + "proper_name": "Knotenpunkt", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 7 + }, + { + "system_id": "GJ 124", + "proper_name": "Hạ Long", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 169", + "proper_name": "Posto Avançado", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 7 + }, + { + "system_id": "GJ 172", + "proper_name": "Kaelong", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 7 + }, + { + "system_id": "GJ 208", + "proper_name": "Pereira's Rest", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 7 + }, + { + "system_id": "GJ 231", + "proper_name": "Thorvaldsen's Circuit", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 27", + "proper_name": "Okahandja", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 286", + "proper_name": "Weitblick", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 320", + "proper_name": "Sonnwend", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 325A", + "proper_name": "Purnima", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 7 + }, + { + "system_id": "GJ 325B", + "proper_name": "GJ 325B", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 7 + }, + { + "system_id": "GJ 3442", + "proper_name": "Último Farol", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 7 + }, + { + "system_id": "GJ 356A", + "proper_name": "Lichtung", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 7 + }, + { + "system_id": "GJ 370", + "proper_name": "Traagwater", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 7 + }, + { + "system_id": "GJ 3737", + "proper_name": "Nyrheim", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 393", + "proper_name": "Encrucijada", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "hub", + "aperture_count": 5, + "gate_connections": 5, + "hop_distance": 7 + }, + { + "system_id": "GJ 410", + "proper_name": "Wagtoring", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 7 + }, + { + "system_id": "GJ 423A", + "proper_name": "Marunong", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 423B", + "proper_name": "Quilombo", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 449", + "proper_name": "Murakami's Lantern", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 7 + }, + { + "system_id": "GJ 453", + "proper_name": "Freiholt", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 7 + }, + { + "system_id": "GJ 4A", + "proper_name": "Velha Guarda", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 7 + }, + { + "system_id": "GJ 505A", + "proper_name": "Bergtor", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 508A", + "proper_name": "Stilwater", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 534", + "proper_name": "Stillvakt", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 7 + }, + { + "system_id": "GJ 541", + "proper_name": "Chandra Deep", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 7 + }, + { + "system_id": "GJ 567", + "proper_name": "Fragua", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 7 + }, + { + "system_id": "GJ 603", + "proper_name": "Mossbank", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 7 + }, + { + "system_id": "GJ 635A", + "proper_name": "Stilmark", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 7 + }, + { + "system_id": "GJ 635B", + "proper_name": "Clearwater Station", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 653", + "proper_name": "Dunmore", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 660A", + "proper_name": "Seongho", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 7 + }, + { + "system_id": "GJ 688", + "proper_name": "Aguas Bravas", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 706", + "proper_name": "Ngata's Star", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 75", + "proper_name": "Enugu", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 7 + }, + { + "system_id": "GJ 79", + "proper_name": "Oshima", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 86", + "proper_name": "Kumasi", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 902", + "proper_name": "GJ 902", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 7 + }, + { + "system_id": "GJ 909A", + "proper_name": "Tam Giang", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 92", + "proper_name": "Pollux", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 7 + }, + { + "system_id": "GJ 1248", + "proper_name": "Haltefenn", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 8 + }, + { + "system_id": "GJ 136", + "proper_name": "Dài Lộ", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 138", + "proper_name": "Sertão", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 142", + "proper_name": "Leuchtturm", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 189", + "proper_name": "Altgrund", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 197", + "proper_name": "Puerto Último", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 8 + }, + { + "system_id": "GJ 211", + "proper_name": "Encruzilhada", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 302", + "proper_name": "Lichtbrücke", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 309", + "proper_name": "Vorposten", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 324A", + "proper_name": "", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 3250", + "proper_name": "Rionegro", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 8 + }, + { + "system_id": "GJ 3333", + "proper_name": "Groenland", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 3533", + "proper_name": "Trung's Crossing", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 3728B", + "proper_name": "Jeonnam", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 414A", + "proper_name": "Vindkast", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 435", + "proper_name": "Mirante", + "geographic_sector": "south_reach", + "geographic_band": "inner", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 454", + "proper_name": "Steinfeld", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 8 + }, + { + "system_id": "GJ 482A", + "proper_name": "Dagat", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 482B", + "proper_name": "Travessa", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 542", + "proper_name": "Kopparhytta", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "loop_member", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 575A", + "proper_name": "Ratnagiri", + "geographic_sector": "north_reach", + "geographic_band": "inner", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 598", + "proper_name": "Helle Wacht", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 620.1A", + "proper_name": "Suối Vàng", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 8 + }, + { + "system_id": "GJ 624", + "proper_name": "Halvøy", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 8 + }, + { + "system_id": "GJ 67", + "proper_name": "Roça Nova", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 8 + }, + { + "system_id": "GJ 737B", + "proper_name": "Punta del Sol", + "geographic_sector": "south_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 8 + }, + { + "system_id": "GJ 798", + "proper_name": "Brückenau", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 848", + "proper_name": "Shimanami", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 914A", + "proper_name": "Droëland", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 8 + }, + { + "system_id": "GJ 95", + "proper_name": "Caledonia's End", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 8 + }, + { + "system_id": "GJ 1073", + "proper_name": "Mabuhay", + "geographic_sector": "east_reach", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 9 + }, + { + "system_id": "GJ 167", + "proper_name": "GJ 167", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 9 + }, + { + "system_id": "GJ 174", + "proper_name": "Tiefenbach", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 9 + }, + { + "system_id": "GJ 194B", + "proper_name": "Pemberton", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 9 + }, + { + "system_id": "GJ 204", + "proper_name": "Pedra Funda", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 9 + }, + { + "system_id": "GJ 349", + "proper_name": "Randgebiet", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 9 + }, + { + "system_id": "GJ 3612", + "proper_name": "Xa Vời", + "geographic_sector": "east_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 9 + }, + { + "system_id": "GJ 425A", + "proper_name": "GJ 425A", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 9 + }, + { + "system_id": "GJ 556", + "proper_name": "Recanto", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 9 + }, + { + "system_id": "GJ 601A", + "proper_name": "Ostmark", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 9 + }, + { + "system_id": "GJ 675", + "proper_name": "Neugrund", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 9 + }, + { + "system_id": "GJ 707", + "proper_name": "Dois Sóis", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 9 + }, + { + "system_id": "GJ 722", + "proper_name": "Skuilplek", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 9 + }, + { + "system_id": "GJ 775", + "proper_name": "Randsholm", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 9 + }, + { + "system_id": "GJ 1267", + "proper_name": "Saudade", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 10 + }, + { + "system_id": "GJ 146", + "proper_name": "GJ 146", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 10 + }, + { + "system_id": "GJ 177", + "proper_name": "GJ 177", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 10 + }, + { + "system_id": "GJ 3403", + "proper_name": "GJ 3403", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 10 + }, + { + "system_id": "GJ 3586", + "proper_name": "Eisfeld", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 10 + }, + { + "system_id": "GJ 395", + "proper_name": "Confluent", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 10 + }, + { + "system_id": "GJ 528A", + "proper_name": "Ouplaas", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 10 + }, + { + "system_id": "GJ 69", + "proper_name": "GJ 69", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 10 + }, + { + "system_id": "GJ 716", + "proper_name": "Murchison", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 10 + }, + { + "system_id": "GJ 868", + "proper_name": "Encontro", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 10 + }, + { + "system_id": "GJ 1292", + "proper_name": "Moyale", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 11 + }, + { + "system_id": "GJ 394", + "proper_name": "GJ 394", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 11 + }, + { + "system_id": "GJ 428A", + "proper_name": "Fluchtburg", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 11 + }, + { + "system_id": "GJ 5", + "proper_name": "GJ 5", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 11 + }, + { + "system_id": "GJ 524", + "proper_name": "GJ 524", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 11 + }, + { + "system_id": "GJ 615", + "proper_name": "Dunkelholz", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 11 + }, + { + "system_id": "GJ 656", + "proper_name": "GJ 656", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 11 + }, + { + "system_id": "GJ 771A", + "proper_name": "Alshain", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 11 + }, + { + "system_id": "GJ 853A", + "proper_name": "GJ 853A", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 11 + }, + { + "system_id": "GJ 354A", + "proper_name": "GJ 354A", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 12 + }, + { + "system_id": "GJ 4056", + "proper_name": "GJ 4056", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 12 + }, + { + "system_id": "GJ 407", + "proper_name": "Einde", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 12 + }, + { + "system_id": "GJ 61", + "proper_name": "Fernlicht", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 12 + }, + { + "system_id": "GJ 616", + "proper_name": "GJ 616", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 12 + }, + { + "system_id": "GJ 770", + "proper_name": "Rekohu", + "geographic_sector": "north_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 12 + }, + { + "system_id": "GJ 127B", + "proper_name": "Echternach", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 13 + }, + { + "system_id": "GJ 201", + "proper_name": "GJ 201", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "gate_topology": "junction", + "aperture_count": 3, + "gate_connections": 3, + "hop_distance": 13 + }, + { + "system_id": "GJ 684B", + "proper_name": "Paso Largo", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 13 + }, + { + "system_id": "GJ 903", + "proper_name": "Errai", + "geographic_sector": "deep_frontier", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 13 + }, + { + "system_id": "GJ 42", + "proper_name": "Langemark", + "geographic_sector": "west_reach", + "geographic_band": "inner", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 14 + }, + { + "system_id": "GJ 481", + "proper_name": "Orchard's End", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 14 + }, + { + "system_id": "GJ 684A", + "proper_name": "Sonnenkap", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "spur_end", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 14 + }, + { + "system_id": "GJ 875", + "proper_name": "The Narrows", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 14 + }, + { + "system_id": "GJ 282A", + "proper_name": "Bout du Chemin", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 15 + }, + { + "system_id": "GJ 282B", + "proper_name": "Travessia", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 15 + }, + { + "system_id": "GJ 532", + "proper_name": "Kettenschmied", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 15 + }, + { + "system_id": "GJ 421B", + "proper_name": "Pedra Seca", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 16 + }, + { + "system_id": "GJ 807", + "proper_name": "Grenzstein", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 16 + }, + { + "system_id": "GJ 904", + "proper_name": "Claraboia", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 16 + }, + { + "system_id": "GJ 3943", + "proper_name": "GJ 3943", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 17 + }, + { + "system_id": "GJ 529", + "proper_name": "Yttermark", + "geographic_sector": "west_reach", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 17 + }, + { + "system_id": "GJ 726", + "proper_name": "GJ 726", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 17 + }, + { + "system_id": "GJ 546", + "proper_name": "Eerste Wacht", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 18 + }, + { + "system_id": "GJ 672", + "proper_name": "Nova Estrada", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 18 + }, + { + "system_id": "GJ 111", + "proper_name": "Helderoog", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 19 + }, + { + "system_id": "GJ 147", + "proper_name": "Entremeio", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 19 + }, + { + "system_id": "GJ 127A", + "proper_name": "Dalim", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 20 + }, + { + "system_id": "GJ 501B", + "proper_name": "Candeia", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 21 + }, + { + "system_id": "GJ 7547", + "proper_name": "Skemeraand", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "through_route", + "aperture_count": 2, + "gate_connections": 2, + "hop_distance": 22 + }, + { + "system_id": "GJ 6711", + "proper_name": "Abzu", + "geographic_sector": "deep_frontier", + "geographic_band": "outer", + "gate_topology": "dead_end", + "aperture_count": 1, + "gate_connections": 1, + "hop_distance": 23 + } + ], + "edges": [ + [ + "GJ 71", + "GJ 244A" + ], + [ + "GJ 71", + "GJ 559B" + ], + [ + "GJ 783A", + "GJ 1" + ], + [ + "GJ 1", + "GJ 664" + ], + [ + "GJ 664", + "GJ 19" + ], + [ + "GJ 19", + "GJ 506" + ], + [ + "GJ 506", + "GJ 442A" + ], + [ + "GJ 442A", + "GJ 3737" + ], + [ + "GJ 3737", + "GJ 414A" + ], + [ + "GJ 414A", + "GJ 542" + ], + [ + "GJ 542", + "GJ 231" + ], + [ + "GJ 764", + "GJ 15A" + ], + [ + "GJ 231", + "GJ 581" + ], + [ + "GJ 581", + "GJ 526" + ], + [ + "GJ 526", + "GJ 628" + ], + [ + "GJ 664", + "GJ 66B" + ], + [ + "GJ 628", + "GJ 729" + ], + [ + "GJ 729", + "GJ 191" + ], + [ + "GJ 191", + "GJ 229" + ], + [ + "GJ 631", + "GJ 1232" + ], + [ + "GJ 1232", + "GJ 624" + ], + [ + "GJ 229", + "GJ 809" + ], + [ + "GJ 505A", + "GJ 454" + ], + [ + "GJ 832", + "GJ 502" + ], + [ + "GJ 502", + "GJ 208" + ], + [ + "GJ 635A", + "GJ 357" + ], + [ + "GJ 357", + "GJ 103" + ], + [ + "GJ 502", + "GJ 453" + ], + [ + "GJ 189", + "GJ 174" + ], + [ + "GJ 3333", + "GJ 534" + ], + [ + "GJ 174", + "GJ 69" + ], + [ + "GJ 69", + "GJ 428A" + ], + [ + "GJ 302", + "GJ 775" + ], + [ + "GJ 775", + "GJ 395" + ], + [ + "GJ 395", + "GJ 771A" + ], + [ + "GJ 251", + "GJ 338B" + ], + [ + "GJ 771A", + "GJ 4056" + ], + [ + "GJ 428A", + "GJ 61" + ], + [ + "GJ 4056", + "GJ 127B" + ], + [ + "GJ 481", + "GJ 903" + ], + [ + "GJ 903", + "GJ 616" + ], + [ + "GJ 127B", + "GJ 42" + ], + [ + "GJ 616", + "GJ 853A" + ], + [ + "GJ 853A", + "GJ 146" + ], + [ + "GJ 146", + "GJ 349" + ], + [ + "GJ 349", + "GJ 309" + ], + [ + "GJ 42", + "GJ 282A" + ], + [ + "GJ 309", + "GJ 286" + ], + [ + "GJ 282A", + "GJ 807" + ], + [ + "GJ 603", + "GJ 3988" + ], + [ + "GJ 370", + "GJ 150" + ], + [ + "GJ 356A", + "GJ 643" + ], + [ + "GJ 3442", + "GJ 211" + ], + [ + "GJ 169", + "GJ 482B" + ], + [ + "GJ 211", + "GJ 117" + ], + [ + "GJ 482B", + "GJ 707" + ], + [ + "GJ 556", + "GJ 1267" + ], + [ + "GJ 707", + "GJ 868" + ], + [ + "GJ 868", + "GJ 204" + ], + [ + "GJ 117", + "GJ 434" + ], + [ + "GJ 1267", + "GJ 524" + ], + [ + "GJ 524", + "GJ 5" + ], + [ + "GJ 682", + "GJ 784" + ], + [ + "GJ 784", + "GJ 1128" + ], + [ + "GJ 189", + "GJ 675" + ], + [ + "GJ 204", + "GJ 138" + ], + [ + "GJ 5", + "GJ 528A" + ], + [ + "GJ 528A", + "GJ 722" + ], + [ + "GJ 675", + "GJ 3403" + ], + [ + "GJ 1128", + "GJ 438" + ], + [ + "GJ 138", + "GJ 92" + ], + [ + "GJ 722", + "GJ 914A" + ], + [ + "GJ 438", + "GJ 566A" + ], + [ + "GJ 107A", + "GJ 660A" + ], + [ + "GJ 908", + "GJ 17" + ], + [ + "GJ 17", + "GJ 172" + ], + [ + "GJ 172", + "GJ 555" + ], + [ + "GJ 555", + "GJ 79" + ], + [ + "GJ 66A", + "GJ 412B" + ], + [ + "GJ 79", + "GJ 848" + ], + [ + "GJ 848", + "GJ 136" + ], + [ + "GJ 3403", + "GJ 656" + ], + [ + "GJ 136", + "GJ 449" + ], + [ + "GJ 34B", + "GJ 860B" + ], + [ + "GJ 860B", + "GJ 713A" + ], + [ + "GJ 320", + "GJ 798" + ], + [ + "GJ 909A", + "GJ 3533" + ], + [ + "GJ 3533", + "GJ 1073" + ], + [ + "GJ 656", + "GJ 354A" + ], + [ + "GJ 1073", + "GJ 482A" + ], + [ + "GJ 4285", + "GJ 635B" + ], + [ + "GJ 286", + "GJ 598" + ], + [ + "GJ 482A", + "GJ 124" + ], + [ + "GJ 684A", + "GJ 532" + ], + [ + "GJ 914A", + "GJ 508A" + ], + [ + "GJ 541", + "GJ 432A" + ], + [ + "GJ 354A", + "GJ 684B" + ], + [ + "GJ 684B", + "GJ 875" + ], + [ + "GJ 508A", + "GJ 661B" + ], + [ + "GJ 124", + "GJ 268" + ], + [ + "GJ 532", + "GJ 904" + ], + [ + "GJ 904", + "GJ 726" + ], + [ + "GJ 726", + "GJ 672" + ], + [ + "GJ 432A", + "GJ 1005A" + ], + [ + "GJ 268", + "GJ 754" + ], + [ + "GJ 661B", + "GJ 183" + ], + [ + "GJ 1005A", + "GJ 105A" + ], + [ + "GJ 672", + "GJ 147" + ], + [ + "GJ 147", + "GJ 127A" + ], + [ + "GJ 105A", + "GJ 445" + ], + [ + "GJ 875", + "GJ 282B" + ], + [ + "GJ 625", + "GJ 423A" + ], + [ + "GJ 754", + "GJ 53A" + ], + [ + "GJ 53A", + "GJ 866A" + ], + [ + "GJ 282B", + "GJ 421B" + ], + [ + "GJ 127A", + "GJ 501B" + ], + [ + "GJ 183", + "GJ 68" + ], + [ + "GJ 501B", + "GJ 7547" + ], + [ + "GJ 423A", + "GJ 3135" + ], + [ + "GJ 421B", + "GJ 3943" + ], + [ + "GJ 598", + "GJ 167" + ], + [ + "GJ 798", + "GJ 601A" + ], + [ + "GJ 3135", + "GJ 205" + ], + [ + "GJ 453", + "GJ 1248" + ], + [ + "GJ 167", + "GJ 324A" + ], + [ + "GJ 172", + "GJ 620.1A" + ], + [ + "GJ 68", + "GJ 892" + ], + [ + "GJ 92", + "GJ 2097" + ], + [ + "GJ 205", + "GJ 687" + ], + [ + "GJ 555", + "GJ 3379" + ], + [ + "GJ 3612", + "GJ 3728B" + ], + [ + "GJ 137", + "GJ 325A" + ], + [ + "GJ 601A", + "GJ 3586" + ], + [ + "GJ 142", + "GJ 425A" + ], + [ + "GJ 2097", + "GJ 752A" + ], + [ + "GJ 687", + "GJ 4274" + ], + [ + "GJ 3943", + "GJ 546" + ], + [ + "GJ 845", + "GJ 34A" + ], + [ + "GJ 34A", + "GJ 473B" + ], + [ + "GJ 54.1", + "GJ 411" + ], + [ + "GJ 3820", + "GJ 876" + ], + [ + "GJ 425A", + "GJ 177" + ], + [ + "GJ 807", + "GJ 529" + ], + [ + "GJ 54.1", + "GJ 695A" + ], + [ + "GJ 34A", + "GJ 667A" + ], + [ + "GJ 3728B", + "GJ 27" + ], + [ + "GJ 876", + "GJ 661A" + ], + [ + "GJ 695A", + "GJ 299" + ], + [ + "GJ 567", + "GJ 896A" + ], + [ + "GJ 896A", + "GJ 388" + ], + [ + "GJ 661A", + "GJ 667B" + ], + [ + "GJ 667B", + "GJ 3618" + ], + [ + "GJ 1245B", + "GJ 54" + ], + [ + "GJ 667A", + "GJ 3991" + ], + [ + "GJ 3379", + "GJ 667C" + ], + [ + "GJ 388", + "GJ 33" + ], + [ + "GJ 54", + "GJ 1061" + ], + [ + "GJ 1061", + "GJ 768" + ], + [ + "GJ 3586", + "GJ 615" + ], + [ + "GJ 785", + "GJ 234A" + ], + [ + "GJ 768", + "GJ 1002" + ], + [ + "GJ 3991", + "GJ 3325" + ], + [ + "GJ 3325", + "GJ 475" + ], + [ + "GJ 177", + "GJ 394" + ], + [ + "GJ 234A", + "GJ 566B" + ], + [ + "GJ 475", + "GJ 3622" + ], + [ + "GJ 1002", + "GJ 222A" + ], + [ + "GJ 4A", + "GJ 829" + ], + [ + "GJ 222A", + "GJ 35" + ], + [ + "GJ 3622", + "GJ 663A" + ], + [ + "GJ 33", + "GJ 1156" + ], + [ + "GJ 663A", + "GJ 1230A" + ], + [ + "GJ 412A", + "GJ 860A" + ], + [ + "GJ 394", + "GJ 407" + ], + [ + "GJ 1230A", + "GJ 825" + ], + [ + "GJ 667B", + "GJ 1116A" + ], + [ + "GJ 1075", + "GJ 435" + ], + [ + "GJ 1116A", + "GJ 873" + ], + [ + "GJ 300", + "GJ 216A" + ], + [ + "GJ 435", + "GJ 393" + ], + [ + "GJ 393", + "GJ 644A" + ], + [ + "GJ 644A", + "GJ 905" + ], + [ + "GJ 216A", + "GJ 706" + ], + [ + "GJ 905", + "GJ 1111" + ], + [ + "GJ 1111", + "GJ 693" + ], + [ + "GJ 825", + "GJ 780" + ], + [ + "GJ 566B", + "GJ 213" + ], + [ + "GJ 213", + "GJ 473A" + ], + [ + "GJ 825", + "GJ 139" + ], + [ + "GJ 879", + "GJ 178" + ], + [ + "GJ 706", + "GJ 75" + ], + [ + "GJ 473A", + "GJ 423B" + ], + [ + "GJ 178", + "GJ 638" + ], + [ + "GJ 693", + "GJ 3323" + ], + [ + "GJ 75", + "GJ 575A" + ], + [ + "GJ 1061", + "GJ 884" + ], + [ + "GJ 75", + "GJ 680" + ], + [ + "GJ 451A", + "GJ 688" + ], + [ + "GJ 680", + "GJ 1289" + ], + [ + "GJ 216B", + "GJ 673" + ], + [ + "GJ 35", + "GJ 86" + ], + [ + "GJ 673", + "GJ 406" + ], + [ + "GJ 1111", + "GJ 4053" + ], + [ + "GJ 1245A", + "GJ 877" + ], + [ + "GJ 575A", + "GJ 194B" + ], + [ + "GJ 402", + "GJ 653" + ], + [ + "GJ 653", + "GJ 95" + ], + [ + "GJ 406", + "GJ 65A" + ], + [ + "GJ 3323", + "GJ 325B" + ], + [ + "GJ 1289", + "GJ 273" + ], + [ + "GJ 273", + "GJ 250A" + ], + [ + "GJ 65A", + "GJ 285" + ], + [ + "GJ 194B", + "GJ 716" + ], + [ + "GJ 716", + "GJ 1292" + ], + [ + "GJ 250A", + "GJ 588" + ], + [ + "GJ 546", + "GJ 111" + ], + [ + "GJ 408", + "GJ 410" + ], + [ + "GJ 4053", + "GJ 827" + ], + [ + "GJ 393", + "GJ 197" + ], + [ + "GJ 86", + "GJ 3250" + ], + [ + "GJ 688", + "GJ 737B" + ], + [ + "GJ 877", + "GJ 880" + ], + [ + "GJ 451A", + "GJ 902" + ], + [ + "GJ 1292", + "GJ 770" + ], + [ + "GJ 388", + "GJ 536" + ], + [ + "GJ 423B", + "GJ 67" + ], + [ + "GJ 7547", + "GJ 6711" + ], + [ + "GJ 3454", + "GJ 505A" + ], + [ + "GJ 3454", + "GJ 1116B" + ], + [ + "GJ 3454", + "GJ 357" + ], + [ + "GJ 411", + "GJ 1061" + ], + [ + "GJ 411", + "GJ 1111" + ], + [ + "GJ 411", + "GJ 860A" + ], + [ + "GJ 411", + "GJ 234A" + ], + [ + "GJ 3522", + "GJ 1286" + ], + [ + "GJ 845", + "GJ 663A" + ], + [ + "GJ 845", + "GJ 406" + ], + [ + "GJ 845", + "GJ 661A" + ], + [ + "GJ 845", + "GJ 780" + ], + [ + "GJ 137", + "GJ 66A" + ], + [ + "GJ 137", + "GJ 635B" + ], + [ + "GJ 406", + "GJ 273" + ], + [ + "GJ 406", + "GJ 3618" + ], + [ + "GJ 832", + "GJ 357" + ], + [ + "GJ 832", + "GJ 3454" + ], + [ + "GJ 832", + "GJ 191" + ], + [ + "GJ 832", + "GJ 150" + ], + [ + "GJ 566B", + "GJ 884" + ], + [ + "GJ 566B", + "GJ 388" + ], + [ + "GJ 566B", + "GJ 1156" + ], + [ + "GJ 71", + "GJ 725B" + ], + [ + "GJ 71", + "GJ 144" + ], + [ + "GJ 566A", + "GJ 445" + ], + [ + "GJ 566A", + "GJ 34B" + ], + [ + "GJ 566A", + "GJ 3379" + ], + [ + "GJ 566A", + "GJ 713A" + ], + [ + "GJ 783A", + "GJ 570A" + ], + [ + "GJ 682", + "GJ 860B" + ], + [ + "GJ 667A", + "GJ 873" + ], + [ + "GJ 829", + "GJ 54" + ], + [ + "GJ 473B", + "GJ 667B" + ], + [ + "GJ 27", + "GJ 3323" + ], + [ + "GJ 695A", + "GJ 566B" + ], + [ + "GJ 1116A", + "GJ 408" + ], + [ + "GJ 905", + "GJ 54.1" + ], + [ + "GJ 660A", + "GJ 17" + ], + [ + "GJ 105A", + "GJ 34B" + ], + [ + "GJ 725B", + "GJ 674" + ], + [ + "GJ 380", + "GJ 244A" + ], + [ + "GJ 251", + "GJ 280A" + ], + [ + "GJ 1286", + "GJ 3877" + ], + [ + "GJ 570A", + "GJ 764" + ], + [ + "GJ 702B", + "GJ 144" + ], + [ + "GJ 447", + "GJ 551" + ], + [ + "GJ 820B", + "GJ 887" + ], + [ + "GJ 559B", + "GJ 845" + ], + [ + "GJ 699", + "GJ 139" + ], + [ + "GJ 820B", + "GJ 768" + ], + [ + "GJ 3877", + "GJ 1156" + ], + [ + "GJ 1221", + "GJ 866A" + ], + [ + "GJ 280A", + "GJ 34B" + ], + [ + "GJ 1289", + "GJ 3988" + ], + [ + "GJ 832", + "GJ 643" + ], + [ + "GJ 1005A", + "GJ 49" + ], + [ + "GJ 661A", + "GJ 729" + ], + [ + "GJ 1116B", + "GJ 896A" + ], + [ + "GJ 570A", + "GJ 244A" + ], + [ + "GJ 570A", + "GJ 551" + ], + [ + "GJ 570A", + "GJ 559B" + ], + [ + "GJ 244A", + "GJ 380" + ], + [ + "GJ 244A", + "GJ 338B" + ], + [ + "GJ 244A", + "GJ 820B" + ], + [ + "GJ 783A", + "GJ 1286" + ], + [ + "GJ 783A", + "GJ 251" + ], + [ + "GJ 725B", + "GJ 764" + ], + [ + "GJ 1286", + "GJ 447" + ], + [ + "GJ 251", + "GJ 3522" + ], + [ + "GJ 3877", + "GJ 280A" + ], + [ + "GJ 551", + "GJ 166A" + ], + [ + "GJ 820B", + "GJ 699" + ], + [ + "GJ 887", + "GJ 3877" + ], + [ + "GJ 674", + "GJ 380" + ], + [ + "GJ 764", + "GJ 3522" + ], + [ + "GJ 1156", + "GJ 299" + ], + [ + "GJ 3877", + "GJ 551" + ], + [ + "GJ 357", + "GJ 3333" + ], + [ + "GJ 1111", + "GJ 393" + ], + [ + "GJ 764", + "GJ 144" + ], + [ + "GJ 388", + "GJ 299" + ], + [ + "GJ 820B", + "GJ 559B" + ], + [ + "GJ 54.1", + "GJ 393" + ], + [ + "GJ 251", + "GJ 559B" + ], + [ + "GJ 892", + "GJ 845" + ], + [ + "GJ 324A", + "GJ 1235" + ], + [ + "GJ 142", + "GJ 1235" + ], + [ + "GJ 481", + "GJ 201" + ], + [ + "GJ 684A", + "GJ 201" + ], + [ + "GJ 61", + "GJ 201" + ], + [ + "GJ 752A", + "GJ 54.1" + ], + [ + "GJ 434", + "GJ 682" + ], + [ + "GJ 83.1", + "GJ 1235" + ], + [ + "GJ 909A", + "GJ 137" + ], + [ + "GJ 83.1", + "GJ 884" + ], + [ + "GJ 625", + "GJ 445" + ], + [ + "GJ 879", + "GJ 780" + ], + [ + "GJ 644C", + "GJ 451A" + ], + [ + "GJ 644C", + "GJ 860A" + ], + [ + "GJ 302", + "GJ 453" + ], + [ + "GJ 1125", + "GJ 66A" + ], + [ + "GJ 1125", + "GJ 660A" + ], + [ + "GJ 1245A", + "GJ 139" + ], + [ + "GJ 631", + "GJ 1116B" + ], + [ + "GJ 320", + "GJ 150" + ], + [ + "GJ 402", + "GJ 873" + ], + [ + "GJ 0", + "GJ 71" + ] + ] +} diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 9180493b5..7507b4b07 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -100,7 +100,8 @@ var character_archetype: String = "detective" # #705: Character visual descriptor — set by character_creation.gd on confirmation. # Passed to EntityRenderer for the player entity's CharacterVisual on game start. # Null when no custom appearance has been selected (fallback: default descriptor). -var character_visual_descriptor: CharacterVisualDescriptor = null +# Type is CharacterVisualDescriptor — untyped to avoid autoload parse-order issue. +var character_visual_descriptor = null # #646: AI-Enhanced Dialogue enabled state (D-138). # Runtime toggle — true means the LLM re-voicing pipeline should run (server-side). @@ -362,6 +363,19 @@ func apply_snapshot(snapshot: Dictionary) -> void: else: settings_response = null + # #718: character_visual_descriptor — restored from server snapshot on save/load. + # Server persists the descriptor and includes it in ObserverSnapshot after load. + # Only update when field is present (null means no change). + if snapshot.has("character_visual_descriptor") and snapshot.character_visual_descriptor is Dictionary: + # load() returns a cached script — safe to call per-tick once the resource is in cache. + # Cannot use CharacterVisualDescriptor directly: autoloads compile before global class_names + # are registered, causing a parse-time "not declared" error. + var CVD := load("res://scripts/rendering/character_visual_descriptor.gd") + if CVD != null: + var restored = CVD.from_dict(snapshot.character_visual_descriptor) + if restored != null: + character_visual_descriptor = restored + # v14: player_knowledge (#264, D-041) — partial KG dump for journal panel. # Only update when field is present (null means no change, server sends when KG changes). if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary: diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 5424ac1ea..8154886bb 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -5,7 +5,8 @@ enum ConnectionState { DISCONNECTED, CONNECTING, HANDSHAKING, CONNECTED, ERROR } var state: ConnectionState = ConnectionState.DISCONNECTED var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server -var harness: TestHarness = null # Test simulation (D-020: game logic lives outside production client) +# Type is TestHarness — untyped to avoid autoload parse-order issue. +var harness = null # Test simulation (D-020: game logic lives outside production client) var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot) var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport @@ -33,7 +34,7 @@ signal handshake_failed(reason: String) func _ready() -> void: if test_mode: - harness = TestHarness.new() + harness = load("res://scripts/protocol/test_harness.gd").new() print("SimBridge: Running in test mode (dynamic snapshot)") @@ -231,9 +232,9 @@ func _process(delta: float) -> void: _set_state(ConnectionState.ERROR) return - # Send startup message with world_seed (#175, D-010/D-029). + # Send startup message with world_seed and character appearance (#175, D-010/D-029, #718). # Server blocks waiting for this before entering the tick loop. - var startup_bytes := Protocol.encode_startup_message(GameState.world_seed, GameState.character_archetype) + var startup_bytes := Protocol.encode_startup_message(GameState.world_seed, GameState.character_archetype, GameState.character_visual_descriptor) if startup_bytes.size() > 0: var send_err := _bridge.send_message(startup_bytes) if send_err != OK: diff --git a/client/scripts/autoloads/ui_strings.gd b/client/scripts/autoloads/ui_strings.gd index 95dd4e6d5..00da195a0 100644 --- a/client/scripts/autoloads/ui_strings.gd +++ b/client/scripts/autoloads/ui_strings.gd @@ -51,4 +51,4 @@ func reload() -> void: ## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }. ## Delegates to YamlParser.parse_flat() (#560). static func _parse_yaml(text: String) -> Dictionary: - return YamlParser.parse_flat(text) + return load("res://scripts/util/yaml_parser.gd").parse_flat(text) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 12555c944..7d45d99a5 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -24,6 +24,7 @@ extends Node2D @onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load @onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console @onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7) +@onready var star_map = $UILayer/HUD/StarMap # #674: star map insert module (hop-ring view) var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution @@ -254,6 +255,8 @@ func _propagate_insert_state() -> void: interaction_prompt.set_insert_active(insert_state) if minimap: minimap.set_insert_active(insert_state) + if star_map: + star_map.set_insert_active(insert_state) # D-057: Update interaction list from game state. diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 995b88c0a..93a25a2f1 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -494,11 +494,12 @@ static func _decode_enum_variant(raw) -> Dictionary: # -- Encode: GDScript types → bytes to server ---------------------------------- -## Encode a StartupMessage to MessagePack bytes (#175, #588). +## Encode a StartupMessage to MessagePack bytes (#175, #588, #718). ## Sent by the client immediately after handshake validation. ## Server reads this to initialize SimRng (D-010, D-029) and select monologue pool (D-032). ## character_archetype: "detective" → "Detective", "smuggler" → "Smuggler" (server enum variant). -static func encode_startup_message(world_seed: int, character_archetype: String = "detective") -> PackedByteArray: +## character_visual: optional CharacterVisualDescriptor — included as "character_visual_descriptor" dict. +static func encode_startup_message(world_seed: int, character_archetype: String = "detective", character_visual: Variant = null) -> PackedByteArray: # Map client lowercase archetype string to server PascalCase enum variant. # Explicit match prevents unknown strings silently reaching the server as # garbage enum values — fail loudly and fall back to "Detective". @@ -515,6 +516,8 @@ static func encode_startup_message(world_seed: int, character_archetype: String "world_seed": world_seed, "character_archetype": archetype_variant, } + if character_visual != null and character_visual.has_method("to_dict"): + msg["character_visual_descriptor"] = character_visual.to_dict() var result = Messagepack.encode(msg) if result.status != null: push_error("Protocol: startup message encode failed: %s" % result.status) diff --git a/client/scripts/rendering/character_visual.gd b/client/scripts/rendering/character_visual.gd index 9e4a4ce18..6598bb6a7 100644 --- a/client/scripts/rendering/character_visual.gd +++ b/client/scripts/rendering/character_visual.gd @@ -23,6 +23,7 @@ extends Node3D ## get_clothing_node_count() — number of clothing MeshInstance3D nodes ## get_accessory_node_count() — number of accessory BoneAttachment3D nodes ## get_skin_tone_texture_name(index) — skin tone texture filename key for index +## get_overhead_anchor() — Marker3D above Head bone for floating UI (#712) ## ## D-159 (11 body types), D-160 (18 segments), D-161 (head separate), ## D-162 (clothing pre-fitted), D-163 (heads via BoneAttachment3D), D-164 (skeleton fork) @@ -101,6 +102,8 @@ var _clothing_meshes: Array[MeshInstance3D] = [] var _bone_attachments: Array[BoneAttachment3D] = [] var _outline_nodes: Array[MeshInstance3D] = [] var _accessory_attachments: Array[BoneAttachment3D] = [] +var _overhead_anchor: Marker3D = null +var _overhead_attachment: BoneAttachment3D = null # Inspectable state for tests var _active_torso_variant: String = "full" @@ -218,6 +221,13 @@ func get_skin_tone_texture_name(index: int) -> String: return SKIN_TONES[clampi(index, 0, SKIN_TONES.size() - 1)]["tex"] +## Return the overhead anchor Marker3D (#712). Null if skeleton not loaded. +## Anchor point for floating UI elements: status indicators, thought bubbles, +## alert markers, speech icons. Positioned ~0.3m above the Head bone. +func get_overhead_anchor() -> Marker3D: + return _overhead_anchor + + # ============================================================================= # Internal — teardown # ============================================================================= @@ -234,6 +244,13 @@ func _clear() -> void: node.free() _outline_nodes.clear() + # #712: free overhead anchor before general bone attachments + if is_instance_valid(_overhead_attachment) and _overhead_attachment.get_parent(): + _overhead_attachment.get_parent().remove_child(_overhead_attachment) + _overhead_attachment.free() + _overhead_attachment = null + _overhead_anchor = null + for att in _bone_attachments: if is_instance_valid(att) and att.get_parent(): att.get_parent().remove_child(att) @@ -291,6 +308,7 @@ func _load_skeleton() -> void: for m in armature_meshes: print(" armature mesh: ", m.name, " visible=", m.visible) _validate_slot_bones() + _create_overhead_anchor() ## Warn on any SLOT_TO_BONE entry that doesn't exist in the loaded skeleton. @@ -302,6 +320,26 @@ func _validate_slot_bones() -> void: push_warning("CharacterVisual: SLOT_TO_BONE['%s'] = '%s' — bone not found in skeleton" % [slot, bone_name]) +## #712: Create a Marker3D anchored ~0.3m above the Head bone via BoneAttachment3D. +## Anchor point for floating UI elements (status indicators, thought bubbles, etc.). +func _create_overhead_anchor() -> void: + if _skeleton == null: + return + var bone_idx := _skeleton.find_bone("Head") + if bone_idx == -1: + push_warning("CharacterVisual: Head bone not found — overhead anchor not created") + return + _overhead_attachment = BoneAttachment3D.new() + _overhead_attachment.bone_name = "Head" + _overhead_attachment.name = "OverheadAttachment" + _skeleton.add_child(_overhead_attachment) + + _overhead_anchor = Marker3D.new() + _overhead_anchor.name = "OverheadAnchor" + _overhead_anchor.position = Vector3(0, 0.3, 0) + _overhead_attachment.add_child(_overhead_anchor) + + # ============================================================================= # Internal — body segments (D-160) # ============================================================================= diff --git a/client/tests/test_sprint30.gd b/client/tests/test_sprint30.gd new file mode 100644 index 000000000..4d8b509b4 --- /dev/null +++ b/client/tests/test_sprint30.gd @@ -0,0 +1,590 @@ +## Sprint 30 — QA acceptance tests +## +## Covers all 5 Sprint 30 client tickets: +## #718 — Persist CharacterVisualDescriptor on new game start +## #719 — Hair highlight: make swatch read-only (Option B — no compositor yet) +## #720 — Replace DirAccess scanning with manifest JSON for export builds +## #712 — BoneAttachment3D marker above Head bone for floating icons +## #674 — Star map insert module (test-first: scene must exist when implemented) +## +## Test convention: +## - Tests that should PASS immediately = regression guards on existing code +## - Tests prefixed [ACCEPTANCE] = will FAIL until the ticket is implemented +## +## Ticket refs: #718, #719, #720, #712, #674 +class_name TestSprint30 +extends GdUnitTestSuite + + +func before_each() -> void: + # Reset GameState fields touched by #718 tests to avoid cross-test pollution. + GameState.character_visual_descriptor = null + + +func after_each() -> void: + GameState.character_visual_descriptor = null + + +# ============================================================================= +# #718 — Persist CharacterVisualDescriptor +# ============================================================================= + +func test_game_state_has_character_visual_descriptor_field() -> void: + ## GameState.character_visual_descriptor must exist and default to null. + ## Confirms the field added in game_state.gd line 103 is present. + var gs := GameState.new() + auto_free(gs) + # The field is declared on the class — access it without error + var val: Variant = gs.get("character_visual_descriptor") + # Field should exist (not return null from missing property vs. null value) + assert_bool(gs.has_method("apply_snapshot")).override_failure_message( + "GameState must be a valid autoload class with apply_snapshot" + ).is_true() + # The property itself must be gettable and null by default + assert_bool(val == null).override_failure_message( + "GameState.character_visual_descriptor must default to null" + ).is_true() + + +func test_descriptor_to_dict_includes_all_required_fields() -> void: + ## CharacterVisualDescriptor.to_dict() must include all wire-format fields. + var desc := CharacterVisualDescriptor.new() + auto_free(desc) + var d := desc.to_dict() + var required_keys := [ + "body_type", "head_id", "hair_id", "hair_tint", + "facial_hair_id", "facial_hair_tint", "eyebrow_id", "eyebrow_tint", + "eye_color", "skin_tone", "clothing_slots", "clothing_tints", + "accessory_slots", "accessory_tints", + ] + for key in required_keys: + assert_bool(d.has(key)).override_failure_message( + "to_dict() must include field '%s'" % key + ).is_true() + + +func test_descriptor_to_dict_body_type_is_wire_string() -> void: + ## body_type in to_dict() must be a string (rmp_serde unit enum), not an int. + var desc := CharacterVisualDescriptor.new() + auto_free(desc) + desc.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M + var d := desc.to_dict() + assert_str(d["body_type"]).override_failure_message( + "to_dict() body_type must be wire string 'AverageM'" + ).is_equal("AverageM") + + +func test_descriptor_round_trip_preserves_fields() -> void: + ## from_dict(to_dict(desc)) must preserve all scalar fields. + var original := CharacterVisualDescriptor.new() + auto_free(original) + original.body_type = CharacterVisualDescriptor.BodyType.THIN_F + original.head_id = "head_002" + original.hair_id = "bob" + original.hair_tint = Color(0.8, 0.4, 0.2) + original.skin_tone = 3 + + var wire := original.to_dict() + var restored := CharacterVisualDescriptor.from_dict(wire) + assert_bool(restored != null).override_failure_message( + "from_dict() must return a descriptor for valid wire data" + ).is_true() + if restored == null: + return + + assert_int(int(restored.body_type)).override_failure_message( + "body_type must survive round-trip" + ).is_equal(int(CharacterVisualDescriptor.BodyType.THIN_F)) + + assert_str(restored.head_id).override_failure_message( + "head_id must survive round-trip" + ).is_equal("head_002") + + assert_str(restored.hair_id).override_failure_message( + "hair_id must survive round-trip" + ).is_equal("bob") + + assert_int(restored.skin_tone).override_failure_message( + "skin_tone must survive round-trip" + ).is_equal(3) + + +func test_descriptor_from_dict_returns_null_when_missing_body_type() -> void: + ## from_dict() must return null if body_type is absent (required field). + var d := {"head_id": "head_001"} # missing body_type + var result := CharacterVisualDescriptor.from_dict(d) + assert_bool(result == null).override_failure_message( + "from_dict() must return null when body_type is missing" + ).is_true() + + +func test_descriptor_color_encoding_is_float_array() -> void: + ## Colors must encode as [r, g, b, a] float arrays for rmp_serde compatibility. + var desc := CharacterVisualDescriptor.new() + auto_free(desc) + desc.eye_color = Color(0.1, 0.2, 0.3, 1.0) + var d := desc.to_dict() + var encoded: Variant = d["eye_color"] + assert_bool(encoded is Array).override_failure_message( + "eye_color must encode as an Array [r, g, b, a]" + ).is_true() + if not (encoded is Array): + return + assert_int((encoded as Array).size()).override_failure_message( + "eye_color array must have 4 elements" + ).is_equal(4) + assert_float((encoded as Array)[0]).override_failure_message( + "eye_color[0] (r) must be approx 0.1" + ).is_equal_approx(0.1, 0.001) + + +func test_apply_snapshot_restores_character_visual_descriptor() -> void: + ## #718: apply_snapshot() must restore character_visual_descriptor from + ## the "character_visual_descriptor" key in ObserverSnapshot (save/load path). + var desc := CharacterVisualDescriptor.new() + desc.body_type = CharacterVisualDescriptor.BodyType.MUSCULAR_F + desc.head_id = "head_003" + desc.hair_id = "dreads" + desc.skin_tone = 5 + var snapshot := { + "character_visual_descriptor": desc.to_dict(), + } + GameState.apply_snapshot(snapshot) + var restored: Variant = GameState.character_visual_descriptor + assert_bool(restored != null).override_failure_message( + "apply_snapshot() must restore character_visual_descriptor from snapshot" + ).is_true() + if restored != null and restored is CharacterVisualDescriptor: + var r := restored as CharacterVisualDescriptor + assert_int(int(r.body_type)).override_failure_message( + "restored body_type must match" + ).is_equal(int(CharacterVisualDescriptor.BodyType.MUSCULAR_F)) + assert_str(r.head_id).override_failure_message( + "restored head_id must match" + ).is_equal("head_003") + assert_int(r.skin_tone).override_failure_message( + "restored skin_tone must match" + ).is_equal(5) + + +func test_apply_snapshot_preserves_descriptor_when_field_absent() -> void: + ## #718: If snapshot lacks "character_visual_descriptor", the existing field + ## must not be overwritten (server only sends when descriptor changes). + var desc := CharacterVisualDescriptor.new() + desc.hair_id = "bob" + GameState.character_visual_descriptor = desc + # Snapshot with no character_visual_descriptor key + GameState.apply_snapshot({"tick": 1}) + var after: Variant = GameState.character_visual_descriptor + assert_bool(after != null).override_failure_message( + "apply_snapshot() must NOT clear descriptor when field is absent" + ).is_true() + if after is CharacterVisualDescriptor: + assert_str((after as CharacterVisualDescriptor).hair_id).override_failure_message( + "descriptor must be unchanged after snapshot with no character_visual_descriptor key" + ).is_equal("bob") + + +func test_protocol_encode_startup_includes_descriptor() -> void: + ## #718: Protocol.encode_startup_message() must include "character_visual_descriptor" + ## in the encoded payload when a descriptor is provided. + var desc := CharacterVisualDescriptor.new() + desc.body_type = CharacterVisualDescriptor.BodyType.THIN_M + desc.hair_id = "buzzed" + var bytes := Protocol.encode_startup_message(12345, "detective", desc) + assert_bool(bytes.size() > 0).override_failure_message( + "encode_startup_message() must produce non-empty bytes" + ).is_true() + # Decode and verify the field is present (Messagepack.decode returns {status, value}) + var raw = Messagepack.decode(bytes) + assert_bool(raw.status == null).override_failure_message( + "encode_startup_message() output must be valid msgpack" + ).is_true() + if raw.status != null: + return + var msg: Dictionary = raw.value as Dictionary + assert_bool(msg.has("character_visual_descriptor")).override_failure_message( + "StartupMessage must include 'character_visual_descriptor' key when descriptor is provided" + ).is_true() + if msg.has("character_visual_descriptor"): + assert_bool(msg["character_visual_descriptor"] is Dictionary).override_failure_message( + "character_visual_descriptor in StartupMessage must be a Dictionary" + ).is_true() + + +func test_protocol_encode_startup_omits_descriptor_when_null() -> void: + ## #718: encode_startup_message() must still produce valid bytes when descriptor is null. + var bytes := Protocol.encode_startup_message(0, "detective", null) + assert_bool(bytes.size() > 0).override_failure_message( + "encode_startup_message() must produce valid bytes even with null descriptor" + ).is_true() + + +func test_descriptor_has_no_hair_highlight_tint_field() -> void: + ## CharacterVisualDescriptor must NOT have a hair_highlight_tint field. + ## The highlight is always auto-derived from hair_tint (Option B of #719). + ## to_dict() must not include it in the wire format. + var desc := CharacterVisualDescriptor.new() + auto_free(desc) + var d := desc.to_dict() + assert_bool(d.has("hair_highlight_tint")).override_failure_message( + "to_dict() must NOT include hair_highlight_tint — highlight is auto-derived" + ).is_false() + assert_bool("hair_highlight_tint" in desc).override_failure_message( + "CharacterVisualDescriptor must not define a hair_highlight_tint property" + ).is_false() + + +# ============================================================================= +# #719 — Hair highlight swatch (Option B: read-only, auto-derived) +# ============================================================================= + +func test_derive_hair_highlight_lightens_primary() -> void: + ## _derive_hair_highlight() must return primary.lightened(0.3). + ## Tests the derivation formula in character_creation.gd:1917. + var cc_scene_path := "res://scenes/character_creation.tscn" + if not ResourceLoader.exists(cc_scene_path): + push_warning("test_derive_hair_highlight_lightens_primary: scene not available in headless — skipping") + return + var packed := load(cc_scene_path) as PackedScene + if packed == null: + return + var cc := packed.instantiate() as CharacterCreation + if cc == null: + push_warning("test_derive_hair_highlight_lightens_primary: failed to instantiate — skipping") + return + auto_free(cc) + + # CharacterCreation._derive_hair_highlight is a private method but testable via call() + var primary := Color(0.4, 0.3, 0.5) + var expected := primary.lightened(0.3) + var result: Variant = cc.call("_derive_hair_highlight", primary) + assert_bool(result is Color).override_failure_message( + "_derive_hair_highlight must return a Color" + ).is_true() + if not (result is Color): + return + var r := result as Color + assert_float(r.r).override_failure_message("derived highlight.r incorrect").is_equal_approx(expected.r, 0.001) + assert_float(r.g).override_failure_message("derived highlight.g incorrect").is_equal_approx(expected.g, 0.001) + assert_float(r.b).override_failure_message("derived highlight.b incorrect").is_equal_approx(expected.b, 0.001) + + +func test_hair_highlight_swatch_exists_in_ui() -> void: + ## [ACCEPTANCE #719] After fix, _hair_highlight_swatch must be non-null + ## (a display node must be created in _build_hair_color_dock). + ## WILL FAIL until #719 is implemented. + var cc_scene_path := "res://scenes/character_creation.tscn" + if not ResourceLoader.exists(cc_scene_path): + push_warning("test_hair_highlight_swatch_exists_in_ui: scene not available in headless — skipping") + return + var packed := load(cc_scene_path) as PackedScene + if packed == null: + return + var cc := packed.instantiate() as CharacterCreation + if cc == null: + return + auto_free(cc) + add_child(cc) + await get_tree().process_frame + + # _hair_highlight_swatch must be set after _ready() builds the hair color dock + var swatch: Variant = cc.get("_hair_highlight_swatch") + assert_bool(swatch != null).override_failure_message( + "[#719] _hair_highlight_swatch must not be null — a display node must be created" + ).is_true() + + +func test_hair_highlight_swatch_is_not_interactive() -> void: + ## [ACCEPTANCE #719] The highlight swatch must be non-interactive. + ## Either mouse_filter = IGNORE, or the node is a ColorRect (not a Button with a callback). + ## WILL FAIL until #719 is implemented. + var cc_scene_path := "res://scenes/character_creation.tscn" + if not ResourceLoader.exists(cc_scene_path): + push_warning("test_hair_highlight_swatch_is_not_interactive: scene not available — skipping") + return + var packed := load(cc_scene_path) as PackedScene + if packed == null: + return + var cc := packed.instantiate() as CharacterCreation + if cc == null: + return + auto_free(cc) + add_child(cc) + await get_tree().process_frame + + var swatch: Variant = cc.get("_hair_highlight_swatch") + if swatch == null: + push_warning("test_hair_highlight_swatch_is_not_interactive: swatch not found — #719 not yet implemented") + return + + # If swatch is a Control node, mouse_filter must be IGNORE (2) + if swatch is Control: + var ctrl := swatch as Control + assert_int(ctrl.mouse_filter).override_failure_message( + "[#719] hair highlight swatch must have mouse_filter=IGNORE (non-interactive)" + ).is_equal(Control.MOUSE_FILTER_IGNORE) + + +# ============================================================================= +# #720 — Manifest JSON completeness (replaces DirAccess scanning) +# ============================================================================= + +func test_manifest_json_is_parseable() -> void: + ## manifest.json must exist and parse as a Dictionary. + var path := "res://assets/characters/manifest.json" + assert_bool(ResourceLoader.exists(path) or FileAccess.file_exists(path)).override_failure_message( + "manifest.json must exist at res://assets/characters/manifest.json" + ).is_true() + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + push_warning("test_manifest_json_is_parseable: file not openable — skipping") + return + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + assert_bool(parsed is Dictionary).override_failure_message( + "manifest.json must parse as a JSON object (Dictionary)" + ).is_true() + + +func test_manifest_hair_includes_all_asset_dirs() -> void: + ## [ACCEPTANCE #720] manifest.json "hair" array must include every .glb in + ## assets/characters/hair/. Currently missing: balding, buzzed_female, dreads, + ## long_dreads, mohawk, ponytail_f, simple_parted, slick_back. + ## WILL FAIL until #720 populates the manifest fully. + var path := "res://assets/characters/manifest.json" + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + push_warning("test_manifest_hair_includes_all_asset_dirs: manifest not readable — skipping") + return + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if not (parsed is Dictionary): + return + var manifest := parsed as Dictionary + var hair_list: Array = manifest.get("hair", []) + + # All hair IDs confirmed from assets/characters/hair/*.glb scan (2026-04-04) + var expected_hair := [ + "bald", "balding", "bob", "buns", "buzzed", "buzzed_female", + "dreads", "long", "long_dreads", "mohawk", "ponytail", "ponytail_f", + "simple_parted", "slick_back", + ] + for hair_id in expected_hair: + assert_bool(hair_list.has(hair_id)).override_failure_message( + "[#720] manifest 'hair' must include '%s'" % hair_id + ).is_true() + + +func test_manifest_heads_are_populated() -> void: + ## [ACCEPTANCE #720] manifest.json "heads" must not be empty. + ## heads/templates/ contains head_001..head_004 — all must be listed. + ## WILL FAIL until #720 populates the manifest. + var path := "res://assets/characters/manifest.json" + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + push_warning("test_manifest_heads_are_populated: manifest not readable — skipping") + return + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if not (parsed is Dictionary): + return + var manifest := parsed as Dictionary + var heads_list: Array = manifest.get("heads", []) + + var expected_heads := ["head_001", "head_002", "head_003", "head_004"] + assert_bool(not heads_list.is_empty()).override_failure_message( + "[#720] manifest 'heads' must not be empty — 4 head templates exist" + ).is_true() + for head_id in expected_heads: + assert_bool(heads_list.has(head_id)).override_failure_message( + "[#720] manifest 'heads' must include '%s'" % head_id + ).is_true() + + +func test_manifest_body_types_includes_all_11() -> void: + ## [ACCEPTANCE #720] manifest.json "body_types" must include all 11 types. + ## Currently has 6; missing: thin_m, thin_f, heavy_m, heavy_f, child. + ## WILL FAIL until #720 updates the manifest. + var path := "res://assets/characters/manifest.json" + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + push_warning("test_manifest_body_types_includes_all_11: manifest not readable — skipping") + return + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if not (parsed is Dictionary): + return + var manifest := parsed as Dictionary + var bt_list: Array = manifest.get("body_types", []) + + var expected_types := [ + "thin_m", "thin_f", "average_m", "average_f", + "muscular_m", "muscular_f", "teen_m", "teen_f", + "heavy_m", "heavy_f", "child", + ] + for bt in expected_types: + assert_bool(bt_list.has(bt)).override_failure_message( + "[#720] manifest 'body_types' must include '%s'" % bt + ).is_true() + + +func test_manifest_clothing_includes_all_items() -> void: + ## [ACCEPTANCE #720] manifest.json "clothing" must include all items in + ## assets/characters/clothing/. Currently missing: boots_work, coveralls_basic, + ## jacket_utility, pants_cargo, shirt_henley. + ## WILL FAIL until #720 populates the manifest. + var path := "res://assets/characters/manifest.json" + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + push_warning("test_manifest_clothing_includes_all_items: manifest not readable — skipping") + return + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if not (parsed is Dictionary): + return + var manifest := parsed as Dictionary + var clothing_data: Variant = manifest.get("clothing", {}) + var clothing_keys: Array = [] + if clothing_data is Dictionary: + clothing_keys = (clothing_data as Dictionary).keys() + + # All clothing item IDs confirmed from assets/characters/clothing/ scan (2026-04-04) + var expected_items := [ + "boots_work", "coveralls_basic", "jacket_utility", "pants_cargo", + "peasant_pants", "peasant_shoes", "peasant_tunic", "shirt_henley", + ] + for item_id in expected_items: + assert_bool(clothing_keys.has(item_id)).override_failure_message( + "[#720] manifest 'clothing' must include '%s'" % item_id + ).is_true() + + +func test_manifest_eyebrows_are_populated() -> void: + ## [ACCEPTANCE #720] manifest.json "eyebrows" must list all eyebrow styles. + ## assets/characters/eyebrows/ has: female, regular, teen, thick. + ## WILL FAIL until #720 populates the manifest. + var path := "res://assets/characters/manifest.json" + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + push_warning("test_manifest_eyebrows_are_populated: manifest not readable — skipping") + return + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if not (parsed is Dictionary): + return + var manifest := parsed as Dictionary + var eb_list: Array = manifest.get("eyebrows", []) + + var expected_eyebrows := ["female", "regular", "teen", "thick"] + assert_bool(not eb_list.is_empty()).override_failure_message( + "[#720] manifest 'eyebrows' must not be empty" + ).is_true() + for eb_id in expected_eyebrows: + assert_bool(eb_list.has(eb_id)).override_failure_message( + "[#720] manifest 'eyebrows' must include '%s'" % eb_id + ).is_true() + + +func test_dir_access_scan_functions_removed() -> void: + ## [ACCEPTANCE #720] After fix, _scan_subdirs and _scan_asset_ids must be + ## removed from CharacterCreation. These functions fail in exported PCK builds. + ## WILL FAIL until #720 removes the DirAccess fallbacks. + var cc_scene_path := "res://scenes/character_creation.tscn" + if not ResourceLoader.exists(cc_scene_path): + push_warning("test_dir_access_scan_functions_removed: scene not available — skipping") + return + var packed := load(cc_scene_path) as PackedScene + if packed == null: + return + var cc := packed.instantiate() as CharacterCreation + if cc == null: + return + auto_free(cc) + + assert_bool(cc.has_method("_scan_subdirs")).override_failure_message( + "[#720] _scan_subdirs must be removed — use manifest JSON instead" + ).is_false() + assert_bool(cc.has_method("_scan_asset_ids")).override_failure_message( + "[#720] _scan_asset_ids must be removed — use manifest JSON instead" + ).is_false() + + +# ============================================================================= +# #712 — BoneAttachment3D overhead anchor in CharacterVisual +# ============================================================================= + +func test_character_visual_has_get_overhead_anchor() -> void: + ## CharacterVisual must expose get_overhead_anchor() as part of its public API. + ## This is a static assertion — no 3D assets required. + var cv := CharacterVisual.new() + auto_free(cv) + assert_bool(cv.has_method("get_overhead_anchor")).override_failure_message( + "CharacterVisual must have get_overhead_anchor() method (#712)" + ).is_true() + + +func test_overhead_anchor_is_null_before_load() -> void: + ## get_overhead_anchor() must return null before load_descriptor() is called. + ## The anchor is created during skeleton load, not at construction. + var cv := CharacterVisual.new() + auto_free(cv) + var anchor: Variant = cv.get_overhead_anchor() + assert_bool(anchor == null).override_failure_message( + "get_overhead_anchor() must be null before load_descriptor() is called" + ).is_true() + + +func test_overhead_anchor_offset_constant() -> void: + ## [ACCEPTANCE #712] If CharacterVisual exposes the overhead anchor offset + ## as a constant or via get_overhead_anchor(), the offset must be Vector3(0, 0.3, 0). + ## Verified via code inspection: _overhead_anchor.position = Vector3(0, 0.3, 0). + ## This test loads a scene and verifies if assets are present. + var cv := CharacterVisual.new() + auto_free(cv) + add_child(cv) + + # Without GLB assets available in headless, skeleton load is a no-op. + # Check that _overhead_attachment is also null before load (belt-and-suspenders). + var attachment: Variant = cv.get("_overhead_attachment") + assert_bool(attachment == null).override_failure_message( + "_overhead_attachment must be null before skeleton is loaded" + ).is_true() + + +# ============================================================================= +# #674 — Star map insert module (test-first) +# ============================================================================= + +func test_star_map_scene_exists() -> void: + ## [ACCEPTANCE #674] The star map scene must exist at the expected path. + ## WILL FAIL until #674 is implemented. + var expected_path := "res://ui/star_map.tscn" + assert_bool(ResourceLoader.exists(expected_path)).override_failure_message( + "[#674] Star map scene must exist at res://ui/star_map.tscn" + ).is_true() + + +func test_star_map_is_accessible_from_insert_ui() -> void: + ## [ACCEPTANCE #674] The star map module must be reachable from the insert UI. + ## Verify via HUD or main scene that a star_map node/scene is connected. + ## WILL FAIL until #674 wires the scene into the insert layer. + var hud_scene_path := "res://ui/hud.tscn" + if not ResourceLoader.exists(hud_scene_path): + push_warning("test_star_map_is_accessible_from_insert_ui: HUD scene not found — skipping") + return + var packed := load(hud_scene_path) as PackedScene + if packed == null: + return + var hud := packed.instantiate() + if hud == null: + return + auto_free(hud) + add_child(hud) + await get_tree().process_frame + + # Star map must be reachable as a named node from the HUD or insert layer + var star_map := hud.get_node_or_null("StarMap") + assert_bool(star_map != null).override_failure_message( + "[#674] HUD must contain a StarMap node accessible from the insert UI" + ).is_true() diff --git a/client/ui/character_creation.gd b/client/ui/character_creation.gd index ec4490a76..7f44841f4 100644 --- a/client/ui/character_creation.gd +++ b/client/ui/character_creation.gd @@ -669,6 +669,11 @@ func _build_hair_color_dock() -> Control: func(c): _on_hair_primary_changed(c)) row.add_child(_hair_primary_swatch) + # #719 (Option B): highlight is auto-derived from primary — display-only, not editable. + _hair_highlight_swatch = _make_display_swatch( + _derive_hair_highlight(_descriptor.hair_tint), "Highlight") + row.add_child(_hair_highlight_swatch) + _eyebrow_tint_swatch = _make_color_swatch(_descriptor.hair_tint, "Brows ●", func(c): _on_eyebrow_tint_changed(c)) row.add_child(_eyebrow_tint_swatch) @@ -1840,45 +1845,6 @@ func _apply_search_filter(grid: GridContainer, query: String) -> void: child.visible = lower_q.is_empty() or (child as Button).text.to_lower().contains(lower_q) -# ============================================================================= -# Asset scanning helpers -# ============================================================================= - -## Scan a directory for subdirectory names (item_id directories like clothing/coveralls_basic/). -## Returns fallback list if the directory is absent or empty. -## TODO: replace with manifest JSON for export builds (DirAccess won't list res:// in PCK). -static func _scan_subdirs(dir_path: String, fallback: Array) -> Array: - var dir := DirAccess.open(dir_path) - if dir == null: - return fallback - var ids: Array = [] - dir.list_dir_begin() - var name := dir.get_next() - while name != "": - if dir.current_is_dir() and not name.begins_with("."): - ids.append(name) - name = dir.get_next() - dir.list_dir_end() - return ids if not ids.is_empty() else fallback - - -## Scan a directory for .glb asset IDs. Returns fallback list if directory absent. -## TODO: replace with manifest JSON for export builds (DirAccess won't list res:// in PCK). -static func _scan_asset_ids(dir_path: String, ext: String, fallback: Array) -> Array: - var dir := DirAccess.open(dir_path) - if dir == null: - return fallback - var ids: Array = [] - dir.list_dir_begin() - var name := dir.get_next() - while name != "": - if not dir.current_is_dir() and name.ends_with(ext): - ids.append(name.get_basename()) - name = dir.get_next() - dir.list_dir_end() - return ids if not ids.is_empty() else fallback - - func _get_clothing_ids_for_slot(slot: String) -> Array: # Clothing items from manifest — slot assignment is explicit, not prefix-based. var clothing_data: Variant = _manifest.get("clothing", {}) diff --git a/client/ui/hud.tscn b/client/ui/hud.tscn index 4e6d2b32f..12a94112c 100644 --- a/client/ui/hud.tscn +++ b/client/ui/hud.tscn @@ -1,6 +1,7 @@ -[gd_scene load_steps=2 format=3 uid="uid://cq1y5w3hmxr8b"] +[gd_scene load_steps=3 format=3 uid="uid://cq1y5w3hmxr8b"] [ext_resource type="Script" path="res://ui/hud.gd" id="1_hud"] +[ext_resource type="PackedScene" path="res://ui/star_map.tscn" id="2_starmap"] [node name="HUD" type="Control"] layout_mode = 3 @@ -36,3 +37,7 @@ text = "Mode: Baseline" [node name="TimeLabel" type="Label" parent="MarginContainer/VBoxContainer"] layout_mode = 2 text = "Time: 08:00" + +; #674: Star map insert — concentric hop-ring view, hidden by default, toggled via keybind +[node name="StarMap" parent="." instance=ExtResource("2_starmap")] +visible = false diff --git a/client/ui/main_menu.gd b/client/ui/main_menu.gd index 992aea439..e50a83c14 100644 --- a/client/ui/main_menu.gd +++ b/client/ui/main_menu.gd @@ -62,7 +62,7 @@ func _show_character_creation() -> void: _char_creation.creation_cancelled.connect(_on_creation_cancelled) -func _on_creation_confirmed(descriptor: CharacterVisualDescriptor) -> void: +func _on_creation_confirmed(descriptor) -> void: if _char_creation != null and is_instance_valid(_char_creation): _char_creation.queue_free() _char_creation = null diff --git a/client/ui/star_map.gd b/client/ui/star_map.gd new file mode 100644 index 000000000..45721d58c --- /dev/null +++ b/client/ui/star_map.gd @@ -0,0 +1,529 @@ +class_name StarMapRenderer +extends Control + +## Star map — concentric hop-ring view of the Settled Reach gate network (#674). +## Renders 301 systems as dots on concentric rings (hop distance from Gateway). +## Sector-colored: core (white-gold), north (blue), south (orange), east (green), west (tan). +## +## Data source: res://data/star_map_data.json (generated from star-map.json + systems.db). +## Regenerate with: tooling/generate-star-map-data.py +## +## D-013: Diegetic neural insert overlay. Accessible from the insert UI. +## Parent epic: #51 (Diegetic Insert/Minimap), ticket #674. + +const DATA_PATH := "res://data/star_map_data.json" + +# Layout +const MAP_CENTER_FRACTION := Vector2(0.5, 0.5) # center of control +const MIN_RING_RADIUS: float = 30.0 # innermost ring (hop 0 = gateway dot only) +const RING_SPACING: float = 22.0 # pixels between hop rings +const MAX_HOP_RINGS: int = 24 # max hop distance we render rings for + +# Dot sizing +const DOT_RADIUS_HUB: float = 4.5 +const DOT_RADIUS_JUNCTION: float = 3.5 +const DOT_RADIUS_DEFAULT: float = 2.5 +const DOT_RADIUS_DEAD_END: float = 2.0 +const GATEWAY_RADIUS: float = 6.0 + +# Selection +const SELECTION_RING_RADIUS: float = 8.0 +const HIT_RADIUS: float = 10.0 # click tolerance + +# Edge rendering +const EDGE_WIDTH: float = 0.4 +const EDGE_ALPHA: float = 0.12 +const EDGE_SELECTED_ALPHA: float = 0.5 + +# Colors — sector palette from wireframe +const COLOR_BG: Color = Color("#0d1117") +const COLOR_RING: Color = Color("#1a2030") +const COLOR_RING_MAJOR: Color = Color("#222a3a") +const COLOR_GATEWAY: Color = Color("#f0d060") +const COLOR_SELECTION: Color = Color("#f0d060") +const COLOR_TEXT: Color = Color("#c8d0e0") +const COLOR_TEXT_DIM: Color = Color("#667788") +const COLOR_INFO_BG: Color = Color(0.05, 0.08, 0.14, 0.92) + +const SECTOR_COLORS: Dictionary = { + "core": Color("#c8d0e0"), + "north_reach": Color("#4488aa"), + "south_reach": Color("#aa6644"), + "east_reach": Color("#44aa66"), + "west_reach": Color("#aa8844"), + "deep_frontier": Color("#556677"), + "unknown": Color("#445566"), +} + +const SECTOR_LABELS: Dictionary = { + "north_reach": "NORTH REACH", + "south_reach": "SOUTH REACH", + "east_reach": "EAST REACH", + "west_reach": "WEST REACH", +} + +# Quadrant angles for sector placement (radians, 0 = right, counterclockwise) +# North = top (-PI/2), East = right (0), South = bottom (PI/2), West = left (PI) +const SECTOR_ANGLE_CENTER: Dictionary = { + "north_reach": -PI / 2.0, + "east_reach": 0.0, + "south_reach": PI / 2.0, + "west_reach": PI, +} +const SECTOR_ANGLE_SPREAD: float = PI / 2.5 # each sector occupies ~72° of arc +const CORE_ANGLE_SPREAD: float = TAU # core systems spread full circle +const DEEP_FRONTIER_ANGLE_SPREAD: float = TAU # deep frontier wraps entire outer edge + +# Pan/zoom +const ZOOM_MIN: float = 0.3 +const ZOOM_MAX: float = 3.0 +const ZOOM_STEP: float = 0.15 + +# Internal state +var _nodes: Array = [] +var _edges: Array = [] +var _node_positions: Dictionary = {} # system_id -> Vector2 (screen coords relative to map center) +var _node_lookup: Dictionary = {} # system_id -> node dict +var _selected_system: String = "" +var _hovered_system: String = "" + +var _zoom: float = 1.0 +var _pan_offset: Vector2 = Vector2.ZERO +var _is_panning: bool = false +var _pan_start: Vector2 = Vector2.ZERO +var _pan_start_offset: Vector2 = Vector2.ZERO + +var _data_loaded: bool = false +var _insert_active: bool = true +var _show_edges: bool = false # toggle edge display +var _dirty: bool = true # redraw needed — set by state changes, cleared after _draw + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_STOP + _load_data() + if _data_loaded: + _compute_layout() + + +func _process(_delta: float) -> void: + if not visible: + return + if _dirty and _data_loaded: + queue_redraw() + _dirty = false + + +## Called from main.gd when insert state changes. +## Only force-hides when insert is inactive. Does NOT auto-show — star map is +## modal (player opens via toggle_visible()), not always-on like the minimap. +func set_insert_active(active: bool) -> void: + _insert_active = active + if not active: + visible = false + + +## Toggle visibility (e.g., from a keybind or button). +func toggle_visible() -> void: + visible = not visible + if visible: + _dirty = true + + +## Return the currently selected system data, or empty dict. +func get_selected_system() -> Dictionary: + return _node_lookup.get(_selected_system, {}) + + +## Return total system count. +func get_system_count() -> int: + return _nodes.size() + + +## Return total edge count. +func get_edge_count() -> int: + return _edges.size() + + +# ============================================================================= +# Data loading +# ============================================================================= + +func _load_data() -> void: + if not FileAccess.file_exists(DATA_PATH): + push_warning("StarMapRenderer: data file not found at %s" % DATA_PATH) + return + var file := FileAccess.open(DATA_PATH, FileAccess.READ) + if file == null: + push_warning("StarMapRenderer: could not open %s" % DATA_PATH) + return + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if not (parsed is Dictionary): + push_warning("StarMapRenderer: invalid JSON in %s" % DATA_PATH) + return + var data := parsed as Dictionary + _nodes = data.get("nodes", []) + _edges = data.get("edges", []) + for node: Dictionary in _nodes: + _node_lookup[node.get("system_id", "")] = node + _data_loaded = true + + +# ============================================================================= +# Layout — place systems on concentric rings by hop distance +# ============================================================================= + +func _compute_layout() -> void: + _node_positions.clear() + + # Group nodes by hop distance + var rings: Dictionary = {} # hop -> Array of nodes + for node: Dictionary in _nodes: + var hop: int = int(node.get("hop_distance", 0)) + if not rings.has(hop): + rings[hop] = [] + rings[hop].append(node) + + # Place each ring + for hop: int in rings: + var ring_nodes: Array = rings[hop] + var radius: float = MIN_RING_RADIUS + hop * RING_SPACING + + if hop == 0: + # Gateway at center + for node: Dictionary in ring_nodes: + _node_positions[node["system_id"]] = Vector2.ZERO + continue + + # Sort nodes within ring by sector for angular grouping + ring_nodes.sort_custom(_sort_by_sector_angle) + + # Distribute nodes within their sector's angular range + var sector_groups: Dictionary = {} + for node: Dictionary in ring_nodes: + var sector: String = node.get("geographic_sector", "unknown") + if not sector_groups.has(sector): + sector_groups[sector] = [] + sector_groups[sector].append(node) + + for sector: String in sector_groups: + var group: Array = sector_groups[sector] + var count: int = group.size() + + var center_angle: float + var spread: float + if sector == "core": + center_angle = 0.0 + spread = CORE_ANGLE_SPREAD + elif sector == "deep_frontier": + center_angle = 0.0 + spread = DEEP_FRONTIER_ANGLE_SPREAD + elif SECTOR_ANGLE_CENTER.has(sector): + center_angle = SECTOR_ANGLE_CENTER[sector] + spread = SECTOR_ANGLE_SPREAD + else: + center_angle = 0.0 + spread = TAU + + # Distribute evenly within sector arc, with deterministic offset per system + for i: int in range(count): + var node: Dictionary = group[i] + var t: float + if count == 1: + t = 0.0 + else: + t = float(i) / float(count) - 0.5 # -0.5 to +0.5 + var angle: float = center_angle + t * spread + # Add small per-node jitter based on system_id hash for visual variety + var jitter: float = _system_hash(node["system_id"]) * 0.08 + angle += jitter + # Slight radial variation to avoid perfect circles + var r_var: float = radius + _system_hash(node["system_id"] + "r") * RING_SPACING * 0.3 + _node_positions[node["system_id"]] = Vector2(cos(angle), sin(angle)) * r_var + + +func _sort_by_sector_angle(a: Dictionary, b: Dictionary) -> bool: + var sa: float = _sector_sort_key(a) + var sb: float = _sector_sort_key(b) + if sa != sb: + return sa < sb + return a.get("system_id", "") < b.get("system_id", "") + + +func _sector_sort_key(node: Dictionary) -> float: + var sector: String = node.get("geographic_sector", "unknown") + match sector: + "core": return 0.0 + "north_reach": return 1.0 + "east_reach": return 2.0 + "south_reach": return 3.0 + "west_reach": return 4.0 + "deep_frontier": return 5.0 + _: return 6.0 + + +## Deterministic float in [-1, 1] from a string key. +func _system_hash(key: String) -> float: + var h: int = key.hash() & 0x7FFFFFFF # mask to 31-bit positive range + return float(h) / 2147483647.0 * 2.0 - 1.0 + + +# ============================================================================= +# Drawing +# ============================================================================= + +func _draw() -> void: + if not _data_loaded: + return + + var sz: Vector2 = get_rect().size + var center: Vector2 = sz * MAP_CENTER_FRACTION + _pan_offset + + # Background + draw_rect(Rect2(Vector2.ZERO, sz), COLOR_BG) + + # Hop rings (concentric circles) + _draw_rings(center) + + # Sector labels + _draw_sector_labels(center) + + # Edges (gate connections) + if _show_edges or _selected_system != "": + _draw_edges(center) + + # System dots + _draw_systems(center) + + # Selection highlight + if _selected_system != "": + _draw_selection(center) + + # Info panel for selected system + if _selected_system != "": + _draw_info_panel(sz) + + # Title + _draw_title() + + +func _draw_rings(center: Vector2) -> void: + for hop: int in range(MAX_HOP_RINGS + 1): + var radius: float = (MIN_RING_RADIUS + hop * RING_SPACING) * _zoom + if radius < 1.0 or radius > 2000.0: + continue + var color: Color = COLOR_RING_MAJOR if hop % 5 == 0 else COLOR_RING + draw_arc(center, radius, 0.0, TAU, 64, color, 0.5 if hop % 5 == 0 else 0.3, true) + + +func _draw_sector_labels(center: Vector2) -> void: + var label_radius: float = (MIN_RING_RADIUS + 12 * RING_SPACING) * _zoom + for sector: String in SECTOR_LABELS: + var angle: float = SECTOR_ANGLE_CENTER.get(sector, 0.0) + var pos: Vector2 = center + Vector2(cos(angle), sin(angle)) * label_radius + var label: String = SECTOR_LABELS[sector] + var color: Color = SECTOR_COLORS.get(sector, COLOR_TEXT_DIM) + var font := get_theme_default_font() + var font_size: int = 10 + var text_size: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size) + draw_string(font, pos - text_size / 2.0, label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, color) + + +func _draw_systems(center: Vector2) -> void: + for node: Dictionary in _nodes: + var sid: String = node.get("system_id", "") + if not _node_positions.has(sid): + continue + var pos: Vector2 = center + _node_positions[sid] * _zoom + var sector: String = node.get("geographic_sector", "unknown") + var topology: String = node.get("gate_topology", "") + var color: Color = SECTOR_COLORS.get(sector, COLOR_TEXT_DIM) + var radius: float = _dot_radius(topology) + + # Gateway gets special treatment + if node.get("is_gateway", false): + color = COLOR_GATEWAY + radius = GATEWAY_RADIUS + + # Dim deep frontier slightly + if sector == "deep_frontier": + color.a = 0.7 + + # Hover highlight + if sid == _hovered_system and sid != _selected_system: + draw_arc(pos, radius + 3.0, 0.0, TAU, 16, Color(color.r, color.g, color.b, 0.4), 1.0, true) + + draw_circle(pos, radius, color) + + +func _draw_edges(center: Vector2) -> void: + for edge: Array in _edges: + if edge.size() < 2: + continue + var sid_a: String = edge[0] + var sid_b: String = edge[1] + if not _node_positions.has(sid_a) or not _node_positions.has(sid_b): + continue + var pos_a: Vector2 = center + _node_positions[sid_a] * _zoom + var pos_b: Vector2 = center + _node_positions[sid_b] * _zoom + + var alpha: float = EDGE_ALPHA + # Highlight edges connected to selected system + if _selected_system == sid_a or _selected_system == sid_b: + alpha = EDGE_SELECTED_ALPHA + + var color := Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, alpha) + draw_line(pos_a, pos_b, color, EDGE_WIDTH, true) + + +func _draw_selection(center: Vector2) -> void: + if not _node_positions.has(_selected_system): + return + var pos: Vector2 = center + _node_positions[_selected_system] * _zoom + draw_arc(pos, SELECTION_RING_RADIUS, 0.0, TAU, 24, COLOR_SELECTION, 1.2, true) + + # Draw label next to selection + var node: Dictionary = _node_lookup.get(_selected_system, {}) + var label: String = node.get("proper_name", _selected_system) + if label.is_empty(): + label = _selected_system + var font := get_theme_default_font() + draw_string(font, pos + Vector2(SELECTION_RING_RADIUS + 4, 4), label, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, COLOR_SELECTION) + + +func _draw_info_panel(sz: Vector2) -> void: + var node: Dictionary = _node_lookup.get(_selected_system, {}) + if node.is_empty(): + return + + var panel_w: float = 220.0 + var panel_h: float = 130.0 + var margin: float = 16.0 + var panel_pos := Vector2(sz.x - panel_w - margin, margin) + + # Background + draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)), COLOR_INFO_BG) + draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)), Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.15), false, 1.0) + + var font := get_theme_default_font() + var y: float = panel_pos.y + 20.0 + var x: float = panel_pos.x + 12.0 + var line_h: float = 18.0 + + # System name + var name: String = node.get("proper_name", "") + if name.is_empty(): + name = node.get("system_id", "Unknown") + draw_string(font, Vector2(x, y), name, HORIZONTAL_ALIGNMENT_LEFT, -1, 14, COLOR_TEXT) + y += line_h + + # System ID + draw_string(font, Vector2(x, y), node.get("system_id", ""), HORIZONTAL_ALIGNMENT_LEFT, -1, 10, COLOR_TEXT_DIM) + y += line_h + + # Sector + var sector: String = node.get("geographic_sector", "").replace("_", " ").capitalize() + var sector_color: Color = SECTOR_COLORS.get(node.get("geographic_sector", ""), COLOR_TEXT_DIM) + draw_string(font, Vector2(x, y), "Sector: " + sector, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, sector_color) + y += line_h + + # Hop distance + draw_string(font, Vector2(x, y), "Hop distance: " + str(node.get("hop_distance", "?")), HORIZONTAL_ALIGNMENT_LEFT, -1, 11, COLOR_TEXT_DIM) + y += line_h + + # Topology + var topo: String = node.get("gate_topology", "").replace("_", " ").capitalize() + draw_string(font, Vector2(x, y), "Topology: " + topo, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, COLOR_TEXT_DIM) + y += line_h + + # Gate connections + draw_string(font, Vector2(x, y), "Gates: " + str(node.get("aperture_count", 0)) + " apertures", HORIZONTAL_ALIGNMENT_LEFT, -1, 11, COLOR_TEXT_DIM) + + +func _draw_title() -> void: + var font := get_theme_default_font() + draw_string(font, Vector2(16, 28), "THE REACH — NAVIGATOR", HORIZONTAL_ALIGNMENT_LEFT, -1, 16, COLOR_TEXT) + draw_string(font, Vector2(16, 44), "Concord Assembly Gate Network — %d Systems" % _nodes.size(), HORIZONTAL_ALIGNMENT_LEFT, -1, 10, COLOR_TEXT_DIM) + + +func _dot_radius(topology: String) -> float: + match topology: + "hub": return DOT_RADIUS_HUB + "junction": return DOT_RADIUS_JUNCTION + "dead_end": return DOT_RADIUS_DEAD_END + _: return DOT_RADIUS_DEFAULT + + +# ============================================================================= +# Input — selection, pan, zoom +# ============================================================================= + +func _gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton: + var mb := event as InputEventMouseButton + if mb.pressed: + match mb.button_index: + MOUSE_BUTTON_LEFT: + _handle_click(mb.position) + MOUSE_BUTTON_MIDDLE: + _is_panning = true + _pan_start = mb.position + _pan_start_offset = _pan_offset + MOUSE_BUTTON_WHEEL_UP: + var old_zoom := _zoom + _zoom = clampf(_zoom + ZOOM_STEP, ZOOM_MIN, ZOOM_MAX) + if _zoom != old_zoom: + _dirty = true + MOUSE_BUTTON_WHEEL_DOWN: + var old_zoom := _zoom + _zoom = clampf(_zoom - ZOOM_STEP, ZOOM_MIN, ZOOM_MAX) + if _zoom != old_zoom: + _dirty = true + else: + if mb.button_index == MOUSE_BUTTON_MIDDLE: + _is_panning = false + + elif event is InputEventMouseMotion: + var mm := event as InputEventMouseMotion + if _is_panning: + _pan_offset = _pan_start_offset + (mm.position - _pan_start) + _dirty = true + else: + _update_hover(mm.position) + + +## Find the system_id of the nearest node to screen position, or "" if none within HIT_RADIUS. +func _find_nearest_system(pos: Vector2) -> String: + var sz: Vector2 = get_rect().size + var center: Vector2 = sz * MAP_CENTER_FRACTION + _pan_offset + var best_dist: float = HIT_RADIUS + var best_sid: String = "" + for node: Dictionary in _nodes: + var sid: String = node.get("system_id", "") + if not _node_positions.has(sid): + continue + var node_pos: Vector2 = center + _node_positions[sid] * _zoom + var dist: float = pos.distance_to(node_pos) + if dist < best_dist: + best_dist = dist + best_sid = sid + return best_sid + + +func _handle_click(pos: Vector2) -> void: + var nearest := _find_nearest_system(pos) + if nearest != "": + _selected_system = nearest + _show_edges = true + else: + _selected_system = "" + _show_edges = false + _dirty = true + + +func _update_hover(pos: Vector2) -> void: + var nearest := _find_nearest_system(pos) + if nearest != _hovered_system: + _hovered_system = nearest + _dirty = true diff --git a/client/ui/star_map.tscn b/client/ui/star_map.tscn new file mode 100644 index 000000000..0754fb0f6 --- /dev/null +++ b/client/ui/star_map.tscn @@ -0,0 +1,18 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/star_map.gd" id="1_starmap"] + +; #674: Star map insert module — concentric hop-ring view of the Settled Reach gate network. +; Sector-colored, interactive selection, pan/zoom. Accessible from the insert UI. +; Data source: res://data/star_map_data.json (enriched from star-map.json + systems.db). +; Positioned as full-size overlay. Toggle visibility via set_insert_active() or toggle_visible(). + +[node name="StarMapRenderer" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 +script = ExtResource("1_starmap") diff --git a/tooling/generate-character-manifest b/tooling/generate-character-manifest new file mode 100755 index 000000000..eabbe8385 --- /dev/null +++ b/tooling/generate-character-manifest @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Generate client/assets/characters/manifest.json from the asset directories. + +Run this whenever artists add new assets so the manifest stays in sync: + tooling/generate-character-manifest + +The manifest is the single source of truth for CharacterCreation asset IDs. +DirAccess.open() cannot enumerate res:// paths in exported PCK builds (#720). + +Output: client/assets/characters/manifest.json +""" + +import json +import os +import sys + +ASSETS_ROOT = os.path.join(os.path.dirname(__file__), "..", "client", "assets", "characters") +MANIFEST_PATH = os.path.join(ASSETS_ROOT, "manifest.json") + + +def scan_glb_ids(dir_path: str) -> list[str]: + """Return sorted list of .glb basenames (without extension) in dir_path.""" + if not os.path.isdir(dir_path): + return [] + ids = sorted( + os.path.splitext(f)[0] + for f in os.listdir(dir_path) + if f.endswith(".glb") and not f.startswith(".") + ) + return ids + + +def scan_subdirs(dir_path: str) -> list[str]: + """Return sorted list of subdirectory names in dir_path.""" + if not os.path.isdir(dir_path): + return [] + return sorted( + d for d in os.listdir(dir_path) + if os.path.isdir(os.path.join(dir_path, d)) and not d.startswith(".") + ) + + +def infer_clothing_slot(item_id: str) -> str: + """Infer clothing slot from item_id by convention.""" + id_lower = item_id.lower() + if any(k in id_lower for k in ("boot", "shoe", "sandal", "slipper")): + return "feet" + if any(k in id_lower for k in ("pant", "trouser", "skirt", "short")): + return "legs" + if any(k in id_lower for k in ("glove", "gauntlet")): + return "hands" + # Default: torso (jacket, tunic, shirt, coveralls, vest, etc.) + return "torso" + + +def build_manifest() -> dict: + # Body types: subdirs under bodies/ + body_types = scan_subdirs(os.path.join(ASSETS_ROOT, "bodies")) + + # Heads: .glb files in heads/templates/ + heads = scan_glb_ids(os.path.join(ASSETS_ROOT, "heads", "templates")) + + # Hair: .glb files in hair/ + hair = scan_glb_ids(os.path.join(ASSETS_ROOT, "hair")) + + # Facial hair: .glb files in facial_hair/ + facial_hair = scan_glb_ids(os.path.join(ASSETS_ROOT, "facial_hair")) + + # Eyebrows: .glb files in eyebrows/ + eyebrows = scan_glb_ids(os.path.join(ASSETS_ROOT, "eyebrows")) + + # Clothing: subdirs under clothing/, each assigned a slot + clothing_items = scan_subdirs(os.path.join(ASSETS_ROOT, "clothing")) + clothing: dict = {} + for item_id in clothing_items: + clothing[item_id] = {"slot": infer_clothing_slot(item_id)} + + # Accessories: no directory yet — leave empty + accessories: list = [] + acc_dir = os.path.join(ASSETS_ROOT, "accessories") + if os.path.isdir(acc_dir): + accessories = scan_glb_ids(acc_dir) + + return { + "body_types": body_types, + "heads": heads, + "hair": hair, + "facial_hair": facial_hair, + "eyebrows": eyebrows, + "clothing": clothing, + "accessories": accessories, + } + + +def main() -> None: + manifest = build_manifest() + output = json.dumps(manifest, indent=2) + "\n" + with open(MANIFEST_PATH, "w", encoding="utf-8") as f: + f.write(output) + print(f"Written: {MANIFEST_PATH}") + print(f" body_types : {len(manifest['body_types'])}") + print(f" heads : {len(manifest['heads'])}") + print(f" hair : {len(manifest['hair'])}") + print(f" facial_hair: {len(manifest['facial_hair'])}") + print(f" eyebrows : {len(manifest['eyebrows'])}") + print(f" clothing : {len(manifest['clothing'])}") + print(f" accessories: {len(manifest['accessories'])}") + + +if __name__ == "__main__": + main() diff --git a/tooling/generate-star-map-data.py b/tooling/generate-star-map-data.py new file mode 100755 index 000000000..22aa0a78e --- /dev/null +++ b/tooling/generate-star-map-data.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Generate client/data/star_map_data.json from star-map.json + systems.db. + +Run from any directory — paths are resolved relative to this script's location: + python3 tooling/generate-star-map-data.py + python3 tooling/generate-star-map-data.py --check # exit 1 if committed JSON is stale + +Sources: + docs/design/star-map.json — graph topology (nodes + edges) + server/server/data/systems.db — proper names, geographic sectors + +Output: + client/data/star_map_data.json — self-contained client data for the star map UI +""" + +import json +import os +import sqlite3 +import sys +import tempfile + +# Resolve project root from this script's location: tooling/ is one level below root. +# Works regardless of cwd — no fragile relative path guessing. +_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +_PROJECT_ROOT = os.path.dirname(_SCRIPT_DIR) + +# Worktree layout: settled-reach/{client,server,main}/ +# This script lives in client/tooling/, so _PROJECT_ROOT = client/. +# The parent of _PROJECT_ROOT is the worktree parent where sibling dirs live. +_WORKTREE_PARENT = os.path.dirname(_PROJECT_ROOT) + +STAR_MAP_PATH = os.path.join(_PROJECT_ROOT, "docs", "design", "star-map.json") +SYSTEMS_DB_PATH = os.path.join(_WORKTREE_PARENT, "server", "server", "data", "systems.db") +OUTPUT_PATH = os.path.join(_PROJECT_ROOT, "client", "data", "star_map_data.json") + + +def generate() -> dict: + """Generate the enriched star map data dict.""" + if not os.path.exists(STAR_MAP_PATH): + print(f"ERROR: star-map.json not found at {STAR_MAP_PATH}", file=sys.stderr) + sys.exit(1) + if not os.path.exists(SYSTEMS_DB_PATH): + print(f"ERROR: systems.db not found at {SYSTEMS_DB_PATH}", file=sys.stderr) + sys.exit(1) + + with open(STAR_MAP_PATH) as f: + star_map = json.load(f) + + conn = sqlite3.connect(SYSTEMS_DB_PATH) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute( + "SELECT system_id, proper_name, geographic_sector, geographic_band " + "FROM star_systems" + ) + db_lookup = {row["system_id"]: dict(row) for row in cur.fetchall()} + conn.close() + + nodes = [] + for n in star_map["nodes"]: + sid = n["system_id"] + db = db_lookup.get(sid, {}) + entry = { + "system_id": sid, + "proper_name": db.get("proper_name", ""), + "geographic_sector": db.get("geographic_sector", "unknown"), + "geographic_band": db.get("geographic_band", ""), + "gate_topology": n["gate_topology"], + "aperture_count": n["aperture_count"], + "gate_connections": n["gate_connections"], + "hop_distance": n["hop_distance_from_gateway"], + } + if n.get("is_gateway"): + entry["is_gateway"] = True + nodes.append(entry) + + nodes.sort(key=lambda x: (x["hop_distance"], x["system_id"])) + + return { + "_meta": { + "generated_from": "star-map.json + systems.db", + "system_count": len(nodes), + "edge_count": len(star_map["edges"]), + "note": "Client-side star map data. Regenerate with: tooling/generate-star-map-data.py", + }, + "nodes": nodes, + "edges": star_map["edges"], + } + + +def write_output(data: dict, path: str) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + + +def main() -> None: + check_mode = "--check" in sys.argv + + data = generate() + + if check_mode: + # Generate to temp file and compare against committed JSON + if not os.path.exists(OUTPUT_PATH): + print(f"STALE: {OUTPUT_PATH} does not exist — run without --check to generate") + sys.exit(1) + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp: + json.dump(data, tmp, indent=2, ensure_ascii=False) + tmp.write("\n") + tmp_path = tmp.name + try: + with open(tmp_path) as a, open(OUTPUT_PATH) as b: + if a.read() != b.read(): + print(f"STALE: {OUTPUT_PATH} differs from generated output") + print("Run: python3 tooling/generate-star-map-data.py") + sys.exit(1) + print(f"OK: {OUTPUT_PATH} is up to date") + finally: + os.unlink(tmp_path) + else: + write_output(data, OUTPUT_PATH) + print(f"Generated {OUTPUT_PATH}") + print(f" Nodes: {data['_meta']['system_count']}, Edges: {data['_meta']['edge_count']}") + + +if __name__ == "__main__": + main()