Compare commits
@@ -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 <type> <title> [--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
|
||||
@@ -66,8 +66,22 @@ works on screen before invoking `/pr-push`. If they haven't, ask:
|
||||
|
||||
```bash
|
||||
git status
|
||||
git diff --stat
|
||||
```
|
||||
|
||||
**Run both commands from the repo root** (`git rev-parse --show-toplevel`).
|
||||
Running from a subdirectory can cause paths to not resolve, hiding real
|
||||
changes — Sprint 30 proved this when `git diff HEAD -- server/src/bin/atlas.rs`
|
||||
returned 0 lines from the wrong CWD, masking uncommitted agent work.
|
||||
|
||||
**CRITICAL: Do not trust "already done" claims without checking git state.**
|
||||
If agents report that work was "already implemented in a prior commit," verify
|
||||
by checking `git status` and `git diff --stat` first. Grepping source files
|
||||
only proves the code exists on disk — it does NOT prove the code is committed.
|
||||
Uncommitted working-tree changes look identical to committed code when you
|
||||
read files. Only `git status` distinguishes "already shipped" from "just
|
||||
written by a teammate."
|
||||
|
||||
If there are uncommitted changes (staged or unstaged), run the **commit skill**
|
||||
first. Use the `/git-commit` skill to group changes into logical commits with
|
||||
proper conventional commit messages. Wait for commit to complete before
|
||||
|
||||
@@ -6,6 +6,25 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.1.30] — 2026-04-05
|
||||
|
||||
### 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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
# =============================================================================
|
||||
|
||||
@@ -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()
|
||||
@@ -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", {})
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"system_id": "GJ 1111",
|
||||
"proper_name": "Zenzele",
|
||||
"star_type": "unusual",
|
||||
"star_type": "M",
|
||||
"spectral_class": "M6.5",
|
||||
"wiki_data": {
|
||||
"habitable_count": 1,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"system_id": "GJ 3943",
|
||||
"proper_name": "GJ 3943",
|
||||
"star_type": "K+M",
|
||||
"proper_name": "Brandpunt",
|
||||
"star_type": "binary",
|
||||
"spectral_class": "K5V+M3V",
|
||||
"wiki_data": {
|
||||
"habitable_count": 1,
|
||||
"inhabited_count": 0,
|
||||
"inhabited_count": 1,
|
||||
"has_gas_giant": false,
|
||||
"has_asteroid_belt": true,
|
||||
"has_horizon_station": true,
|
||||
@@ -14,12 +14,12 @@
|
||||
"bodies": [
|
||||
{
|
||||
"body_id": "GJ3943b",
|
||||
"proper_name": null,
|
||||
"proper_name": "Skuiling",
|
||||
"body_type": "planet",
|
||||
"orbit_index": 1,
|
||||
"parent_body_id": null,
|
||||
"inhabited": false,
|
||||
"population": null,
|
||||
"inhabited": true,
|
||||
"population": 1800,
|
||||
"mass_class": "terrestrial",
|
||||
"surface_gravity": 0.62,
|
||||
"orbital_period_days": 130.0,
|
||||
@@ -27,10 +27,10 @@
|
||||
"atmosphere": "thin",
|
||||
"biome_summary": "cold_arid",
|
||||
"hydrosphere": "ice",
|
||||
"economic_role": null,
|
||||
"settlement_pattern": null,
|
||||
"industrial_corridor": null,
|
||||
"notes": "inner HZ marginal; thin atmosphere, low gravity, polar ice deposits; within parameters for emergency habitation only; not recommended for permanent settlement; unsettled"
|
||||
"economic_role": "research",
|
||||
"settlement_pattern": "urban_concentrated",
|
||||
"industrial_corridor": "research_export",
|
||||
"notes": "polar ice extraction provides water supply and habitat foundation; pressurized dome settlements anchored to ice-extraction framework; Agricultural Spectrum Research Compact facilities; primary K5V+M3V binary oscillation testing environment; 1,800 permanent residents plus rotating research cohort from member communities"
|
||||
},
|
||||
{
|
||||
"body_id": "GJ3943-belt",
|
||||
@@ -176,14 +176,14 @@
|
||||
"stations": [
|
||||
{
|
||||
"station_id": "GJ3943-oort-S1",
|
||||
"proper_name": "GJ 3943 Horizon",
|
||||
"proper_name": "Brandpunt Horizon",
|
||||
"orbits_body_id": "GJ3943-oort",
|
||||
"station_type": "horizon",
|
||||
"population": 0,
|
||||
"population": 120,
|
||||
"economic_role": "transit",
|
||||
"docking_class": "major",
|
||||
"has_gate_infrastructure": true,
|
||||
"notes": "horizon station — 2 apertures; through_route; connects Pedra Seca and GJ 546; unsettled gap in corridor; k-m transitional binary (shifting light registers as color oscillation on viewport); automated; Gate Corporation biennial maintenance"
|
||||
"notes": "horizon station — 2 apertures; through_route; connects Pedra Seca and Eerste Wacht; wave_5 settlement; Agricultural Spectrum Research Compact operations; Commission research contract; researcher rotation transit; K5V+M3V binary (shifting light registers as color oscillation on viewport)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"system_id": "GJ 4056",
|
||||
"proper_name": "GJ 4056",
|
||||
"star_type": "unusual",
|
||||
"spectral_class": "g",
|
||||
"star_type": "M",
|
||||
"spectral_class": "M4V",
|
||||
"wiki_data": {
|
||||
"habitable_count": 0,
|
||||
"inhabited_count": 0,
|
||||
|
||||
@@ -44,12 +44,12 @@
|
||||
"surface_gravity": 0.89,
|
||||
"orbital_period_days": 200.0,
|
||||
"rotation_period_hours": 24.8,
|
||||
"atmosphere": "breathable",
|
||||
"atmosphere": "standard",
|
||||
"biome_summary": "temperate",
|
||||
"hydrosphere": "rivers-lakes",
|
||||
"hydrosphere": "liquid_water",
|
||||
"economic_role": "agricultural",
|
||||
"settlement_pattern": "distributed-rural",
|
||||
"industrial_corridor": "west_reach",
|
||||
"settlement_pattern": "dispersed",
|
||||
"industrial_corridor": "agricultural_export",
|
||||
"notes": "primary inhabited world; K2V habitable zone; genuine agricultural potential found by founding pastoral cooperative; temperate conditions, good soil, river-valley farmland; Wave 3 settlement ~300 years old; population peaked ~80 years ago and in slow consistent decline as young adults leave for inner-corridor systems offering more economic variety; self-governing council of landholders (reformed ~150 years ago to include non-agricultural members); no Assembly or Compact presence; Gate Corporation maintenance rotation is extent of institutional contact"
|
||||
},
|
||||
{
|
||||
@@ -165,7 +165,7 @@
|
||||
"orbital_period_days": 27.8,
|
||||
"rotation_period_hours": 667.2,
|
||||
"atmosphere": "none",
|
||||
"biome_summary": "ice",
|
||||
"biome_summary": "frozen",
|
||||
"hydrosphere": null,
|
||||
"economic_role": null,
|
||||
"settlement_pattern": null,
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"economic_role": "agricultural",
|
||||
"settlement_pattern": "dispersed",
|
||||
"industrial_corridor": "agricultural_export",
|
||||
"notes": "primary inhabited world; wave_1 German heritage ('edge'); 2-aperture through_route; F2III bright giant — hot equator, settlement in polar and temperate zones; open-field agriculture with substantial surplus; light manufacturing (four centuries institutional investment); Council of Rand governance (nine seats, staggered terms, founding charter continuity); Compact of Westphalia founding member and treaty drafter; five centuries continuous self-governance; oldest governing body in outer west"
|
||||
"notes": "primary inhabited world; wave_1 German heritage ('edge'); 2-aperture through_route; F2III bright giant — significantly more UV-intense than a main-sequence F; equatorial zones inhospitable without shielding, settlement concentrated in polar and high-latitude temperate regions; star is post-main-sequence and progressing toward giant phase on geological timescales, but current habitability is stable for millions of years; open-field agriculture with substantial surplus; light manufacturing (four centuries institutional investment); Council of Rand governance (nine seats, staggered terms, founding charter continuity); Compact of Westphalia founding member and treaty drafter; five centuries continuous self-governance; oldest governing body in outer west"
|
||||
},
|
||||
{
|
||||
"body_id": "GJ601A-belt",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"system_id": "GJ 707",
|
||||
"proper_name": "Dois Sóis",
|
||||
"star_type": "unusual",
|
||||
"spectral_class": "binary",
|
||||
"star_type": "binary",
|
||||
"spectral_class": "K4V+K8V",
|
||||
"wiki_data": {
|
||||
"habitable_count": 1,
|
||||
"inhabited_count": 1,
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -1,5 +1,5 @@
|
||||
name: The Settled Reach
|
||||
version: 0.1.29
|
||||
version: 0.1.30
|
||||
repository: settled-reach
|
||||
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -1236,7 +1236,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.29"
|
||||
version = "0.1.30"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
Binary file not shown.
+80
-4
@@ -10,6 +10,7 @@
|
||||
//! tooling/atlas show-body "GJ 15Ab"
|
||||
//! tooling/atlas add-body --system "GJ 15A" --type planet --orbit 1 --id "GJ 15Ab"
|
||||
//! tooling/atlas stats
|
||||
//! tooling/atlas corridor-status # remaining systems by corridor/hop
|
||||
//! tooling/atlas populate # bulk classifier pass
|
||||
//!
|
||||
//! # Direct:
|
||||
@@ -182,6 +183,8 @@ enum Commands {
|
||||
#[arg(long, default_value = "wiki/star-systems")]
|
||||
wiki: String,
|
||||
},
|
||||
/// Show remaining unfinished systems grouped by geographic sector and hop distance
|
||||
CorridorStatus,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -909,7 +912,7 @@ fn generate_body_matrix(
|
||||
surface_gravity: Some(0.9),
|
||||
orbital_period_days: Some(365.0),
|
||||
rotation_period_hours: Some(24.0),
|
||||
atmosphere: Some("breathable".into()),
|
||||
atmosphere: Some("standard".into()),
|
||||
biome_summary: Some("temperate".into()),
|
||||
hydrosphere: None,
|
||||
economic_role: None,
|
||||
@@ -941,7 +944,7 @@ fn generate_body_matrix(
|
||||
surface_gravity: Some(0.85),
|
||||
orbital_period_days: Some(400.0),
|
||||
rotation_period_hours: Some(26.0),
|
||||
atmosphere: Some("breathable".into()),
|
||||
atmosphere: Some("standard".into()),
|
||||
biome_summary: Some("temperate".into()),
|
||||
hydrosphere: None,
|
||||
economic_role: None,
|
||||
@@ -1290,8 +1293,10 @@ fn cmd_commit_system(conn: &Connection, path: &str) {
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
b.atmosphere.as_deref() == Some("breathable")
|
||||
&& (b.body_type == "planet" || b.body_type == "moon")
|
||||
matches!(
|
||||
b.atmosphere.as_deref(),
|
||||
Some("breathable") | Some("standard")
|
||||
) && (b.body_type == "planet" || b.body_type == "moon")
|
||||
})
|
||||
.count() as i32;
|
||||
|
||||
@@ -1992,6 +1997,76 @@ fn format_population(pop: i64) -> String {
|
||||
|
||||
// cmd_populate removed — replaced by per-system author/commit workflow
|
||||
|
||||
fn cmd_corridor_status(conn: &Connection) {
|
||||
// Query unfinished systems grouped by geographic_sector and hop_distance_from_gateway.
|
||||
// "Unfinished" = no rows in bodies for this system_id.
|
||||
// LEFT JOIN so systems with no gate record are still counted (hop = NULL → "?").
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT s.geographic_sector, g.hop_distance_from_gateway, COUNT(*) AS remaining
|
||||
FROM star_systems s
|
||||
LEFT JOIN system_gates g ON s.system_id = g.system_id
|
||||
WHERE s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies)
|
||||
GROUP BY s.geographic_sector, g.hop_distance_from_gateway
|
||||
ORDER BY s.geographic_sector, g.hop_distance_from_gateway",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CorridorRow {
|
||||
sector: Option<String>,
|
||||
hop: Option<i32>,
|
||||
remaining: i64,
|
||||
}
|
||||
|
||||
let rows: Vec<CorridorRow> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(CorridorRow {
|
||||
sector: row.get(0)?,
|
||||
hop: row.get(1)?,
|
||||
remaining: row.get(2)?,
|
||||
})
|
||||
})
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
if rows.is_empty() {
|
||||
#[derive(Serialize)]
|
||||
struct EmptyResult {
|
||||
total: i64,
|
||||
message: &'static str,
|
||||
corridors: Vec<()>,
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&EmptyResult {
|
||||
total: 0,
|
||||
message: "All systems have bodies authored.",
|
||||
corridors: vec![],
|
||||
})
|
||||
.unwrap()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let total: i64 = rows.iter().map(|r| r.remaining).sum();
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CorridorResult {
|
||||
total: i64,
|
||||
corridors: Vec<CorridorRow>,
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&CorridorResult {
|
||||
total,
|
||||
corridors: rows,
|
||||
})
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2039,5 +2114,6 @@ fn main() {
|
||||
Commands::Unfinished { sector } => cmd_unfinished(&conn, sector.as_deref()),
|
||||
Commands::Next { hop, sector } => cmd_next(&conn, *hop, sector.as_deref()),
|
||||
Commands::SyncWiki { system_id, wiki } => cmd_sync_wiki(&conn, system_id.as_deref(), wiki),
|
||||
Commands::CorridorStatus => cmd_corridor_status(&conn),
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+111
@@ -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()
|
||||
Executable
+128
@@ -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()
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
## Supply Dependency
|
||||
|
||||
Mabuhay sits at the operational edge of the east corridor's settled network — 42.1 light years out, two gates, and a gate path to GJ 482A beyond it that has not yet been developed. The system was named by its wave_4 founding party, a Filipino-heritage agricultural cooperative whose founders used the old word for "long life" not as aspiration but as defiance — they had been told by at least two inner-corridor development agencies that the system's K-type primary did not warrant the investment of a permanent settlement, and they funded the charter themselves.
|
||||
Mabuhay sits at the operational edge of the east corridor's settled network — 42.1 light years out, two gates connecting to Dagat (GJ 482A) and Trung's Crossing (GJ 3533). The system was named by its wave_4 founding party, a Filipino-heritage agricultural cooperative whose founders used the old word for "long life" not as aspiration but as defiance — they had been told by at least two inner-corridor development agencies that the system's K-type primary did not warrant the investment of a permanent settlement, and they funded the charter themselves.
|
||||
|
||||
The founding cooperative was correct about one thing the development agencies missed: the system's cold habitable body supports a specific microbial ecology that, with modification, produces a highly efficient atmospheric nitrogen-fixing cycle. Mabuhay's farms are not productive by inner-reach standards. They are self-sufficient in a way that most frontier systems at this distance are not, which means the settlement has survived without reliable Assembly resupply for three generations now. It imports precision equipment, medical supplies, and communication hardware. Everything else it produces. This is not comfort. It is the minimum viable condition for sustained independence at 42 light years from Gateway.
|
||||
|
||||
@@ -26,21 +26,21 @@ The Assembly has no operational presence at Mabuhay. The founding cooperative fi
|
||||
|
||||
Mabuhay's current governance is a council of cooperative shares — every resident over the age of majority holds a share, the council is elected quarterly, and the share structure means a family that has been present since founding holds meaningfully more weight than a recent arrival. This is the intended design. The founding families built a governance system that reflects the settlement's history, and they are explicit about this to anyone who asks. They are less explicit about the specific voting structure that makes share dilution effectively impossible without constitutional amendment, which has never passed.
|
||||
|
||||
The Gate Corporation's presence is the only institutional link to the broader Reach that Mabuhay actively maintains. Maintenance certification and communication relay access are the practical reasons. The founding cooperative has also, quietly, used Gate Corp's information network to establish contact with the system at GJ 482A — not yet a settlement, but a surveyed horizon station that several Mabuhay families have expressed interest in developing. The Assembly survey data for GJ 482A has not been publicly released. Mabuhay appears to have its own.
|
||||
The Gate Corporation's presence is the only institutional link to the broader Reach that Mabuhay actively maintains. Maintenance certification and communication relay access are the practical reasons. The founding cooperative has also maintained closer ties with Dagat (GJ 482A) than either community publicly acknowledges to the Assembly. Dagat is a three-hundred-year-old wave_3 settlement at the next gate inward — the two systems have been each other's nearest neighbors since Mabuhay's founding — but the depth of the bilateral arrangement goes further than shared emergency protocols. Neither cooperative council has offered a complete accounting of the relationship when asked by Assembly representatives.
|
||||
|
||||
## Silence Topic
|
||||
|
||||
What Mabuhay does not discuss: its relationship with the unsurveyed system beyond.
|
||||
What Mabuhay does not discuss: the full extent of its arrangement with Dagat.
|
||||
|
||||
The horizon station at GJ 482A is accessible from Mabuhay's outer gate. The Assembly has not released a survey report for it. Mabuhay residents have not filed a survey notification, which the Assembly charter technically requires for any charted system within three hops of a registered settlement. They have not filed it because they do not want the Assembly to hold the development rights. The founding families have been conducting informal surveys for over a decade. What they have found — whether promising or unremarkable — is internal to Mabuhay's cooperative governance and not accessible to outside inquiry without cooperative council approval, which is not readily given to strangers.
|
||||
Dagat (GJ 482A) is the gate-adjacent system at hop 8 — the nearest settled neighbor and, for most practical purposes, Mabuhay's link to the broader Reach. Three centuries of proximity have produced something that neither community names directly. Mabuhay will confirm bilateral agreements covering emergency gate access, search and rescue, and certain cargo categorizations not standard in Assembly filings. What those categorizations cover, what commitments the bilateral agreements encode beyond the disclosed items, and whether the structure extends to governance arrangements that should have been reported under the cooperative charter — that is information the council holds internally and has never offered to Assembly compliance officers who have inquired.
|
||||
|
||||
## Narrative Hook
|
||||
|
||||
Mabuhay has been conducting informal surveys of the unsurveyed system adjacent to its outer gate for over a decade, and has not filed the Assembly notification that its charter requires, because the founding families intend to develop it themselves on terms the Assembly did not write.
|
||||
A freight operator passing through both Mabuhay and Dagat recently filed an anomalous customs notation with the Gate Corporation — a cargo classification applied to a shipment that passed through both systems using a category that has no Assembly-standard equivalent and that should have required full manifest documentation under the cooperative charter. The notation exists in the Gate Corp record. Mabuhay's council has not responded to the inquiry.
|
||||
|
||||
## Calibration Note
|
||||
|
||||
The stub lists this system as `unsettled`; the correct classification is `wave_4`, consistent with the east corridor's frontier development push and the system's through_route topology implying deliberate rather than incidental settlement. `wave_4` at `hop 9` represents the frontier edge of the east corridor's active settlement history — later than the inner corridor but earlier than the wave_5 pure-frontier systems. Through_route topology at hop 9 indicates a system positioned on the chain leading outward, which provides the same transit-value rationale that drove Trung's Crossing development one hop inward. The self-sufficiency profile justifies the absence of ongoing Assembly supply relationships despite the hop distance.
|
||||
The stub lists this system as `unsettled`; the correct classification is `wave_4`, consistent with the east corridor's frontier development push and the system's through_route topology implying deliberate rather than incidental settlement. `wave_4` at `hop 9` represents the frontier edge of the east corridor's active settlement history — later than the inner corridor but earlier than the wave_5 pure-frontier systems. Through_route topology at hop 9 indicates a system positioned on the chain leading outward, which provides the same transit-value rationale that drove Trung's Crossing development at comparable hop distance. The self-sufficiency profile justifies the absence of ongoing Assembly supply relationships despite the hop distance.
|
||||
|
||||
## Celestial Bodies
|
||||
<!-- READ-ONLY — generated from systems.db bodies/stations tables -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Zenzele
|
||||
**GJ 1111** | unusual-type | south_reach
|
||||
**GJ 1111** | M-type | south_reach
|
||||
|
||||
---
|
||||
|
||||
@@ -18,7 +18,7 @@ The name means "do it yourself." In Zulu — the language of Zenzele's founding
|
||||
|
||||
The star demands the ethic. GJ 1111 is an M6.5-type — one of the coolest, dimmest red dwarfs in the settled Reach, sitting near the boundary where stellar classification shades toward brown dwarf territory. The habitable zone exists, but it is close-in and tidally influenced. The primary inhabited body at Zenzele is a close-orbit station, not a planetary surface; the fraction of the population that lives planetside inhabits a world that experiences regular stellar flare events, requiring hardened infrastructure and an acceptance of periodic communication blackout. Zenzele has some of the most robust electromagnetic hardening standards in the south_reach, developed locally over three generations and now exported to newer systems. The community that designed its own infrastructure out of necessity found that the expertise was marketable.
|
||||
|
||||
Five gates make Zenzele a significant hub despite the hostile primary. The gate network connects to Lalande — the major hop-3 node — as well as to GJ 905, GJ 693, GJ 4053, and GJ 393. This puts Zenzele at the center of the outer south_reach's eastern arc, a distribution point for systems that would otherwise have limited connectivity. The transit cooperative that runs the gates is formally separate from the infrastructure guilds that handle station maintenance, but in practice they are deeply interlinked — many families have members in both.
|
||||
Five gates make Zenzele a significant hub despite the hostile primary. The gate network connects to Lalande — the major hop-3 node — as well as to GJ 905, GJ 693, GJ 4053, and GJ 393, making it the distribution anchor for the outer south_reach's eastern arc. The system's population is split between its two settled locations: Qina's surface holds the founding community of 380,000, the source of the hardening expertise and the origin point of the settlement's ethic; Zenzele Horizon, the oort station housing the gate complex, has grown to just over a million over three centuries, and is where most of the system's people now live. The transit cooperative that runs the gates is formally separate from the infrastructure guilds that handle station maintenance, but in practice they are deeply interlinked — many families have members in both.
|
||||
|
||||
The self-reliance culture is visible in how Zenzele handles outside assistance. The Assembly maintains a modest presence, as expected at hop 4, but the local governance structure — a rotating assembly of household representatives, modeled on historical precedent and modified three times over three hundred years — has always insisted on managing its own security and customs operations. The Assembly compliance office has a small staff here; enforcement of Assembly transit regulations is technically handled by Zenzele's own port authority operating under delegated authority. In practice, this works smoothly. The port authority holds the delegation seriously. But the distinction matters to residents, who will explain it at length if asked.
|
||||
|
||||
@@ -44,7 +44,7 @@ The Zenzele Flare Watch has been recording anomalous pattern activity in the sta
|
||||
|
||||
## Calibration Note
|
||||
|
||||
M6.5 places GJ 1111 at the extreme cool end of the M-dwarf sequence, close to the hydrogen-burning minimum mass. The unusual-type designation reflects that it sits at the boundary of standard stellar classification and requires non-standard infrastructure assumptions. This is the worldbuilding basis for Zenzele's electromagnetic hardening specialization.
|
||||
M6.5 places GJ 1111 at the extreme cool end of the M-dwarf sequence, close to the hydrogen-burning minimum mass. It is classified M, not unusual — the star is a genuine red dwarf, just the most extreme variety of one. The practical consequences of the extreme M-type classification (close-in habitable zone, tidal influence, irregular flare activity) are the worldbuilding basis for Zenzele's electromagnetic hardening specialization. The ~1.67M total population (380K surface + 1.2M horizon station + 86K industrial station) is intentionally structured: surface habitability is severely constrained by the stellar environment, which concentrates population at the horizon station rather than on the planetary surface — this distribution is correct and not an error.
|
||||
|
||||
## Celestial Bodies
|
||||
<!-- READ-ONLY — generated from systems.db bodies/stations tables -->
|
||||
@@ -64,7 +64,7 @@ M6.5 places GJ 1111 at the extreme cool end of the M-dwarf sequence, close to th
|
||||
|
||||
| ID | Name | Type | Orbits | Population | Economy | Governance | Docking | Gate | Districts |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| `GJ1111-oort-S1` | Zenzele Horizon | horizon | `GJ1111-oort` | 1M | transit | — | major | yes | 1 |
|
||||
| `GJ1111-oort-S1` | Zenzele Horizon | horizon | `GJ1111-oort` | 1.2M | transit | — | major | yes | 1 |
|
||||
| `GJ1111b-S1` | Isivikelo | industrial | `GJ1111b` | 86K | manufacturing | — | major | no | 1 |
|
||||
## Topology
|
||||
<!-- READ-ONLY — regenerated from star-map.json edges -->
|
||||
|
||||
@@ -54,7 +54,7 @@ The loop's geometry, Devereux's transit logs show, has shifted by a calculable f
|
||||
|
||||
## Calibration Note
|
||||
|
||||
The stub header specifies K-type while the star field shows "m" (lowercase, indicating M-type by catalog convention). The header K-type is preserved as the canonical identifier. Loop_member topology at hop 6 is consistent with a navigational waypoint system — the loop between Kovács Loop and Relay 7 gives Devereux strategic value disproportionate to its population. Wave_3 cross-corridor settlement is an acknowledged pattern in east_reach expansion history.
|
||||
M-type classification is correct — the primary is an M-dwarf, consistent with the header. Loop_member topology at hop 6 is consistent with a navigational waypoint system — the loop between Kovács Loop and Relay 7 gives Devereux strategic value disproportionate to its population. Wave_3 cross-corridor settlement is an acknowledged pattern in east_reach expansion history.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ A Frontier Compact navigator who has been pushing surveys beyond the last apertu
|
||||
|
||||
## Calibration Note
|
||||
|
||||
The stub header specifies M-type while the star field shows G2V — a genuinely sun-like star at hop 8 is the defining physical characteristic of this system and is treated as narratively significant. The header M-type is preserved as the canonical identifier per wiki convention. Wave_2 at hop 8 through_route represents the deepest confirmed organized settlement in the east_reach corridor — a community that has been operating at the frontier for four centuries. The through-route connecting to both GJ 848 (inward) and GJ 449 / Murakami's Lantern (outward dead-end) gives Dài Lộ its supply-gateway function. Assembly authority at this hop is acknowledged in principle only.
|
||||
G2V classification is correct — a genuinely sun-like star at hop 8 is the defining physical characteristic of this system and is treated as narratively significant. Wave_2 at hop 8 through_route represents the deepest confirmed organized settlement in the east_reach corridor — a community that has been operating at the frontier for four centuries. The through-route connecting to both GJ 848 (inward) and GJ 449 / Murakami's Lantern (outward dead-end) gives Dài Lộ its supply-gateway function. Assembly authority at this hop is acknowledged in principle only.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Entremeio is the corridor's last named community before the deep chain enters te
|
||||
|
||||
The F9V primary is near-solar — warm, bright enough to feel familiar to anyone who has spent time in the inner Reach, dim enough to remind you that familiar is relative. The system is binary in a loose sense: a distant M-dwarf companion orbits at the outer edge of the system, contributing nothing to the habitable zone and appearing in the night sky of the habitable world as a faint red point that the community calls "the follower." The habitable world itself is adequate — thin atmosphere requiring supplemental pressure in lower elevations, surface water in equatorial lakes, soil chemistry that has responded to two centuries of agricultural amendment with the grudging cooperation typical of deep frontier worlds.
|
||||
|
||||
Wave_4 settlers arrived approximately 160 years ago, following the corridor through Nova Estrada's current position (unsettled at the time) from the mid-Reach. The founding cohort was a mixed south_reach group — Brazilian and Portuguese heritage predominant, with significant Filipino and Indonesian minority representation. The name passagem is Portuguese: passage, crossing, the place you go through. The founders named their settlement for its topology, not its conditions, which tells you what they understood about where they were. Entremeio is not a destination. It is the system between where you have been and where you are going.
|
||||
Wave_4 settlers arrived approximately 160 years ago, following the corridor through Nova Estrada's current position (unsettled at the time) from the mid-Reach. The founding cohort was a mixed south_reach group — Brazilian and Portuguese heritage predominant, with significant Filipino and Indonesian minority representation. The name entremeio is Portuguese: in-between, the interlude, the space that exists between the thing before and the thing after. The founders named their settlement for its topology, not its conditions, which tells you what they understood about where they were. Entremeio is not a destination. It is the system between where you have been and where you are going.
|
||||
|
||||
The community of approximately 700 people has the particular character of a through-route settlement that has accepted its transitional identity: they provision travelers, they maintain the corridor, and they do not pretend that the majority of people who pass through will remember the system's name. The settlement sits on the shore of the largest equatorial lake, where supplemental atmospheric pressure is unnecessary and the microclimate supports adapted agriculture without the full infrastructure investment that the rest of the world requires. The lakeside settlement has a quality that the deep frontier's drier, dimmer systems lack — something almost comfortable, almost inviting, almost enough to make a traveler consider staying. Almost.
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@ The community has absorbed at least four separate cohorts of crisis refugees ove
|
||||
|
||||
Altgrund has integrated four separate waves of crisis refugees across four centuries; the terms of each integration are archived but not discussed, and most of the descendants of those arriving cohorts have no idea what their founders agreed to.
|
||||
|
||||
## Calibration Note
|
||||
|
||||
Wave_2 settlement at hop 8 is an apparent anomaly. Wave_2 communities are typically found within hops 1-5 of Gateway; Altgrund's hop 8 position is an artifact of how hop distance is calculated. In the wave_2 era, Gateway was not yet the canonical hub from which all distances were measured — settlement expeditions launched from multiple staging points along what later became the corridor. Altgrund's founding cohort reached GJ 189 via a shorter route from an earlier hub. When Gateway's administrative authority was established and hop-distances were standardized from that single point, Altgrund and communities like it found themselves formally reclassified as deeper-frontier than their four hundred years of continuous settlement suggested. The community had not moved. The map had changed around them. Wave_2 at hop 8 is therefore correct: the hop distance is current-map accurate, and the wave designation reflects when the community was actually founded. The apparent contradiction is the setting, not an error.
|
||||
|
||||
---
|
||||
|
||||
## Celestial Bodies
|
||||
<!-- READ-ONLY — generated from systems.db bodies/stations tables -->
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ Henrique Pereira, the Portuguese-heritage founder who led the Wave 4 charter par
|
||||
|
||||
## Faction Notes
|
||||
|
||||
Pereira's Rest is governed by a community council that follows no formal governance template. Decisions are made by consensus among heads of household, a practice that worked when the community was sixty people and is strained but still functional at its current size of approximately eight hundred. There is no Assembly delegate, no corporate presence, and no Lattice Commission office. The Gate Corporation maintains the single aperture through a rotating service contract managed from Meridian — a technician comes through twice a year, checks the hardware, leaves.
|
||||
Pereira's Rest is governed by a community council that follows no formal governance template. Decisions are made by consensus among heads of household, a practice that worked when the community was sixty people and is increasingly strained at its current size of approximately forty-two thousand. There is no Assembly delegate, no corporate presence, and no Lattice Commission office. The Gate Corporation maintains the single aperture through a rotating service contract managed from Meridian — a technician comes through twice a year, checks the hardware, leaves.
|
||||
|
||||
Assembly authority is functionally nonexistent. Henrique Pereira's original charter is Assembly-registered, which technically establishes jurisdiction. No one has exercised it. The system does not pay Assembly assessments. It has never been asked. Pereira's Rest is too small and too isolated to appear in any Assembly enforcement priority framework.
|
||||
|
||||
@@ -38,7 +38,7 @@ The community council's consensus governance model has a structural problem that
|
||||
|
||||
## Narrative Hook
|
||||
|
||||
Pereira's Rest was founded to be beyond institutional reach and has succeeded — the community governs itself without external authority, Assembly or otherwise — but its consensus governance model, designed for sixty people, is quietly fracturing under the weight of eight hundred.
|
||||
Pereira's Rest was founded to be beyond institutional reach and has succeeded — the community governs itself without external authority, Assembly or otherwise — but its consensus governance model, designed for sixty people, is quietly fracturing under the weight of forty-two thousand.
|
||||
|
||||
## Calibration Note
|
||||
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
# THE DRIFTER'S GUIDE TO THE REACH — GJ 3943
|
||||
# THE DRIFTER'S GUIDE TO THE REACH — BRANDPUNT
|
||||
|
||||
**GJ 3943** is a transitional binary at the midpoint of an unsettled gap, which means it provides two colors of insufficient light and no other services.
|
||||
**BRANDPUNT** means focal point. The community at hop 17 named itself for what it does rather than what it is — an unusual choice in a corridor where systems are named for what they look like (Stilwater, Droëland, Skemeraand) or what they feel like (Eerste Wacht, Skuilplek, Helderoog). Brandpunt feels like a laboratory, which is what it is.
|
||||
|
||||
## The Binary Quality
|
||||
## Why the Binary Matters
|
||||
|
||||
A K-dwarf primary with an M-dwarf companion in a wide orbit. From the surface of the single rocky habitable zone world, the effect is not dramatic — it is more accurately described as a persistent variation in the quality of the dimness. The K-dwarf illuminates in amber. The M-dwarf companion shifts the color toward red depending on its orbital position relative to the surface. The modulation between two registers of insufficient light is visible enough that corridor travelers recognize it as a waypoint marker: the subtle color oscillation on viewport displays means you are passing through GJ 3943, which means you have left the settled corridor behind.
|
||||
The K5V+M3V binary at GJ 3943 produces a combined spectrum that no other inhabited system on this corridor can replicate. The companion drifts in a wide orbit; over a cycle of months, its spectral contribution shifts the total system illumination between the primary's amber-dominant K-type output and the cooler red of the M3V. The viewport oscillation that corridor travelers have long used as a waypoint marker — that subtle shift in color as you transit — turns out to be scientifically useful under specific conditions.
|
||||
|
||||
The habitable world is marginal. Thin atmosphere, low surface gravity, polar ice deposits as the primary water source. The wave-3 assessment said "within parameters for emergency habitation, not recommended for permanent settlement." The wave-4 reassessment, a century later, used identical language. Either the wave-4 team consulted the wave-3 record before visiting, or the system produces the same assessment in any competent observer.
|
||||
Specifically: the conditions under which you are trying to understand why adapted crop cultivars underperform when transplanted from one system to another.
|
||||
|
||||
## The Unsettled Gap
|
||||
The corridor's farming communities spent generations adapting Earth-derived cultivars to their specific stellar spectra, and then began exchanging seeds with each other without accounting for the fact that K5V and M2V and M3V are different light environments for which the same cultivar may have contradictory adaptations. Brandpunt was built thirty-five years ago to study this. The Agricultural Spectrum Research Compact runs the settlement. The farming communities provide food. Brandpunt provides the analysis that keeps the food growing.
|
||||
|
||||
GJ 3943 sits between Pedra Seca and Eerste Wacht — Pedra Seca being the last reliably provisioned system before the corridor thins, and Eerste Wacht being the 500-year-old anomaly at hop 18 that nobody followed the founding cohort to reach. Between them is this: two gates, an automated horizon station, a binary that illuminates the transit window in shifting amber and red, and a world the surveys have recommended against settling twice.
|
||||
## What Kind of Place It Is
|
||||
|
||||
The corridor economics do not support a community at GJ 3943. A settlement here would depend entirely on imported supply through a corridor that is itself thin and operated by settlements with barely enough surplus to sustain themselves. Nobody has proposed it. The binary shifts between its two shades of dim and the corridor traffic passes through.
|
||||
It is a pressurized dome settlement at the polar ice deposits on a thin-atmosphere world, staffed by agricultural scientists, spectral analysts, and corridor technical specialists — most of them Afrikaans-heritage, many of them educated at inner-Reach institutions before returning to serve the corridor's needs. The sky through the dome panels shifts slowly through K amber and M red on a logged spectral schedule, because the experiment requires tracking it.
|
||||
|
||||
The community is 1,800 people, more than half of whom hold advanced credentials of some kind. The canteen is institutional. The research library is extensive. The governance is a consortium council. It does not feel like the farming communities one and three hops away, and it is not supposed to. It is what the farming communities can do with surplus capital and a specific problem: they built a lab and staffed it with people who came back.
|
||||
|
||||
## Practical Information
|
||||
|
||||
GJ 3943 is seventeen hops from Gateway with two apertures connecting to Pedra Seca and Eerste Wacht. No services. The automated horizon station processes transit. Gate Corporation maintenance visits occur approximately every two years. The color oscillation on approach is the system's most distinctive feature and the only navigational landmark experienced corridor travelers use to confirm their position.
|
||||
Brandpunt is seventeen hops from Gateway with two apertures connecting to Pedra Seca and Eerste Wacht. The community imports food and exports research. The horizon station has capacity for researcher rotation transits and handles corridor through-traffic without difficulty. Visitors with legitimate research purposes are received. The Commission holds a contract here, which some corridor travelers regard as a mark against Brandpunt and some regard as a mark of competence. Neither position is entirely wrong.
|
||||
|
||||
Adams of the Guide rates GJ 3943: *Two shades of insufficient light. Emergency habitation capable.*
|
||||
Adams of the Guide rates Brandpunt: *Thirty-five years into proving the surveys assessed the wrong question. The oscillating light is the point.*
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# GJ 3943
|
||||
**GJ 3943** | K-type | deep_frontier
|
||||
# Brandpunt
|
||||
**GJ 3943** | binary | deep_frontier
|
||||
|
||||
---
|
||||
|
||||
@@ -10,29 +10,61 @@
|
||||
|---|---|
|
||||
| **Star** | K5V+M3V · 46.6 ly |
|
||||
| **Gates** | 2 aperture · through_route |
|
||||
| **Settlement** | unsettled |
|
||||
| **Settlement** | wave_5 |
|
||||
|
||||
---
|
||||
|
||||
GJ 3943 is a transitional binary — a K-dwarf primary with an M-dwarf companion in a wide orbit, producing a light environment that shifts between dim amber and dimmer red depending on the companion's position relative to the habitable zone. The effect is not dramatic from the surface of the single rocky world in the inner habitable zone. It is more accurately described as a persistent variation in the quality of the dimness — a modulation between two registers of insufficient light that the survey teams measured, noted, and found no reason to dwell on.
|
||||
The name means focal point in Afrikaans — the place where parallel light rays converge after passing through a lens. The founders chose it with precision. Brandpunt is not a farming community and not a frontier outpost in the way the corridor uses that term. It is what the name says: a place where dispersed technical capacity accumulates, is applied, and flows back out.
|
||||
|
||||
The habitable world is marginal. Thin atmosphere, low surface gravity, limited surface water concentrated in polar ice deposits that would require extraction infrastructure to access. The survey record from the wave_3 assessment period is brief: "within parameters for emergency habitation. Not recommended for permanent settlement at current corridor development levels." The wave_4 reassessment, conducted 120 years later, reached the same conclusion with identical language, suggesting either that the wave_4 team consulted the wave_3 record before visiting or that the system's characteristics produce the same assessment in any competent observer.
|
||||
The system's K5V+M3V binary produces a light environment that no other inhabited system on the deep-frontier corridor replicates. The M3V companion drifts in a wide orbit that brings its spectral contribution in and out of alignment with the primary over a cycle of several months — shifting the combined illumination at GJ3943b between the K5V's amber dominant and the M3V's cooler red contribution as the companion moves and its angular contribution to total system illumination varies. From the surface of the settlement, the shift is not dramatic. It manifests as a slow drift in the color temperature of daylight: amber for months, shading toward red, shading back. A human eye adapts without noticing the change. An agricultural cultivar's photosynthetic system does not adapt. It expresses.
|
||||
|
||||
The through-route connects Pedra Seca at GJ 421B to GJ 546. This is the corridor's unsettled gap — the stretch between the last reliably provisioned system at Pedra Seca and the wave_2 anomaly at GJ 546, where settlement exists for reasons that do not follow the normal logic of frontier expansion. GJ 3943 sits in the middle of that gap. Transit vessels pass through. The horizon station processes their transit. The binary's shifting light registers on the viewport displays as a subtle color oscillation that experienced corridor travelers recognize as the marker of this particular waypoint: you have left the settled corridor behind, and what lies ahead is a different kind of territory.
|
||||
This is the fact that Brandpunt was built to study.
|
||||
|
||||
Nobody has proposed settlement. The conditions do not warrant it and the corridor traffic does not require it. A community at GJ 3943 would depend entirely on imported supply through a corridor that is itself thin, fragile, and operated by settlements that have barely enough surplus to sustain themselves. The economics do not work. The survey data confirms that the economics do not work. The system remains what it has been for the entire duration of the Reach's expansion: a through-route waypoint, transited and unremarked, its binary star shifting between two shades of dim.
|
||||
Thirty-five years ago, a consortium of technical specialists from the corridor's farming communities recognized a problem that had been accumulating for decades without a name. The corridor's farming communities had each spent generations adapting Earth-derived crop cultivars to their specific stellar conditions — Pedra Seca's drought-resistance work under its K7V primary, Droëland's photosynthetic adaptations for K-type dimness, Ouplaas's long-cycle cereal genetics tuned to M-dwarf output. As the communities' supply relationships deepened, they began exchanging seeds along with goods. They were exchanging cultivars whose light-spectrum tuning was calibrated to different stars. The adapted strains performed poorly when transplanted to neighboring worlds whose stellar spectrum differed from their development environment — and nobody had the research infrastructure to explain why.
|
||||
|
||||
The founding consortium — eight specialists from four corridor communities, including three Khoikhoi-descended agricultural scientists who had trained at Lalande's technical institute and returned to serve the corridor's needs — identified GJ 3943 as the only natural multi-spectrum testing environment within the corridor's reach. The oscillating binary light would allow a single experimental planting to be assessed against both K5V and M3V spectral parameters within a single growing cycle. Neither parameter exists anywhere else on the corridor in isolation, let alone in combination. Every survey report had recommended against permanent settlement, citing the thin atmosphere, marginal gravity, and polar ice as the only water source. The founding consortium's response was that this made it appropriate. A natural laboratory should not be a place anyone would otherwise want to farm.
|
||||
|
||||
The Agricultural Spectrum Research Compact was incorporated thirty-five years ago. Each member community — Pedra Seca, Droëland, Skuilplek, Ouplaas, and eventually Eerste Wacht — contributes a defined annual share of agricultural surplus to the Compact. In return, members receive Brandpunt's cultivar testing results, light-condition specifications for adapted strains, and access to technical consulting from resident specialists. The system that three consecutive surveys had declared unviable for settlement turned out to be viable in the specific register of people who brought their food with them and had a consortium of farming communities to bring it from.
|
||||
|
||||
The settlement on GJ3943b is called Skuiling — Afrikaans for shelter. It is built into the polar terrain where the ice-extraction infrastructure provides both the water supply and the habitat foundation. Pressurized dome modules anchor to the extraction framework. The sky overhead shows the binary's color oscillation that corridor travelers recognize as the waypoint marker — from inside the domes, the binary's light comes through panels calibrated to specific spectral parameters and shifts on a scheduled cycle to match the companion's orbital position. Researchers track the spectral schedule with precision because the experiment requires it.
|
||||
|
||||
The community is 1,800 people. It holds more advanced degrees per capita than any farming community between hop 10 and hop 23. It does not govern by elder council. It does not maintain partnership protocols for genetic management. It does not keep multi-year emergency stores as a cultural practice. It has a research council, employment contracts, a canteen that produces reasonable food from imported ingredients, and a commission-registered horizon station with transit capacity for the rotation of researchers on secondment from member communities. The corridor's farming communities regard Brandpunt with a combination of appreciation and mild suspicion that the Compact's architects anticipated and have not tried to resolve. They know they depend on what Brandpunt produces. They are less certain about the kind of people who produce it.
|
||||
|
||||
---
|
||||
|
||||
## Supply Dependency
|
||||
|
||||
None.
|
||||
Brandpunt imports essentially all food from member communities. The agricultural surplus contributions from Pedra Seca, Droëland, and Ouplaas cover food requirements for the permanent population and the rotating research cohort. The community maintains six months of reserve stores — shorter than most deep-frontier communities hold, by design: the founding consortium's view was that extended reserve culture would signal a siege mentality that would undermine the Compact's collaborative model.
|
||||
|
||||
Technical components for the research infrastructure arrive through the corridor from inner-reach sources, sourced through Commission research procurement contracts. Brandpunt's Commission contract status is the subject of occasional pointed commentary from the corridor's more autonomy-oriented communities. The community's position is that institutional contracts fund the research that keeps their neighbors' crops viable, and their neighbors have not disagreed in terms that alter the arrangement.
|
||||
|
||||
---
|
||||
|
||||
## Faction Notes
|
||||
|
||||
No faction presence. The horizon station is automated. Gate Corporation maintenance visits follow the standard unsettled through-route schedule — approximately once per two years, shorter than the dead-end unsettled interval because through-route gates process more transits and require more frequent calibration.
|
||||
The **Agricultural Spectrum Research Compact** is Brandpunt's governing body for research priorities and funding allocation. Its council includes one representative from each member community plus elected representatives from Brandpunt's resident population. The founding communities hold veto power over research priority changes by supermajority, a provision the founding consortium inserted to ensure the Compact could not be redirected toward commercial applications that would benefit external interests over member communities.
|
||||
|
||||
The **Lattice Commission** holds an active research contract with Brandpunt's spectral analysis division — the only formal Commission institutional relationship in the deep-frontier Afrikaans corridor. The Commission's interest is the cultivar adaptation data, which has broader applications for wave_5 and wave_6 settlement of M-dwarf-heavy outer corridors. The arrangement is commercially explicit and does not extend to governance or administrative oversight, a distinction the Compact's charter specifies in detail.
|
||||
|
||||
No Syndic operations. No Assembly representation. No Guardians of Autonomy chapter. The community's founding philosophy was not anti-institutional but non-institutional: the Compact provides all necessary governance and the Commission provides external funding. Everything else is overhead they decline to carry.
|
||||
|
||||
---
|
||||
|
||||
## Silence Topic
|
||||
|
||||
The Compact's exact funding terms — what each member community pays and what it receives — are documented in the Compact's operating agreements, which are the Compact's internal documents and not filed with any registry. The farming communities know what they contribute. They know what they receive. What they do not know, and have not asked, is what the other member communities contribute and receive. The Compact's charter prohibits disclosure of individual member terms to other members, on the grounds that disclosure would introduce competitive dynamics into a cooperative framework. Whether this provision protects the farming communities' mutual interests or Brandpunt's negotiating position is a question the farming communities have discussed quietly, among themselves, and reached no common position on.
|
||||
|
||||
---
|
||||
|
||||
## Narrative Hook
|
||||
|
||||
Thirty-five years of spectral adaptation data, held by a consortium that includes both the corridor's oldest communities and a Commission research contract — and nobody on the farming side of the arrangement knows what the Commission is getting from the data that the Compact's charter says belongs to the member communities.
|
||||
|
||||
---
|
||||
|
||||
## Calibration Note
|
||||
|
||||
Wave_5 settlement at hop 17 would be unusual for an agricultural community; it is consistent for a research station funded by established agricultural communities with surplus to invest. The system's prior "unviable for permanent settlement" designations applied to agricultural settlement logic. The Compact model changes the economic basis: food is imported, not grown, and the settlement's output is research and technical services rather than agricultural production. The K5V+M3V binary's oscillating spectrum — previously noted as the system's most distinctive and practically useless feature — is the research infrastructure. The three surveys that recommended against settlement were correct about everything they assessed and assessed the wrong use case.
|
||||
|
||||
---
|
||||
|
||||
@@ -41,7 +73,7 @@ No faction presence. The horizon station is automated. Gate Corporation maintena
|
||||
|
||||
| Orbit | ID | Name | Type | Inhabited | Pop | Mass | Gravity | Year (d) | Day (h) | Atmo | Biome | Hydro | Economy | Settlement | Industrial |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | `GJ3943b` | — | planet | no | — | terrestrial | 0.62g | 130 | 26.8 | thin | cold_arid | ice | — | — | — |
|
||||
| 1 | `GJ3943b` | Skuiling | planet | yes | 1.8K | terrestrial | 0.62g | 130 | 26.8 | thin | cold_arid | ice | research | urban_concentrated | research_export |
|
||||
| 2 | `GJ3943-belt` | — | asteroid_belt | no | — | — | — | — | — | — | — | — | — | — | — |
|
||||
| 3 | `GJ3943c` | — | planet | no | — | terrestrial | 0.48g | 400 | 25.4 | trace | frozen | — | — | — | — |
|
||||
| 4 | `GJ3943d` | — | planet | no | — | terrestrial | 0.38g | 1100 | 29.4 | trace | frozen | ice | — | — | — |
|
||||
@@ -54,8 +86,9 @@ No faction presence. The horizon station is automated. Gate Corporation maintena
|
||||
|
||||
| ID | Name | Type | Orbits | Population | Economy | Governance | Docking | Gate | Districts |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| `GJ3943-oort-S1` | GJ 3943 Horizon | horizon | `GJ3943-oort` | — | transit | — | major | yes | 1 |
|
||||
| `GJ3943-oort-S1` | Brandpunt Horizon | horizon | `GJ3943-oort` | 120 | transit | — | major | yes | 1 |
|
||||
|
||||
## Topology
|
||||
<!-- READ-ONLY — regenerated from star-map.json edges -->
|
||||
- **Hop Distance from Gateway:** 17
|
||||
- **Adjacent Systems:** GJ 421B, GJ 546
|
||||
- **Adjacent Systems:** GJ 421B (Pedra Seca), GJ 546 (Eerste Wacht)
|
||||
|
||||
@@ -42,7 +42,7 @@ A hundred years of informal caretaker logs record something that the official mo
|
||||
|
||||
## Calibration Note
|
||||
|
||||
Unsettled status with through-route topology is the corridor's most straightforward case: the system is on the route because the route needs it, not because anyone wants to be here. The M-type designation in the header matches the database classification. The profile shows "g" — a different catalog notation for the same stellar body. The geologically active primary world provides a consistent explanation for why four hundred years of corridor traffic through this node has produced no settlement attempt.
|
||||
Unsettled status with through-route topology is the corridor's most straightforward case: the system is on the route because the route needs it, not because anyone wants to be here. M4V classification is consistent with the header — a dim mid-range M-dwarf, close enough to the inner rocky world to produce tidal heating, explaining the volcanic activity. The geologically active primary world provides a consistent explanation for why four hundred years of corridor traffic through this node has produced no settlement attempt.
|
||||
|
||||
## Celestial Bodies
|
||||
<!-- READ-ONLY — generated from systems.db bodies/stations tables -->
|
||||
|
||||
@@ -52,7 +52,7 @@ Three hundred years of deliberate distance from the corridor's institutional str
|
||||
| Orbit | ID | Name | Type | Inhabited | Pop | Mass | Gravity | Year (d) | Day (h) | Atmo | Biome | Hydro | Economy | Settlement | Industrial |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | `GJ42b` | — | planet | no | — | terrestrial | 0.64g | 52 | 1248.0 | none | barren | — | — | — | — |
|
||||
| 2 | `GJ42c` | Poperinge | planet | yes | 55K | terrestrial | 0.89g | 200 | 24.8 | breathable | temperate | rivers-lakes | agricultural | distributed-rural | west_reach |
|
||||
| 2 | `GJ42c` | Poperinge | planet | yes | 55K | terrestrial | 0.89g | 200 | 24.8 | standard | temperate | liquid_water | agricultural | dispersed | agricultural_export |
|
||||
| ↳ 2.1 | `GJ42c-1` | — | moon | no | — | dwarf | 0.07g | 21 | 513.6 | none | barren | — | — | — | — |
|
||||
| 3 | `GJ42d` | — | planet | no | — | terrestrial | 0.52g | 510 | 21.2 | thin | arid | — | — | — | — |
|
||||
| 4 | `GJ42-belt` | — | asteroid_belt | no | — | — | — | — | — | — | — | — | — | — | — |
|
||||
|
||||
@@ -22,7 +22,7 @@ Wave_3 settlers arrived approximately 280 years ago — a mixed-heritage cohort
|
||||
|
||||
Two hundred and eighty years of careful water management has produced a community of approximately 800 people living in a settlement built around a system of cisterns, condensation collectors, and aquifer management infrastructure that is, by any engineering assessment, remarkable for its scale and its age. The cistern network predates most of the settlement's above-ground structures — the founders built the water infrastructure first, lived in temporary shelters while they did it, and only began permanent construction once the water supply was secured. This priority ordering is visible in the settlement's architecture: the cisterns are the most solidly built structures, maintained to the highest standard, and treated with the particular reverence that communities reserve for the thing that keeps them alive.
|
||||
|
||||
The through-route topology connects Travessia at GJ 282B to GJ 3943. Pedra Seca sits in the middle of a corridor that gets progressively thinner — settled systems behind, unsettled or barely settled systems ahead. The community's position as the last reliably provisioned stop before the corridor enters its unsettled stretch gives it a specific function: Pedra Seca is where travelers heading deeper refill their water reserves. The community sells water. Not at exploitative prices — the founding charter includes provisions against profiteering from essential supply — but at prices that reflect the reality that every liter sold is a liter that the cistern network must replace, and replacement depends on rainfall that arrives on its own schedule and not the corridor's.
|
||||
The through-route topology connects Travessia at GJ 282B to Brandpunt at GJ 3943. Pedra Seca sits in the middle of a corridor that gets progressively thinner. The community has long served as the last water provisioning point before the deep stretch toward Eerste Wacht — a function that persists even since Brandpunt settled GJ 3943 thirty-five years ago, because Brandpunt imports food and does not sell it, and water provision is Pedra Seca's specific competency. Pedra Seca is where travelers heading deeper refill their water reserves. The community sells water. Not at exploitative prices — the founding charter includes provisions against profiteering from essential supply — but at prices that reflect the reality that every liter sold is a liter that the cistern network must replace, and replacement depends on rainfall that arrives on its own schedule and not the corridor's.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ GJ 432A's K0V star is what makes the position valuable. A K-type star does not f
|
||||
|
||||
The two apertures connect inward to GJ 1005A (Port Desrochers) and GJ 541 (Chandra Deep). Both written systems maintain relationships with the platform: Port Desrochers handles resupply logistics, and Chandra Deep provides approximately 40% of the rotating research staff through its institutional affiliations. The platform is nominally independent — its funding structure is a consortium of university and research institutions scattered across the east reach and inner corridor — but in practice it relies heavily on both adjacent systems for continuity of operation.
|
||||
|
||||
The Punjabi-Canadian research tradition that shaped the platform's founding culture traces to a cohort that established Chandra Deep's original survey institutions, and the institutional lineage is visible in the platform's operational culture: methodical data collection, long archival retention standards, and a deep skepticism toward conclusions drawn from insufficient observational baselines. Dr. Kaur's original catalog methodology — systematic, exhaustive, and annotated with uncertainty flags rather than cleaned for presentability — is still the house style.
|
||||
The Punjabi heritage research tradition that shaped the platform's founding culture traces to a cohort that established Chandra Deep's original survey institutions, and the institutional lineage is visible in the platform's operational culture: methodical data collection, long archival retention standards, and a deep skepticism toward conclusions drawn from insufficient observational baselines. Dr. Kaur's original catalog methodology — systematic, exhaustive, and annotated with uncertainty flags rather than cleaned for presentability — is still the house style.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -20,13 +20,13 @@ Five gates. For a system settled in wave_3, that makes Ribeiro's Star one of the
|
||||
|
||||
What it eventually meant was distribution. Ribeiro's Star became, over the course of wave_3 and into wave_4, the primary transit junction for goods and people moving between Lalande's inner hub and the outer-arc systems that could not efficiently route through the single inward corridor. The flare star requires precautions — the M5.5Ve designation carries a meaningful flare probability that is managed through standard electromagnetic hardening, though not to the extreme levels required at Zenzele — but the gate infrastructure is reliable, and reliability is what transit economics demands.
|
||||
|
||||
The community itself is mid-scale for a five-gate hub. The founding Portuguese heritage is more visible here than at Lalande — the residential districts have the kind of neighborhood depth that accumulates over three hundred years without significant dilution, and the family networks are layered in the way that south_reach communities tend to be when a stable founding population has had generations to intermarry and establish traditions. There is a strong culture of archival record-keeping: births, deaths, property transfers, business relationships, and disputes are maintained in a community registry that goes back to the first decade of settlement. Ribeiro's personal log is the first entry.
|
||||
The community itself is mid-scale for a five-gate hub. Évora's surface holds the older residential fabric — the founding Portuguese heritage is more visible there than at Lalande, the neighborhood depth accumulated over three centuries, the layered family networks of a founding population that stayed and intermarried and kept records. Ribeiro's Star Horizon carries the other 860,000: transit workers and their families, a more varied mix drawn by gate work rather than founding heritage, though many have been aboard long enough that the distinction has faded. A strong culture of archival record-keeping spans both: births, deaths, property transfers, business relationships, and disputes maintained in a community registry that goes back to the first decade of settlement. Ribeiro's personal log is the first entry.
|
||||
|
||||
Governance is a traditional rotating council of household heads — a form common in the southern African and Iberian heritage corridor, adapted to the practical reality of managing significant transit infrastructure. The transport guild holds a formal advisory seat on the council, which distinguishes Ribeiro's Star from neighboring systems where transit operators have less formal standing. The Assembly maintains a presence in keeping with hop-4 expectations: a delegate, a small compliance staff, scheduled patrol visits.
|
||||
|
||||
## Supply Dependency
|
||||
|
||||
Ribeiro's Star imports processed industrial components via Lalande and food surpluses from GJ 905. The system's exports are primarily transit services, technical maintenance, and the specialized flare-weather infrastructure that the founding community developed for the M5.5Ve environment. GJ 695A is an important source of raw materials that enters the inner ring through this system.
|
||||
Ribeiro's Star imports processed industrial components and food staples primarily via Lalande. Isibaya (GJ 905) contributes supplementary agricultural exports — it is a working farm world and a useful close neighbor — but at the scale of a small settlement, not a primary supply source. The system's exports are primarily transit services, technical maintenance, and the specialized flare-weather infrastructure that the founding community developed for the M5.5Ve environment. GJ 695A is an important source of raw materials that enters the inner ring through this system.
|
||||
|
||||
## Faction Notes
|
||||
|
||||
@@ -44,7 +44,7 @@ A crew member on a scheduled freight run through the GJ 695A gate has filed a fo
|
||||
|
||||
## Calibration Note
|
||||
|
||||
M5.5Ve — a mid-range late M-dwarf with active flare designation (Ve). Flare activity is managed through standard hardening protocols. The "e" emission-line designation is reflected in the community's established expertise with flare-weather infrastructure, which positions it adjacent to but distinct from Zenzele's more extreme M6.5 hardening specialization.
|
||||
M5.5Ve — a mid-range late M-dwarf with active flare designation (Ve). Flare activity is managed through standard hardening protocols. The "e" emission-line designation is reflected in the community's established expertise with flare-weather infrastructure, which positions it adjacent to but distinct from Zenzele's more extreme M6.5 hardening specialization. The combined population (~1.18M across surface and horizon) is intentionally lower than heuristics predict for a 5-gate hub at hop 4 with three centuries of history — flare management overhead constrains surface expansion, and transit-hub economics bring people through rather than retaining permanent settlers.
|
||||
|
||||
## Celestial Bodies
|
||||
<!-- READ-ONLY — generated from systems.db bodies/stations tables -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Chandra Deep
|
||||
**GJ 541** | G-type | east_reach
|
||||
**GJ 541** | K-type | east_reach
|
||||
|
||||
---
|
||||
|
||||
@@ -56,7 +56,7 @@ A researcher from the Deep Network, working at a corridor hub seven hops from Ch
|
||||
|
||||
## Calibration Note
|
||||
|
||||
The stub header specifies G-type while the star field shows K2IIIp — a subgiant, brighter and more evolved than a main-sequence G. The header G-type is preserved as canonical. The cross-corridor Indian founder heritage at a dead-end hop 7 position is treated as a deliberate historical anomaly with institutional memory, not a mapping error. Wave_2 at dead_end produces a community that is old, self-contained, and has had centuries to develop its own institutional character without external disruption.
|
||||
K2IIIp is the correct classification — a subgiant, brighter and more evolved than a main-sequence G; the K-type header reflects this accurately. The cross-corridor Indian founder heritage at a dead-end hop 7 position is treated as a deliberate historical anomaly with institutional memory, not a mapping error. Wave_2 at dead_end produces a community that is old, self-contained, and has had centuries to develop its own institutional character without external disruption.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ Five centuries of continuous habitation has produced a community of approximatel
|
||||
|
||||
The Afrikaans heritage is not attenuated. At five hundred years of isolation, the cultural persistence is remarkable — not because the community has deliberately preserved it, but because there has been nothing to dilute it. The language has drifted into a local variant that an inner-Reach Afrikaans speaker would recognize but find increasingly difficult to follow after the first few exchanges. The architectural forms, the naming conventions, the agricultural practices all carry the stamp of a founding culture that has evolved in place for half a millennium without significant external influence. Eerste Wacht is not a museum. It is what a culture looks like when it has had five hundred years to become itself without anyone watching.
|
||||
|
||||
The through-route connects GJ 3943 to GJ 111. Eerste Wacht's position between the unsettled gap behind it and the dead-end community at GJ 111 ahead makes it a node in a corridor that barely exists — two settled systems connected through unsettled space, sustained by their own stubbornness and by whatever brought the founding cohort this far in the first place.
|
||||
The through-route connects Brandpunt at GJ 3943 to GJ 111. Eerste Wacht's position between the research settlement behind it and the daughter colony at GJ 111 ahead makes it the oldest node in a corridor that has, only in the last generation, accumulated enough settled communities to call itself a corridor properly. Thirty-five years ago, when Brandpunt's founders arrived at GJ 3943 to establish the Agricultural Spectrum Research Compact, Eerste Wacht's elder council received the news with the particular courtesy they extend to outsiders: cordial acknowledgment, minimal comment. Compact representatives visit to deliver the annual cultivar reports that Eerste Wacht's partnership protocols and agricultural records feed. The elder council accepts the reports and does not discuss what it thinks of the organization that produces them.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ A researcher studying wave_2 founding claims identifies a discrepancy between We
|
||||
|
||||
## Calibration Note
|
||||
|
||||
The header specifies K-type while the star field shows M2 — the header identifier is preserved as canonical per wiki convention. Wave_2 spur_end with two apertures is consistent with an old, well-established system that grew into its second connection rather than founding with it. The dual-world settlement pattern is plausible at wave_2 timescale — four hundred years is sufficient to develop two inhabited worlds from a double-habitable system. The Assembly's reduced presence (six personnel) at a wave_2 system reflects the standard institutional thinning at hop 6.
|
||||
M2 classification is correct — the header matches the star field. Wave_2 spur_end with two apertures is consistent with an old, well-established system that grew into its second connection rather than founding with it. The dual-world settlement pattern is plausible at wave_2 timescale — four hundred years is sufficient to develop two inhabited worlds from a double-habitable system. The Assembly's reduced presence (six personnel) at a wave_2 system reflects the standard institutional thinning at hop 6.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Star** | K · 0.0 ly |
|
||||
| **Star** | K3V · 0.0 ly |
|
||||
| **Gates** | 1 aperture · dead_end |
|
||||
| **Settlement** | wave_3 |
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Star** | K · 0.0 ly |
|
||||
| **Star** | K4V · 0.0 ly |
|
||||
| **Gates** | 2 aperture · through_route |
|
||||
| **Settlement** | wave_2 |
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# The Narrows
|
||||
**GJ 875** | G-type | deep_frontier
|
||||
**GJ 875** | K-type | deep_frontier
|
||||
|
||||
---
|
||||
|
||||
@@ -50,7 +50,7 @@ A Travessia-based communications technician, running signal calibration through
|
||||
|
||||
## Calibration Note
|
||||
|
||||
The header classifies this as "G-type" while the star data shows K5. The header classification reflects the database's broader tier grouping; the K5 spectral class in the System Profile is the accurate designation. The unsettled status at hop 14 on a through-route is consistent with the deep frontier's pattern of uninhabited transit corridors between settled communities.
|
||||
The unsettled status at hop 14 on a through-route is consistent with the deep frontier's pattern of uninhabited transit corridors between settled communities.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ A buyer from an inner-ring processing cooperative, attending her first Umkhosi w
|
||||
|
||||
## Calibration Note
|
||||
|
||||
dM6e is a flare-active M6 dwarf, among the most energetically variable stellar types in the settled Reach. The "e" designation for emission activity drives the flare management detail throughout. Wave_4 settlement at hop 5 reflects the outer south_reach's later agricultural expansion — wave_4 settlers extended the livestock corridor beyond the grain systems established in wave_3, selecting worlds with productive potential that could support ranching operations. The astronomical catalog designation Ross 248 is the proper name for GJ 905; the community name Isibaya takes precedence in local use.
|
||||
dM6e is a flare-active M6 dwarf, among the most energetically variable stellar types in the settled Reach. The "e" designation for emission activity drives the flare management detail throughout. Wave_4 settlement at hop 5 reflects the outer south_reach's later agricultural expansion — wave_4 settlers extended the livestock corridor beyond the grain systems established in wave_3, selecting worlds with productive potential that could support ranching operations. The astronomical catalog designation Ross 248 is the proper name for GJ 905; the community name Isibaya takes precedence in local use. The ~47K total population is intentionally modest — specialized ranching is productive but low-density by design, and the M6 flare environment reinforces the infrastructure constraints that limit sustainable settlement scale.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ Sorted by sector, then hop distance from Gateway.
|
||||
| [GJ 325A](GJ-325A/index.md) | **Purnima** | M-type | 7 | wave_3 | dead_end | 1 |
|
||||
| [GJ 423A](GJ-423A/index.md) | **Marunong** | M-type | 7 | wave_3 | through_route | 2 |
|
||||
| [GJ 449](GJ-449/index.md) | **Murakami's Lantern** | binary | 7 | wave_5 | dead_end | 1 |
|
||||
| [GJ 541](GJ-541/index.md) | **Chandra Deep** | G-type | 7 | wave_2 | dead_end | 1 |
|
||||
| [GJ 541](GJ-541/index.md) | **Chandra Deep** | K-type | 7 | wave_2 | dead_end | 1 |
|
||||
| [GJ 635B](GJ-635B/index.md) | **Clearwater Station** | G-type | 7 | wave_2 | spur_end | 2 |
|
||||
| [GJ 660A](GJ-660A/index.md) | **Seongho** | M-type | 7 | wave_3 | junction | 3 |
|
||||
| [GJ 79](GJ-79/index.md) | **Oshima** | M-type | 7 | wave_3 | spur_end | 2 |
|
||||
@@ -168,7 +168,7 @@ Sorted by sector, then hop distance from Gateway.
|
||||
| [GJ 411](GJ-411/index.md) | **Lalande** | M-type | 3 | wave_4 | hub | 5 |
|
||||
| [GJ 1002](GJ-1002/index.md) | **Caparica** | G-type | 4 | wave_3 | spur_end | 2 |
|
||||
| [GJ 1061](GJ-1061/index.md) | **Crux Station** | M-type | 4 | unsettled | junction | 4 |
|
||||
| [GJ 1111](GJ-1111/index.md) | **Zenzele** | unusual | 4 | wave_3 | hub | 5 |
|
||||
| [GJ 1111](GJ-1111/index.md) | **Zenzele** | M-type | 4 | wave_3 | hub | 5 |
|
||||
| [GJ 1245B](GJ-1245B/index.md) | **Koeberg** | K-type | 4 | wave_4 | dead_end | 1 |
|
||||
| [GJ 234A](GJ-234A/index.md) | **Carvalhais** | M-type | 4 | wave_3 | junction | 3 |
|
||||
| [GJ 33](GJ-33/index.md) | **96 Piscium** | K-type | 4 | unsettled | loop_member | 2 |
|
||||
@@ -228,7 +228,7 @@ Sorted by sector, then hop distance from Gateway.
|
||||
| [GJ 664](GJ-664/index.md) | **Caldwell Point** | K-type | 3 | wave_3 | junction | 3 |
|
||||
| [GJ 729](GJ-729/index.md) | **Ross 154** | K-type | 3 | wave_5 | junction | 3 |
|
||||
| [GJ 1](GJ-1/index.md) | **Grünfeld** | M-type | 4 | wave_4 | loop_member | 2 |
|
||||
| [GJ 19](GJ-19/index.md) | **Solheim** | K-type | 4 | wave_3 | loop_member | 2 |
|
||||
| [GJ 19](GJ-19/index.md) | **Solheim** | G-type | 4 | wave_3 | loop_member | 2 |
|
||||
| [GJ 191](GJ-191/index.md) | **Kapteyn's Star** | unusual | 4 | wave_4 | junction | 3 |
|
||||
| [GJ 628](GJ-628/index.md) | **Brennan's Drift** | G-type | 4 | wave_2 | loop_member | 2 |
|
||||
| [GJ 66B](GJ-66B/index.md) | **Voss** | K-type | 4 | wave_2 | dead_end | 1 |
|
||||
@@ -266,7 +266,7 @@ Sorted by sector, then hop distance from Gateway.
|
||||
| [GJ 542](GJ-542/index.md) | **Kopparhytta** | M-type | 8 | wave_4 | loop_member | 2 |
|
||||
| [GJ 624](GJ-624/index.md) | **Halvøy** | G-type | 8 | wave_2 | dead_end | 1 |
|
||||
| [GJ 798](GJ-798/index.md) | **Brückenau** | M-type | 8 | wave_4 | through_route | 2 |
|
||||
| [GJ 601A](GJ-601A/index.md) | **Ostmark** | K-type | 9 | wave_1 | through_route | 2 |
|
||||
| [GJ 601A](GJ-601A/index.md) | **Ostmark** | F-type | 9 | wave_1 | through_route | 2 |
|
||||
| [GJ 775](GJ-775/index.md) | **Randsholm** | M-type | 9 | wave_5 | through_route | 2 |
|
||||
| [GJ 3586](GJ-3586/index.md) | **Eisfeld** | M-type | 10 | wave_2 | through_route | 2 |
|
||||
| [GJ 395](GJ-395/index.md) | **Confluent** | M-type | 10 | wave_2 | through_route | 2 |
|
||||
@@ -274,7 +274,7 @@ Sorted by sector, then hop distance from Gateway.
|
||||
| [GJ 771A](GJ-771A/index.md) | **Alshain** | binary | 11 | wave_3 | through_route | 2 |
|
||||
| [GJ 4056](GJ-4056/index.md) | — | M-type | 12 | unsettled | through_route | 2 |
|
||||
| [GJ 127B](GJ-127B/index.md) | **Echternach** | M-type | 13 | wave_2 | through_route | 2 |
|
||||
| [GJ 42](GJ-42/index.md) | **Langemark** | M-type | 14 | wave_3 | through_route | 2 |
|
||||
| [GJ 42](GJ-42/index.md) | **Langemark** | K-type | 14 | wave_3 | through_route | 2 |
|
||||
| [GJ 282A](GJ-282A/index.md) | **Bout du Chemin** | G-type | 15 | wave_2 | through_route | 2 |
|
||||
| [GJ 807](GJ-807/index.md) | **Grenzstein** | G-type | 16 | wave_5 | through_route | 2 |
|
||||
| [GJ 529](GJ-529/index.md) | **Yttermark** | binary | 17 | wave_3 | dead_end | 1 |
|
||||
@@ -307,7 +307,7 @@ Sorted by sector, then hop distance from Gateway.
|
||||
| [GJ 425A](GJ-425A/index.md) | — | K-type | 9 | unsettled | through_route | 2 |
|
||||
| [GJ 556](GJ-556/index.md) | — | M-type | 9 | wave_4 | dead_end | 1 |
|
||||
| [GJ 675](GJ-675/index.md) | — | K-type | 9 | wave_5 | through_route | 2 |
|
||||
| [GJ 707](GJ-707/index.md) | — | binary | 9 | wave_3 | through_route | 2 |
|
||||
| [GJ 707](GJ-707/index.md) | **Dois Sóis** | binary | 9 | wave_3 | through_route | 2 |
|
||||
| [GJ 722](GJ-722/index.md) | — | M-type | 9 | wave_4 | through_route | 2 |
|
||||
| [GJ 1267](GJ-1267/index.md) | — | M-type | 10 | wave_3 | through_route | 2 |
|
||||
| [GJ 146](GJ-146/index.md) | — | M-type | 10 | wave_3 | through_route | 2 |
|
||||
@@ -331,12 +331,12 @@ Sorted by sector, then hop distance from Gateway.
|
||||
| [GJ 903](GJ-903/index.md) | **Errai** | F-type | 13 | wave_4 | through_route | 2 |
|
||||
| [GJ 481](GJ-481/index.md) | — | M-type | 14 | wave_4 | spur_end | 2 |
|
||||
| [GJ 684A](GJ-684A/index.md) | — | F-type | 14 | wave_4 | spur_end | 2 |
|
||||
| [GJ 875](GJ-875/index.md) | — | G-type | 14 | unsettled | through_route | 2 |
|
||||
| [GJ 875](GJ-875/index.md) | — | K-type | 14 | unsettled | through_route | 2 |
|
||||
| [GJ 282B](GJ-282B/index.md) | — | M-type | 15 | wave_4 | through_route | 2 |
|
||||
| [GJ 532](GJ-532/index.md) | — | M-type | 15 | wave_3 | through_route | 2 |
|
||||
| [GJ 421B](GJ-421B/index.md) | — | M-type | 16 | wave_3 | through_route | 2 |
|
||||
| [GJ 904](GJ-904/index.md) | — | M-type | 16 | wave_4 | through_route | 2 |
|
||||
| [GJ 3943](GJ-3943/index.md) | — | G-type | 17 | unsettled | through_route | 2 |
|
||||
| [GJ 3943](GJ-3943/index.md) | **Brandpunt** | binary | 17 | wave_5 | through_route | 2 |
|
||||
| [GJ 726](GJ-726/index.md) | — | K-type | 17 | unsettled | through_route | 2 |
|
||||
| [GJ 546](GJ-546/index.md) | — | M-type | 18 | wave_2 | through_route | 2 |
|
||||
| [GJ 672](GJ-672/index.md) | — | M-type | 18 | wave_5 | through_route | 2 |
|
||||
|
||||
@@ -119,7 +119,7 @@ Three of the Guide's field researchers are currently listed as "on extended assi
|
||||
|
||||
| System | Rating |
|
||||
|--------|--------|
|
||||
| [GJ 3943](GJ-3943/gttr.md) (GJ 3943) | *Two shades of insufficient light. Emergency habitation capable.* |
|
||||
| [Brandpunt](GJ-3943/gttr.md) (GJ 3943) | *Thirty-five years into proving the surveys assessed the wrong question. The oscillating light is the point.* |
|
||||
| [GJ 726](GJ-726/gttr.md) (GJ 726) | *A transit corridor where the traffic is slowly, quietly increasing. Nobody has asked why.* |
|
||||
| [Eerste Wacht](GJ-546/gttr.md) (GJ 546) | *Five hundred years, no explanation, still watching.* |
|
||||
| [Nova Estrada](GJ-672/gttr.md) (GJ 672) | *Thirty years old, still becoming. Named for a road it is still building.* |
|
||||
|
||||
Reference in New Issue
Block a user