Compare commits
@@ -23,8 +23,8 @@ Two generators write to `systems.db`:
|
||||
|
||||
| Generator | Command | Source files (all contribute to the meta stamp SHA) |
|
||||
|-----------|---------|--------------|
|
||||
| `import_economics` | `python3 tooling/economy-db/import_economics.py` | `tooling/economy-db/import_economics.py` + the Rust brand binary sources it invokes: `server/src/bin/generate_brands/main.rs`, `server/src/bin/generate_brands/names.rs`, `tooling/generate-brands` |
|
||||
| `generate_atlas` | `python3 tooling/planet-gen/generate_atlas.py --seed 42` | `tooling/planet-gen/generate_atlas.py` |
|
||||
| `import_economics` | `python3 tooling/economy-db/import_economics.py` | `tooling/economy-db/import_economics.py` + the Rust brand binary sources it invokes: `server/src/bin/generate_brands/main.rs`, `server/src/bin/generate_brands/names.rs`, `tooling/generate-brands` + shared `tooling/schema_version.py` |
|
||||
| `generate_atlas` | `python3 tooling/planet-gen/generate_atlas.py --seed 42` | `tooling/planet-gen/generate_atlas.py` + shared `tooling/schema_version.py` |
|
||||
|
||||
`import_economics` shells out to the Rust `generate_brands` binary as its first
|
||||
step to refresh `wiki/economics/corporations/generated_brands.toml`, then reads
|
||||
@@ -44,12 +44,21 @@ After every successful non-dry-run, each generator writes a row to the `meta` ta
|
||||
```sql
|
||||
CREATE TABLE meta (
|
||||
generator_name TEXT PRIMARY KEY, -- 'import_economics' | 'generate_atlas'
|
||||
schema_version TEXT NOT NULL, -- SHA-1 of server/data/systems-schema.sql at generation time
|
||||
schema_version TEXT NOT NULL, -- monotonic semver string (e.g. "1.0.0") — see #888
|
||||
schema_sha TEXT, -- SHA-1 of server/data/systems-schema.sql (tamper detection)
|
||||
generator_sha TEXT NOT NULL, -- SHA-1 of the generator source file(s)
|
||||
generated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
```
|
||||
|
||||
`schema_version` is a **monotonic semver string** (e.g. `"1.0.0"`), not a hash.
|
||||
It is defined as the `SCHEMA_VERSION` constant in `tooling/schema_version.py`
|
||||
and must be bumped manually whenever the schema changes in a backwards-incompatible way.
|
||||
Unlike a SHA-1 hash, semver strings are orderable — this enables savegame migration
|
||||
lineage in Phase 5+: a save file can record which schema version it derives from and
|
||||
determine exactly which migrations to apply (#888). The old SHA-1 is preserved in
|
||||
`schema_sha` for tamper detection alongside the semver.
|
||||
|
||||
The `generator_sha` is the SHA-1 of the concatenated bytes of the generator's
|
||||
source files (sorted by path, so order is deterministic). If any source file
|
||||
changes and `make regen-db` is not re-run, the stamped SHA will differ from the
|
||||
@@ -166,8 +175,14 @@ no hand-edit path that survives regen.
|
||||
|
||||
---
|
||||
|
||||
## Future: savegame migration lineage
|
||||
## Savegame migration lineage (Phase 5+)
|
||||
|
||||
The `meta.schema_version` field records the schema SHA at generation time. When the
|
||||
savegame system is built (Phase 5+), a save file can record which systems.db snapshot
|
||||
it derives from, enabling forward migration without branching the DB file itself.
|
||||
`meta.schema_version` now stores a monotonic semver string (#888). When the savegame
|
||||
system is built (Phase 5+), a save file records its `schema_version` string; the
|
||||
loader can determine which migrations to apply by comparing that version to the
|
||||
current one. `meta.schema_sha` retains the old SHA-1 for tamper detection.
|
||||
|
||||
**When to bump `SCHEMA_VERSION`:** edit the `SCHEMA_VERSION = "1.0.0"` constant in
|
||||
`tooling/schema_version.py` whenever a schema change is backwards-incompatible
|
||||
(column removed, type changed, FK constraint added, table dropped). Additive changes
|
||||
(new nullable columns, new tables, new indexes) do not require a bump.
|
||||
|
||||
@@ -58,6 +58,9 @@
|
||||
"Bash(ruff check)",
|
||||
"Bash(tests/run-*)",
|
||||
|
||||
"Bash(mkdir -p docs/sprints/*)",
|
||||
"Write(docs/sprints/*)",
|
||||
|
||||
"Bash(chmod *)",
|
||||
"Bash(ls *)",
|
||||
"Bash(find *)",
|
||||
|
||||
@@ -229,6 +229,11 @@ exactly the silent-stale-DB class of bug this skill exists to prevent.
|
||||
git diff --name-only origin/main...HEAD -- \
|
||||
tooling/economy-db/import_economics.py \
|
||||
tooling/planet-gen/generate_atlas.py \
|
||||
tooling/planet-gen/gemma_naming.py \
|
||||
tooling/planet-gen/naming_core.py \
|
||||
tooling/planet-gen/import_city_names.py \
|
||||
tooling/planet-gen/import_heightmaps.py \
|
||||
tooling/planet-gen/import_province_boundaries.py \
|
||||
server/src/bin/generate_brands/main.rs \
|
||||
server/src/bin/generate_brands/names.rs \
|
||||
tooling/generate-brands \
|
||||
|
||||
@@ -268,16 +268,27 @@ After presenting results to the user, post the review as a PR comment.
|
||||
Note: `tea pr reject` does not work on your own PRs. Use `tea comment` instead.
|
||||
|
||||
Post using the `tea-comment` wrapper (handles temp files and cleanup).
|
||||
Write the review to a temp file first, then pass via `@filepath` syntax:
|
||||
|
||||
```bash
|
||||
# Write review to file, then post — avoids $() in the command which breaks permissions
|
||||
cat > /tmp/pr-review-<NUMBER>.md << 'EOF'
|
||||
...review content...
|
||||
EOF
|
||||
**Two rules:**
|
||||
1. **Use the Write tool** for the file content (no permission prompt, no
|
||||
heredoc parsing issues with markdown tables/pipes). Then call
|
||||
`tooling/tea-comment` in a separate short Bash call.
|
||||
2. **Run `tooling/tea-comment` in the FOREGROUND, never with
|
||||
`run_in_background`.** The background execution path silently fails —
|
||||
the comment never reaches Gitea and the team never sees the review.
|
||||
Sprint 38 lost an entire review round this way. Always foreground.
|
||||
|
||||
```
|
||||
# Step 1: Use the Write tool to create the file
|
||||
Write({ file_path: "/tmp/pr-review-<NUMBER>.md", content: "..." })
|
||||
|
||||
# Step 2: Post via short Bash call (foreground)
|
||||
tooling/tea-comment <PR_NUMBER> @/tmp/pr-review-<NUMBER>.md
|
||||
```
|
||||
|
||||
Do NOT use `cat << 'EOF'` heredocs for review content — they create
|
||||
massive permission prompts that are slow to render and often get stuck.
|
||||
|
||||
## 7. Merging approved PRs
|
||||
|
||||
`tea pr merge` fails (405) when branches have conflicts with main. Merge
|
||||
|
||||
@@ -6,6 +6,36 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.2.0] — 2026-05-03
|
||||
|
||||
*Process milestone: final sprint-based release. Development moves to kanban + milestones (Q-096).*
|
||||
|
||||
## [v0.1.38] — 2026-05-03
|
||||
|
||||
### Added
|
||||
- **Generation cascade D-records** (D-194–D-218) — 25 decisions formalizing the full pipeline from planetary heightmap to walkable tile: WorldTier taxonomy, settlement classification, city generation context, drainage routing, attractor matching, district mix, block irregularity, tile conditions
|
||||
- **Atlas data pipeline** (#901–#911) — new `atlas_body_heightmaps`, `atlas_city_names`, `atlas_feature_names`, `atlas_province_boundaries` tables; `body_radius_km` column; three new importers (heightmaps, city names, province boundaries via D8 watershed); `economic_role` normalized to 7 canonical values
|
||||
- **Phase 1 generation pipeline** (#916–#924) — 10-module `server/src/atlas/` package: heightmap BLOB loader, BodyWorldState LRU cache, D8 drainage routing, background generation queue with Rayon pool, five-phase attractor matching, three-component district mix, block irregularity, tile condition thresholds
|
||||
- **District skeleton generator** (#899) — `generate_skeleton()` wires the full atlas pipeline to produce filled `DistrictSkeleton` instances from city markers + planet data. Phase 1 scope: SettingType/ComplexityTier derivation, layout mode assignment, 4×4 block grid with zoning, multi-block reservations
|
||||
- **SystemNameIndex** (#926) — Aho-Corasick text scanner over body/station/system names for background pre-generation queue integration (D-206)
|
||||
- **Free camera viewer** (#898) — F4 toggles decoupled camera with WASD pan + scroll zoom; input suppressed in free-camera mode; implant UI remains accessible
|
||||
- **Fog behavioral tests** (#879) — 11 new tests covering EXP_EXPLORED persistence, grow-only bounds, texture-resize copy, BoundaryWall handling
|
||||
- **Province boundary rendering** (#927) — drainage basin polylines exported to markers.json and rendered on the planetary map under the political_zones overlay
|
||||
- **Stamp expansion** (#892) — `gemma_naming.py` and `naming_core.py` added to `check-systems-db-stamp` source tracking and `/pr-push` watch list
|
||||
- **`make decisions-orphan-tickets`** (#887) — new CLI subcommand (`tooling/db/decision orphan-tickets`) that scans tickets with a `decision_ref` not matching any decision in the DB, surfacing silently orphaned tickets from typo'd or renumbered D-IDs
|
||||
|
||||
### Changed
|
||||
- **`meta.schema_version` switched to monotonic semver** (#888) — replaces SHA-1 hash with an orderable semver string (`"1.0.0"`); old SHA preserved in new `schema_sha` column for tamper detection; `check-systems-db-stamp` now rejects legacy SHA-hex values
|
||||
- **Archetype strip** (#882) — removed `character_archetype`, `lattice_profile`, lattice color palettes, and all related test assertions from client
|
||||
- **Corporation wiki review** (#884) — 19 corporation pages corrected: 6 hop-count fixes, topology label corrections, Rush Mining and Scapa Flow narratives rewritten for star-map accuracy, tag reordering, stub-to-prose rewrites
|
||||
|
||||
### Fixed
|
||||
- **Bevy baseline test panics** (#885) — `SnapshotBuffer` Option-wrapped in economy.rs, `TickPhase::configure` added to SimulationPlugin, stale golden file regenerated. All 6 previously-failing tests pass
|
||||
- **Suffix monotony auto-fix** (#886) — `gemma_naming.py` re-queries affected bodies when >40% suffix clustering detected; cultural-history context threaded into naming prompts
|
||||
- **Client parse-order violations** — sim_bridge, protocol, input_mapper, audio_manager, main_menu all fixed to follow autoload pattern (untyped fields + runtime `load()`)
|
||||
- **Confrontation monologue signal** (#867) — tween validity guard ensures signal fires in headless test mode
|
||||
- **Pre-existing test failures** (#871) — 7 tests fixed inline (examine_display dismiss timing, fog position fragility, rendering snapshot assertions, time display format)
|
||||
|
||||
## [v0.1.37] — 2026-04-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
|
||||
|
||||
.PHONY: help setup build check-protocol client server game stop test lint lint-python setup-venv ci ci-client ci-server clean \
|
||||
decisions-sync decisions-coverage decisions-active decisions-orphan \
|
||||
decisions-sync decisions-coverage decisions-active decisions-orphan decisions-orphan-tickets \
|
||||
db-backup db-install validate-content check-fact-ids setup-hooks install-hooks \
|
||||
audit deny atlas-verify economy-db atlas-generate regen-db check-systems-db \
|
||||
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
|
||||
@@ -48,7 +48,8 @@ help:
|
||||
@echo " make decisions-sync Sync decisions/*.md into SQLite"
|
||||
@echo " make decisions-coverage Each decision with its implementing ticket(s)"
|
||||
@echo " make decisions-active List active decisions"
|
||||
@echo " make decisions-orphan Decisions without implementing tickets"
|
||||
@echo " make decisions-orphan Decisions without implementing tickets"
|
||||
@echo " make decisions-orphan-tickets Tickets with invalid or missing decision_ref"
|
||||
@echo " make audit Run cargo audit (security advisory check)"
|
||||
@echo " make deny Run cargo deny check (license/ban policy)"
|
||||
@echo " make validate-content Validate content YAML against schemas"
|
||||
@@ -404,6 +405,9 @@ decisions-active:
|
||||
decisions-orphan:
|
||||
@tooling/db/sqlite-query "SELECT id, title FROM decisions WHERE type='confirmed' AND status='active' AND id NOT IN (SELECT DISTINCT decision_ref FROM tickets WHERE decision_ref IS NOT NULL)"
|
||||
|
||||
decisions-orphan-tickets:
|
||||
@tooling/db/decision orphan-tickets
|
||||
|
||||
# --- Content Validation ---
|
||||
|
||||
validate-content:
|
||||
|
||||
@@ -124,6 +124,11 @@ stance_down={
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":88,"key_label":0,"unicode":120,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
free_camera={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194335,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
bug_report={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194343,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
|
||||
@@ -231,7 +231,8 @@ func play_sound_event(event_type: String, world_tile_pos: Vector2) -> void:
|
||||
var asset_key: String = SOUND_EVENT_ASSETS.get(event_type, "")
|
||||
if asset_key.is_empty():
|
||||
return
|
||||
play_at(asset_key, world_tile_pos * Constants.TILE_SIZE)
|
||||
var C := load("res://scripts/constants.gd")
|
||||
play_at(asset_key, world_tile_pos * C.TILE_SIZE)
|
||||
|
||||
|
||||
# --- Playback: spatial (D-018 close-range) ---
|
||||
|
||||
@@ -41,11 +41,6 @@ var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs
|
||||
# v5 fields (#414)
|
||||
var current_monologue: Variant = null # {id, text, duration_seconds, priority, is_urgent} or null
|
||||
|
||||
# #122 (D-032): Character lattice profile — selects monologue text colour palette.
|
||||
# "lattice_augmented" = detective, "lattice_baseline" = smuggler.
|
||||
# Server sends this field as part of the player's capability snapshot.
|
||||
var lattice_profile: String = "lattice_baseline"
|
||||
|
||||
# v6 fields (#449, D-053, D-065)
|
||||
var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch
|
||||
var player_inventory: Array = [] # [{item_id, name, slot}]
|
||||
@@ -94,10 +89,8 @@ var debug_response: Variant = null
|
||||
# Format: user://saves/<game-id>/<filename>.sav or "" if no pending load.
|
||||
var pending_load_path: String = ""
|
||||
|
||||
# #588: Character archetype chosen at character select screen.
|
||||
# "detective" or "smuggler". Set before game scene loads; sent in StartupMessage.
|
||||
# Default: "detective" — fallback for legacy saves without character.txt.
|
||||
var character_archetype: String = "detective"
|
||||
# #898: Free camera mode — camera decoupled from player, WASD pans camera directly.
|
||||
var free_camera_mode: bool = false
|
||||
|
||||
# #705: Character visual descriptor — set by character_creation.gd on confirmation.
|
||||
# Passed to EntityRenderer for the player entity's CharacterVisual on game start.
|
||||
|
||||
@@ -68,7 +68,7 @@ func _process(_delta: float) -> void:
|
||||
# D-054: Update facing angle from mouse position every frame
|
||||
_update_facing_from_mouse()
|
||||
|
||||
if GameState.dialogue_active:
|
||||
if GameState.dialogue_active or GameState.free_camera_mode:
|
||||
return
|
||||
|
||||
# D-054: Send facing octant to server when it changes (even without movement)
|
||||
@@ -117,6 +117,8 @@ func _process(_delta: float) -> void:
|
||||
|
||||
# Discrete actions: fire once on key press (not held).
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if GameState.dialogue_active or GameState.free_camera_mode:
|
||||
return
|
||||
var action: Action = -1
|
||||
|
||||
if event.is_action_pressed("interact"):
|
||||
@@ -182,10 +184,11 @@ func _update_facing_from_mouse() -> void:
|
||||
if vp == null:
|
||||
return
|
||||
var canvas_xf := vp.get_canvas_transform()
|
||||
var player_world_px := GameState.player_position * Constants.TILE_SIZE
|
||||
var player_screen := canvas_xf * player_world_px
|
||||
var mouse_screen := vp.get_mouse_position()
|
||||
var delta := mouse_screen - player_screen
|
||||
var C := load("res://scripts/constants.gd")
|
||||
var player_world_px: Vector2 = GameState.player_position * C.TILE_SIZE
|
||||
var player_screen: Vector2 = canvas_xf * player_world_px
|
||||
var mouse_screen: Vector2 = vp.get_mouse_position()
|
||||
var delta: Vector2 = mouse_screen - player_screen
|
||||
# Only update if mouse is meaningfully distant from player (avoid jitter at center)
|
||||
if delta.length_squared() > 4.0:
|
||||
facing_angle = delta.angle()
|
||||
|
||||
@@ -55,12 +55,11 @@ func new_game() -> String:
|
||||
|
||||
|
||||
## Resume an existing game session by setting the active game-id.
|
||||
## Restores world_seed and character_archetype from the save directory.
|
||||
## Restores world_seed from the save directory.
|
||||
func resume_game(game_id: String) -> void:
|
||||
GameState.current_game_id = game_id
|
||||
var save_path := SAVES_DIR + game_id + "/"
|
||||
GameState.world_seed = _read_seed_file(save_path)
|
||||
GameState.character_archetype = _read_archetype_file(save_path)
|
||||
|
||||
|
||||
## List all game directories under user://saves/ sorted by last-modified (most recent first).
|
||||
@@ -171,29 +170,6 @@ func _read_seed_file(save_path: String) -> int:
|
||||
return file.get_64() & 0x7FFFFFFFFFFFFFFF
|
||||
|
||||
|
||||
## Write character_archetype to save directory. Called after new_game() creates the dir.
|
||||
func save_character_archetype(game_id: String, archetype: String) -> void:
|
||||
var save_path := SAVES_DIR + game_id + "/"
|
||||
var file := FileAccess.open(save_path + "character.txt", FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error(
|
||||
(
|
||||
"SessionManager: failed to write character.txt: %s"
|
||||
% error_string(FileAccess.get_open_error())
|
||||
)
|
||||
)
|
||||
return
|
||||
file.store_string(archetype)
|
||||
|
||||
|
||||
## Read character_archetype from save directory. Returns "detective" if missing (legacy saves).
|
||||
func _read_archetype_file(save_path: String) -> String:
|
||||
var file := FileAccess.open(save_path + "character.txt", FileAccess.READ)
|
||||
if file == null:
|
||||
return "detective"
|
||||
return file.get_as_text().strip_edges()
|
||||
|
||||
|
||||
func _find_newest_save(dir_path: String) -> String:
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
|
||||
@@ -27,8 +27,8 @@ var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by
|
||||
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
|
||||
|
||||
# Transport layer (non-test mode)
|
||||
var _bridge: LocalBridge = null
|
||||
var _server: ServerProcess = null
|
||||
var _bridge = null # LocalBridge
|
||||
var _server = null # ServerProcess
|
||||
var _connect_retries: int = 0
|
||||
var _retry_timer: float = 0.0
|
||||
var _handshake_start_usec: int = 0
|
||||
@@ -126,14 +126,15 @@ func connect_to_sim() -> void:
|
||||
|
||||
# Spawn server subprocess
|
||||
if not server_path.is_empty():
|
||||
_server = ServerProcess.new()
|
||||
var SP := load("res://scripts/protocol/server_process.gd")
|
||||
_server = SP.new()
|
||||
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876").
|
||||
# D-085 (#258): pass --game-id <id> so server logs use the same session identifier.
|
||||
var args := ["127.0.0.1:" + str(server_port)]
|
||||
var game_id: String = GameState.current_game_id
|
||||
if not game_id.is_empty():
|
||||
args.append_array(["--game-id", game_id])
|
||||
var pid := _server.start(server_path, args)
|
||||
var pid: int = _server.start(server_path, args)
|
||||
if pid <= 0:
|
||||
push_error("SimBridge: failed to start server")
|
||||
_set_state(ConnectionState.ERROR)
|
||||
@@ -159,8 +160,9 @@ func disconnect_from_sim() -> void:
|
||||
|
||||
# Attempt TCP connection. Called from _process() during CONNECTING state.
|
||||
func _try_connect() -> void:
|
||||
_bridge = LocalBridge.new()
|
||||
var err := _bridge.connect_to_server("127.0.0.1", server_port)
|
||||
var LB := load("res://scripts/protocol/local_bridge.gd")
|
||||
_bridge = LB.new()
|
||||
var err: int = _bridge.connect_to_server("127.0.0.1", server_port)
|
||||
if err != OK:
|
||||
push_warning(
|
||||
(
|
||||
@@ -220,7 +222,7 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
_bridge.poll()
|
||||
|
||||
# Check connection dropped during handshake
|
||||
var bridge_status := _bridge.get_status()
|
||||
var bridge_status: int = _bridge.get_status()
|
||||
if (
|
||||
bridge_status == StreamPeerTCP.STATUS_ERROR
|
||||
or bridge_status == StreamPeerTCP.STATUS_NONE
|
||||
@@ -242,13 +244,14 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
return
|
||||
|
||||
# Try to read first message
|
||||
var msg := _bridge.poll_message()
|
||||
var msg: PackedByteArray = _bridge.poll_message()
|
||||
if msg.is_empty():
|
||||
return # Not ready yet, continue polling
|
||||
|
||||
# Decode HandshakeMessage — D-192 (#875): protocol_version field dropped.
|
||||
# Server sends {} or a minimal dict; only structural validity is required.
|
||||
var decoded: Variant = Messagepack.decode(msg)
|
||||
var MP = load("res://addons/messagepack/messagepack.gd")
|
||||
var decoded: Variant = MP.decode(msg)
|
||||
if decoded.status != null or not (decoded.value is Dictionary):
|
||||
var reason := "Handshake decode failed: malformed HandshakeMessage"
|
||||
push_error("SimBridge: %s" % reason)
|
||||
@@ -261,11 +264,10 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
# Server blocks waiting for this before entering the tick loop.
|
||||
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)
|
||||
var send_err: int = _bridge.send_message(startup_bytes)
|
||||
if send_err != OK:
|
||||
var reason := "Failed to send startup message: %s" % error_string(send_err)
|
||||
push_error("SimBridge: %s" % reason)
|
||||
@@ -304,7 +306,7 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
match _bridge.get_status():
|
||||
StreamPeerTCP.STATUS_CONNECTED:
|
||||
# Receive: drain all complete messages from the bridge
|
||||
var msg := _bridge.poll_message()
|
||||
var msg: PackedByteArray = _bridge.poll_message()
|
||||
while msg.size() > 0:
|
||||
receive_bytes(msg)
|
||||
msg = _bridge.poll_message()
|
||||
@@ -316,7 +318,7 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
if outbound.size() > 0:
|
||||
var encoded := Protocol.encode_player_inputs(outbound)
|
||||
if encoded.size() > 0:
|
||||
var err := _bridge.send_message(encoded)
|
||||
var err: int = _bridge.send_message(encoded)
|
||||
if err != OK:
|
||||
push_error("SimBridge: failed to send message: %s" % error_string(err))
|
||||
else:
|
||||
|
||||
+43
-1
@@ -1,6 +1,11 @@
|
||||
extends Node2D
|
||||
|
||||
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
|
||||
# #898: Free camera pan speed in pixels/second (unzoomed) and zoom step per scroll tick.
|
||||
const FREE_CAMERA_PAN_SPEED: float = 400.0
|
||||
const FREE_CAMERA_ZOOM_STEP: float = 0.1
|
||||
const FREE_CAMERA_ZOOM_MIN: float = 0.5
|
||||
const FREE_CAMERA_ZOOM_MAX: float = 8.0
|
||||
|
||||
var economics_app = null # EconomicsApp — populated in _ready() via ImplantRegistry
|
||||
var atlas_app = null # AtlasApp — populated in _ready() via ImplantRegistry
|
||||
@@ -184,10 +189,33 @@ func _ready() -> void:
|
||||
atlas_app.economics_link_requested.connect(_on_atlas_economics_link)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
# #898: Scroll wheel zoom in free camera mode.
|
||||
if GameState.free_camera_mode and event is InputEventMouseButton:
|
||||
var mb := event as InputEventMouseButton
|
||||
if mb.pressed:
|
||||
var zoom := camera.zoom
|
||||
if mb.button_index == MOUSE_BUTTON_WHEEL_UP:
|
||||
zoom += Vector2(FREE_CAMERA_ZOOM_STEP, FREE_CAMERA_ZOOM_STEP)
|
||||
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||
zoom -= Vector2(FREE_CAMERA_ZOOM_STEP, FREE_CAMERA_ZOOM_STEP)
|
||||
camera.zoom = zoom.clamp(
|
||||
Vector2(FREE_CAMERA_ZOOM_MIN, FREE_CAMERA_ZOOM_MIN),
|
||||
Vector2(FREE_CAMERA_ZOOM_MAX, FREE_CAMERA_ZOOM_MAX)
|
||||
)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _unhandled_key_input(event: InputEvent) -> void:
|
||||
if not (event is InputEventKey) or not event.is_pressed() or event.is_echo():
|
||||
return
|
||||
var key_event := event as InputEventKey
|
||||
# #898: F4 toggles free camera mode. Reset zoom to 1:1 on exit.
|
||||
if Input.is_action_just_pressed("free_camera"):
|
||||
GameState.free_camera_mode = not GameState.free_camera_mode
|
||||
if not GameState.free_camera_mode:
|
||||
camera.zoom = Vector2.ONE
|
||||
return
|
||||
# Registry-driven toggle: each manifest declares its own default_key.
|
||||
for manifest: ImplantAppManifest in ImplantRegistry.get_manifests():
|
||||
if manifest.app_path.is_empty():
|
||||
@@ -239,9 +267,23 @@ func _process(delta: float) -> void:
|
||||
# #559: Dispatch snapshot to registered handlers (router pattern).
|
||||
_router.dispatch(snapshot)
|
||||
|
||||
# #898: Free camera WASD pan — runs in place of player tracking.
|
||||
if GameState.free_camera_mode:
|
||||
var pan := Vector2.ZERO
|
||||
if Input.is_action_pressed("move_north"):
|
||||
pan.y -= 1.0
|
||||
if Input.is_action_pressed("move_south"):
|
||||
pan.y += 1.0
|
||||
if Input.is_action_pressed("move_east"):
|
||||
pan.x += 1.0
|
||||
if Input.is_action_pressed("move_west"):
|
||||
pan.x -= 1.0
|
||||
if pan != Vector2.ZERO:
|
||||
var speed := FREE_CAMERA_PAN_SPEED / camera.zoom.x
|
||||
camera.global_position += pan.normalized() * speed * delta
|
||||
# Track camera to player (D-015: locked, fixed-north).
|
||||
# #117: Manual exponential smoothing.
|
||||
if _camera_anchored:
|
||||
elif _camera_anchored:
|
||||
var target := GameState.player_position * Constants.TILE_SIZE
|
||||
if _teleport_in_progress:
|
||||
camera.global_position = target
|
||||
|
||||
@@ -9,6 +9,9 @@ extends Node
|
||||
## Unit enum variants (no data) → bare strings ("MoveNorth", "Npc")
|
||||
## Data enum variants → single-element maps ({"UsePerceptionMode": "thermal"})
|
||||
|
||||
static func _mp():
|
||||
return load("res://addons/messagepack/messagepack.gd")
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
|
||||
|
||||
@@ -17,7 +20,7 @@ extends Node
|
||||
## v2 fields (version, game_time, player_facing, visible_tiles) default to null/empty
|
||||
## when decoding v1 snapshots for backward compatibility.
|
||||
static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
var result = Messagepack.decode(bytes)
|
||||
var result = _mp().decode(bytes)
|
||||
if result.status != null:
|
||||
push_error("Protocol: msgpack decode failed: %s" % result.status)
|
||||
return null
|
||||
@@ -613,38 +616,19 @@ static func _decode_enum_variant(raw) -> Dictionary:
|
||||
# -- Encode: GDScript types → bytes to server ----------------------------------
|
||||
|
||||
|
||||
## Encode a StartupMessage to MessagePack bytes (#175, #588, #718).
|
||||
## Encode a StartupMessage to MessagePack bytes (#175, #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).
|
||||
## Server reads this to initialize SimRng (D-010, D-029).
|
||||
## 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
|
||||
world_seed: int, 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".
|
||||
var archetype_variant: String
|
||||
match character_archetype:
|
||||
"detective":
|
||||
archetype_variant = "Detective"
|
||||
"smuggler":
|
||||
archetype_variant = "Smuggler"
|
||||
_:
|
||||
push_error(
|
||||
(
|
||||
"Protocol: unknown character_archetype '%s' — defaulting to 'Detective'"
|
||||
% character_archetype
|
||||
)
|
||||
)
|
||||
archetype_variant = "Detective"
|
||||
var msg := {
|
||||
"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)
|
||||
var result = _mp().encode(msg)
|
||||
if result.status != null:
|
||||
push_error("Protocol: startup message encode failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
@@ -665,7 +649,7 @@ static func encode_player_input(
|
||||
"action": action_value,
|
||||
}
|
||||
|
||||
var result = Messagepack.encode(input)
|
||||
var result = _mp().encode(input)
|
||||
if result.status != null:
|
||||
push_error("Protocol: msgpack encode failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
@@ -691,7 +675,7 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
|
||||
)
|
||||
)
|
||||
|
||||
var result = Messagepack.encode(wire_inputs)
|
||||
var result = _mp().encode(wire_inputs)
|
||||
if result.status != null:
|
||||
push_error("Protocol: msgpack encode failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
@@ -724,7 +708,7 @@ static func encode_change_settings(enabled: bool) -> PackedByteArray:
|
||||
"action_data": {"ai_enhanced_dialogue": enabled},
|
||||
}
|
||||
]
|
||||
var result = Messagepack.encode(entries)
|
||||
var result = _mp().encode(entries)
|
||||
if result.status != null:
|
||||
push_error("Protocol: encode_change_settings failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
@@ -735,7 +719,7 @@ static func encode_change_settings(enabled: bool) -> PackedByteArray:
|
||||
## Unit variant — no payload. Server responds with bookmark_catalog in the next snapshot.
|
||||
static func encode_request_bookmark_catalog() -> PackedByteArray:
|
||||
var entries: Array = [{"tick": 0, "action_name": "RequestBookmarkCatalog", "action_data": null}]
|
||||
var result = Messagepack.encode(entries)
|
||||
var result = _mp().encode(entries)
|
||||
if result.status != null:
|
||||
push_error("Protocol: encode_request_bookmark_catalog failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
@@ -752,7 +736,7 @@ static func encode_confirm_bookmark(bookmark_id: String, starting_location_id: S
|
||||
"action_data": {"bookmark_id": bookmark_id, "starting_location_id": starting_location_id},
|
||||
}
|
||||
]
|
||||
var result = Messagepack.encode(entries)
|
||||
var result = _mp().encode(entries)
|
||||
if result.status != null:
|
||||
push_error("Protocol: encode_confirm_bookmark failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
@@ -762,7 +746,7 @@ static func encode_confirm_bookmark(bookmark_id: String, starting_location_id: S
|
||||
## Decode a PlayerInput from MessagePack bytes (used in tests / echo scenarios).
|
||||
## Returns { "tick": int, "action": { "variant": String, "data": Variant } } or null.
|
||||
static func decode_player_input(bytes: PackedByteArray) -> Variant:
|
||||
var result = Messagepack.decode(bytes)
|
||||
var result = _mp().decode(bytes)
|
||||
if result.status != null:
|
||||
push_error("Protocol: msgpack decode failed: %s" % result.status)
|
||||
return null
|
||||
|
||||
@@ -88,10 +88,6 @@ static func apply(snapshot: Dictionary) -> void:
|
||||
else:
|
||||
GameState.current_monologue = null
|
||||
|
||||
# #122: lattice_profile
|
||||
if snapshot.has("lattice_profile") and snapshot.lattice_profile is String:
|
||||
GameState.lattice_profile = snapshot.lattice_profile
|
||||
|
||||
# v6: player_stance (#449, D-053)
|
||||
if snapshot.has("player_stance") and snapshot.player_stance is String:
|
||||
GameState.player_stance = snapshot.player_stance
|
||||
|
||||
@@ -40,15 +40,17 @@ func test_fog_visibility_forward_tile() -> void:
|
||||
var fog = _get_fog_state()
|
||||
if fog == null:
|
||||
return
|
||||
# Reset to deterministic state — 64x64 map at origin, all bytes zeroed
|
||||
# Reset to deterministic state — 64x64 map at origin, all bytes zeroed.
|
||||
# Use position (10,10): 8-tile padding gives tile_bounds origin (2,2), stays within
|
||||
# the 64x64 box and does not trigger an unexpected _resize() in update_from_state().
|
||||
GameState.visible_tiles = []
|
||||
fog._resize(Rect2i(0, 0, 64, 64))
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visibility_sectors = {Vector2i(5, 5): "Forward"}
|
||||
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||
GameState.visibility_sectors = {Vector2i(10, 10): "Forward"}
|
||||
fog.update_from_state()
|
||||
# Index: row 5 * width 64 + col 5
|
||||
assert_that(fog._vis_bytes[5 * 64 + 5]).override_failure_message(
|
||||
"Forward tile at (5,5) should be VIS_FORWARD=%d" % FogState.VIS_FORWARD
|
||||
# Index: row 10 * width 64 + col 10
|
||||
assert_that(fog._vis_bytes[10 * 64 + 10]).override_failure_message(
|
||||
"Forward tile at (10,10) should be VIS_FORWARD=%d" % FogState.VIS_FORWARD
|
||||
).is_equal(FogState.VIS_FORWARD)
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
@@ -81,10 +83,12 @@ func test_fog_exploration_persistence() -> void:
|
||||
var fog = _get_fog_state()
|
||||
if fog == null:
|
||||
return
|
||||
# Use position (10,10): 8-tile padding gives tile_bounds origin (2,2), stays within
|
||||
# the 64x64 box and does not trigger an unexpected _resize() in update_from_state().
|
||||
GameState.visible_tiles = []
|
||||
fog._resize(Rect2i(0, 0, 64, 64))
|
||||
var pos := Vector2i(5, 5)
|
||||
var idx: int = 5 * 64 + 5
|
||||
var pos := Vector2i(10, 10)
|
||||
var idx: int = 10 * 64 + 10
|
||||
# Frame 1: tile visible
|
||||
GameState.visible_positions = {pos: true}
|
||||
GameState.visibility_sectors = {pos: "Forward"}
|
||||
@@ -118,9 +122,10 @@ func test_fog_hidden_tile_value() -> void:
|
||||
assert_that(fog._vis_bytes[idx]).override_failure_message(
|
||||
"Never-visible tile should be VIS_HIDDEN=%d after resize" % FogState.VIS_HIDDEN
|
||||
).is_equal(FogState.VIS_HIDDEN)
|
||||
# Also verify it stays VIS_HIDDEN after an update that makes OTHER tiles visible
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visibility_sectors = {Vector2i(5, 5): "Forward"}
|
||||
# Also verify it stays VIS_HIDDEN after an update that makes OTHER tiles visible.
|
||||
# Use position (10,10): 8-tile padding stays within the 64x64 box, no resize triggered.
|
||||
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||
GameState.visibility_sectors = {Vector2i(10, 10): "Forward"}
|
||||
fog.update_from_state()
|
||||
assert_that(fog._vis_bytes[idx]).override_failure_message(
|
||||
"Non-visible tile should remain VIS_HIDDEN=%d after update" % FogState.VIS_HIDDEN
|
||||
|
||||
@@ -182,11 +182,10 @@ func test_d063_dim_alpha_is_set() -> void:
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func skip_test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
|
||||
func test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
|
||||
## D-063: Selecting a confrontation option fires confrontation_monologue signal.
|
||||
## BROKEN (#867): signal_fired stays false in headless; create_tween() before emit
|
||||
## may abort _start_confrontation_beat if panel node is null. Bug filed.
|
||||
## This delivers the 1-2 second internal monologue beat to MonologueDisplay.
|
||||
## Fixed (#867): guard tween_property behind is_instance_valid(panel) so emit fires
|
||||
## even in headless mode where the panel node may not be in the scene tree.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ func after_test() -> void:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_gamestate_examine_result_field_exists() -> void:
|
||||
assert_bool(GameState.has("current_examine_result")).override_failure_message(
|
||||
assert_bool("current_examine_result" in GameState).override_failure_message(
|
||||
"GameState must have 'current_examine_result' field (#174)"
|
||||
).is_true()
|
||||
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
## Sprint 22 fog state behavioral tests — revived in Sprint 38 (#879).
|
||||
## Original: deleted in Sprint 37 (#870 parse-error cleanup).
|
||||
## Spec refs: D-059, D-066, #569, #585
|
||||
##
|
||||
## Coverage: EXP_EXPLORED persistence, grow-only bounds invariant,
|
||||
## texture-resize copy, BoundaryWall handling.
|
||||
##
|
||||
## Uses FogState autoload directly via /root/FogState — byte-level assertions
|
||||
## on _vis_bytes and _exp_bytes, consistent with test_fog_shader.gd approach.
|
||||
class_name TestFogSprint22
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _get_fog_state() -> Node:
|
||||
var node = get_node_or_null("/root/FogState")
|
||||
if node == null:
|
||||
push_warning("TestFogSprint22: FogState autoload not found — test skipped")
|
||||
return node
|
||||
|
||||
|
||||
func _reset_fog_state(fog_state: Node) -> void:
|
||||
GameState.visible_positions.clear()
|
||||
GameState.boundary_positions.clear()
|
||||
GameState.visible_tiles.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
# 32x32 is an arbitrary test fixture size — not a production assumption.
|
||||
fog_state._resize(Rect2i(0, 0, 32, 32))
|
||||
|
||||
|
||||
# -- EXP_EXPLORED persistence --------------------------------------------------
|
||||
## D-059: Previously-seen tiles render as "deep fog" (EXP_EXPLORED = 128).
|
||||
## Once a tile enters LOS, leaving LOS must NOT reset it to EXP_UNEXPLORED.
|
||||
## This is the core "fog of war memory" invariant.
|
||||
|
||||
func test_exp_explored_persists_after_leaving_los() -> void:
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: tile (2,2) is in LOS → must become EXP_VISIBLE
|
||||
GameState.visible_positions = {Vector2i(2, 2): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var idx_2_2: int = (2 - oy) * w + (2 - ox)
|
||||
assert_int(exp[idx_2_2]).override_failure_message(
|
||||
"D-059: visible tile must have EXP_VISIBLE (255) on first sight"
|
||||
).is_equal(FogState.EXP_VISIBLE)
|
||||
|
||||
# Tick 2: tile (2,2) leaves LOS — only (3,3) is visible now
|
||||
GameState.visible_positions = {Vector2i(3, 3): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# After leaving LOS, (2,2) must be EXP_EXPLORED (128), not EXP_UNEXPLORED (0)
|
||||
ox = fog_state.map_bounds.position.x
|
||||
oy = fog_state.map_bounds.position.y
|
||||
w = fog_state.map_bounds.size.x
|
||||
exp = fog_state._exp_bytes
|
||||
idx_2_2 = (2 - oy) * w + (2 - ox)
|
||||
assert_int(exp[idx_2_2]).override_failure_message(
|
||||
"D-059: tile leaving LOS must decay to EXP_EXPLORED (128), not EXP_UNEXPLORED (0)"
|
||||
).is_equal(FogState.EXP_EXPLORED)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_never_seen_tile_stays_unexplored() -> void:
|
||||
## Corollary: a tile that was never in LOS stays EXP_UNEXPLORED.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tile (5,5) never enters LOS
|
||||
GameState.visible_positions = {Vector2i(2, 2): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var idx_5_5: int = (5 - oy) * w + (5 - ox)
|
||||
assert_int(exp[idx_5_5]).override_failure_message(
|
||||
"D-059: tile never in LOS must remain EXP_UNEXPLORED (0)"
|
||||
).is_equal(FogState.EXP_UNEXPLORED)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_exp_explored_not_overwritten_by_subsequent_invisible_ticks() -> void:
|
||||
## EXP_EXPLORED must not decay further after the player moves away.
|
||||
## If the player is never in the area again, the tile stays at EXP_EXPLORED.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: see tile (4,4)
|
||||
GameState.visible_positions = {Vector2i(4, 4): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Tick 2: player moves far away, (4,4) out of LOS
|
||||
GameState.visible_positions = {Vector2i(20, 20): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Tick 3: player stays far away
|
||||
GameState.visible_positions = {Vector2i(20, 20): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var idx_4_4: int = (4 - oy) * w + (4 - ox)
|
||||
assert_int(exp[idx_4_4]).override_failure_message(
|
||||
"D-059: EXP_EXPLORED must not decay further once set — tile stays at 128"
|
||||
).is_equal(FogState.EXP_EXPLORED)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
# -- Grow-only bounds invariant ------------------------------------------------
|
||||
## D-059: map_bounds only ever grows. Previously-explored tiles that leave the
|
||||
## visible area must not be evicted from the texture. The bounds never shrink.
|
||||
|
||||
func test_bounds_grow_when_player_moves_to_new_area() -> void:
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: small area visible
|
||||
GameState.visible_positions = {Vector2i(2, 2): true, Vector2i(3, 3): true}
|
||||
fog_state.update_from_state()
|
||||
var bounds_after_t1: Rect2i = fog_state.map_bounds
|
||||
|
||||
# Tick 2: player moves to a larger area
|
||||
GameState.visible_positions = {Vector2i(20, 20): true, Vector2i(25, 25): true}
|
||||
fog_state.update_from_state()
|
||||
var bounds_after_t2: Rect2i = fog_state.map_bounds
|
||||
|
||||
# Bounds must have grown or stayed the same — never shrunk
|
||||
assert_bool(bounds_after_t2.size.x >= bounds_after_t1.size.x).override_failure_message(
|
||||
"D-059: map_bounds width must never shrink (grow-only invariant)"
|
||||
).is_true()
|
||||
assert_bool(bounds_after_t2.size.y >= bounds_after_t1.size.y).override_failure_message(
|
||||
"D-059: map_bounds height must never shrink (grow-only invariant)"
|
||||
).is_true()
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_bounds_contain_new_visible_positions() -> void:
|
||||
## After update_from_state, all visible positions must lie within map_bounds.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
GameState.visible_positions = {Vector2i(10, 5): true, Vector2i(15, 12): true}
|
||||
fog_state.update_from_state()
|
||||
var bounds: Rect2i = fog_state.map_bounds
|
||||
|
||||
for pos in GameState.visible_positions:
|
||||
assert_bool(bounds.has_point(pos)).override_failure_message(
|
||||
"D-059: visible position %s must be within map_bounds %s" % [pos, bounds]
|
||||
).is_true()
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_bounds_encompass_previous_area_after_player_moves() -> void:
|
||||
## Old area coordinates must still be within map_bounds after player moves away.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: see area around (2,2)
|
||||
GameState.visible_positions = {Vector2i(2, 2): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Tick 2: player moves far away
|
||||
GameState.visible_positions = {Vector2i(20, 20): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# The original tile (2,2) must still be within map_bounds
|
||||
var bounds: Rect2i = fog_state.map_bounds
|
||||
assert_bool(bounds.has_point(Vector2i(2, 2))).override_failure_message(
|
||||
"D-059: grow-only — previously-visited area (2,2) must remain within map_bounds"
|
||||
).is_true()
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
# -- Texture-resize copy -------------------------------------------------------
|
||||
## D-059: When bounds grow (resize), exploration data from the old bounds
|
||||
## must be preserved in the new texture at the correct offsets.
|
||||
## This is the "texture-resize copy" invariant.
|
||||
|
||||
func test_exploration_data_preserved_across_resize() -> void:
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: mark (3,3) as explored
|
||||
GameState.visible_positions = {Vector2i(3, 3): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Move far enough to trigger a resize: _grow_bounds_from_positions adds 8-tile padding,
|
||||
# so (25,25) expands the bounds beyond the 32x32 fixture set in _reset_fog_state.
|
||||
GameState.visible_positions = {Vector2i(25, 25): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# After resize, (3,3) must still be EXP_EXPLORED (not reset to EXP_UNEXPLORED)
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var idx_3_3: int = (3 - oy) * w + (3 - ox)
|
||||
assert_int(exp[idx_3_3]).override_failure_message(
|
||||
"D-059: exploration state (EXP_EXPLORED=128) must survive texture resize"
|
||||
).is_equal(FogState.EXP_EXPLORED)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_newly_added_area_starts_unexplored_after_resize() -> void:
|
||||
## When bounds grow to include a new area, those new tiles start as EXP_UNEXPLORED.
|
||||
## The copy preserves old data; new tiles get the default (0).
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Establish a small explored area
|
||||
GameState.visible_positions = {Vector2i(2, 2): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Move far enough to trigger a resize: _grow_bounds_from_positions adds 8-tile padding,
|
||||
# so (30,30) expands the bounds beyond the 32x32 fixture set in _reset_fog_state.
|
||||
GameState.visible_positions = {Vector2i(30, 30): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# A completely new tile (30,30) on this tick should be EXP_VISIBLE (just entered LOS)
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var idx_30_30: int = (30 - oy) * w + (30 - ox)
|
||||
assert_int(exp[idx_30_30]).override_failure_message(
|
||||
"D-059: tile first entering LOS after resize must be EXP_VISIBLE (255)"
|
||||
).is_equal(FogState.EXP_VISIBLE)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
# -- BoundaryWall handling (#585) ----------------------------------------------
|
||||
## BoundaryWall margin tiles: fog lifts (VIS_FORWARD) so wall content composites,
|
||||
## but they do NOT persist as explored (not in visible_positions or _exp_bytes).
|
||||
|
||||
func test_boundary_wall_vis_bytes_are_forward() -> void:
|
||||
## #585: BoundaryWall tiles must receive VIS_FORWARD in the vis texture
|
||||
## so the wall sprite composites correctly (not occluded by fog).
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.boundary_positions = {Vector2i(6, 5): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var vis: PackedByteArray = fog_state._vis_bytes
|
||||
var boundary_idx: int = (5 - oy) * w + (6 - ox)
|
||||
assert_int(vis[boundary_idx]).override_failure_message(
|
||||
"#585: BoundaryWall tile must have VIS_FORWARD (255) in vis texture"
|
||||
).is_equal(FogState.VIS_FORWARD)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_boundary_wall_does_not_persist_as_explored() -> void:
|
||||
## #585: BoundaryWall tiles must NOT become EXP_EXPLORED after leaving the area.
|
||||
## They are rendering artifacts, not player memory.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: have a boundary wall tile at (6,5)
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.boundary_positions = {Vector2i(6, 5): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Tick 2: player moves away; (6,5) is no longer a boundary wall
|
||||
GameState.visible_positions = {Vector2i(20, 20): true}
|
||||
GameState.boundary_positions.clear()
|
||||
fog_state.update_from_state()
|
||||
|
||||
# (6,5) must not be EXP_EXPLORED — it was never a true explored tile
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var boundary_idx: int = (5 - oy) * w + (6 - ox)
|
||||
assert_int(exp[boundary_idx]).override_failure_message(
|
||||
"#585: BoundaryWall tile must NOT persist as EXP_EXPLORED — only true LOS tiles are explored"
|
||||
).is_equal(FogState.EXP_UNEXPLORED)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_normal_tile_adjacent_to_boundary_still_explored() -> void:
|
||||
## The normal LOS tile adjacent to a BoundaryWall must still be marked explored.
|
||||
## BoundaryWall exclusion must not affect neighboring tiles.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: normal tile (5,5) in LOS, boundary wall at (6,5)
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.boundary_positions = {Vector2i(6, 5): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Tick 2: player moves away
|
||||
GameState.visible_positions = {Vector2i(20, 20): true}
|
||||
GameState.boundary_positions.clear()
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Normal tile (5,5) must be EXP_EXPLORED
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var normal_idx: int = (5 - oy) * w + (5 - ox)
|
||||
assert_int(exp[normal_idx]).override_failure_message(
|
||||
"#585: normal LOS tile adjacent to BoundaryWall must still be EXP_EXPLORED (128)"
|
||||
).is_equal(FogState.EXP_EXPLORED)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_boundary_wall_visibility_only_when_present() -> void:
|
||||
## #585: A tile that is a BoundaryWall in tick 1 but absent in tick 2
|
||||
## must have VIS_HIDDEN in tick 2 (fog reapplied).
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: boundary wall at (6,5)
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.boundary_positions = {Vector2i(6, 5): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Tick 2: player moves far away; (6,5) no longer visible or boundary
|
||||
GameState.visible_positions = {Vector2i(20, 20): true}
|
||||
GameState.boundary_positions.clear()
|
||||
fog_state.update_from_state()
|
||||
|
||||
# (6,5) must be VIS_HIDDEN — fog returned
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var vis: PackedByteArray = fog_state._vis_bytes
|
||||
var boundary_idx: int = (5 - oy) * w + (6 - ox)
|
||||
assert_int(vis[boundary_idx]).override_failure_message(
|
||||
"#585: BoundaryWall tile must return to VIS_HIDDEN when not in current boundary set"
|
||||
).is_equal(FogState.VIS_HIDDEN)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
@@ -0,0 +1,88 @@
|
||||
## Free camera mode tests (#898).
|
||||
## Covers GameState flag default, InputMapper suppression, and zoom clamping.
|
||||
class_name TestFreeCamera
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.free_camera_mode = false
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.free_camera_mode = false
|
||||
|
||||
|
||||
# -- GameState.free_camera_mode default ----------------------------------------
|
||||
|
||||
func test_free_camera_mode_starts_false() -> void:
|
||||
## #898: Free camera is off by default — normal gameplay on startup.
|
||||
assert_bool(GameState.free_camera_mode).override_failure_message(
|
||||
"GameState.free_camera_mode must default to false"
|
||||
).is_false()
|
||||
|
||||
|
||||
# -- InputMapper suppression ---------------------------------------------------
|
||||
|
||||
func test_input_mapper_suppresses_movement_in_free_camera_mode() -> void:
|
||||
## #898: While free camera is active, InputMapper._process() returns early so
|
||||
## no movement actions enter the queue.
|
||||
GameState.free_camera_mode = true
|
||||
var before := InputMapper.input_queue.size()
|
||||
InputMapper._process(0.016)
|
||||
var after := InputMapper.input_queue.size()
|
||||
assert_int(after).override_failure_message(
|
||||
"InputMapper must not enqueue movement while free_camera_mode is true"
|
||||
).is_equal(before)
|
||||
InputMapper.input_queue.clear()
|
||||
|
||||
|
||||
func test_input_mapper_suppresses_discrete_actions_in_free_camera_mode() -> void:
|
||||
## #898: _unhandled_input returns early in free camera — INTERACT and stance
|
||||
## actions must not be queued.
|
||||
GameState.free_camera_mode = true
|
||||
var before := InputMapper.input_queue.size()
|
||||
var fake_event := InputEventAction.new()
|
||||
fake_event.action = "interact"
|
||||
fake_event.pressed = true
|
||||
InputMapper._unhandled_input(fake_event)
|
||||
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
||||
"InputMapper must not enqueue discrete actions while free_camera_mode is true"
|
||||
).is_equal(before)
|
||||
InputMapper.input_queue.clear()
|
||||
|
||||
|
||||
func test_input_mapper_resumes_after_free_camera_off() -> void:
|
||||
## Turning free camera off lifts the suppression — _process runs normally again.
|
||||
GameState.free_camera_mode = true
|
||||
GameState.free_camera_mode = false
|
||||
## _process should no longer return early (queue may or may not grow depending
|
||||
## on held keys, but no crash and guard is lifted).
|
||||
InputMapper._process(0.016)
|
||||
assert_bool(true).is_true() # no crash = pass
|
||||
InputMapper.input_queue.clear()
|
||||
|
||||
|
||||
# -- Zoom clamp contract -------------------------------------------------------
|
||||
|
||||
func test_zoom_min_constant_is_0_5() -> void:
|
||||
## #898: Minimum zoom keeps the world recognisable.
|
||||
var main_script = load("res://scripts/main.gd")
|
||||
assert_float(main_script.FREE_CAMERA_ZOOM_MIN).override_failure_message(
|
||||
"FREE_CAMERA_ZOOM_MIN must be 0.5"
|
||||
).is_equal_approx(0.5, 0.001)
|
||||
|
||||
|
||||
func test_zoom_max_constant_is_8() -> void:
|
||||
## #898: Maximum zoom must not exceed 8× per spec.
|
||||
var main_script = load("res://scripts/main.gd")
|
||||
assert_float(main_script.FREE_CAMERA_ZOOM_MAX).override_failure_message(
|
||||
"FREE_CAMERA_ZOOM_MAX must be 8.0"
|
||||
).is_equal_approx(8.0, 0.001)
|
||||
|
||||
|
||||
func test_zoom_step_is_positive() -> void:
|
||||
## Zoom step must be > 0 so scroll wheel does something.
|
||||
var main_script = load("res://scripts/main.gd")
|
||||
assert_float(main_script.FREE_CAMERA_ZOOM_STEP).override_failure_message(
|
||||
"FREE_CAMERA_ZOOM_STEP must be positive"
|
||||
).is_greater(0.0)
|
||||
@@ -12,7 +12,7 @@ class_name TestGameStateSprint20
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
func before_test() -> void:
|
||||
GameState.stationary_ticks = 0
|
||||
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
|
||||
GameState.current_zone_id = ""
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
##
|
||||
## API per Tyre architecture review:
|
||||
## show_monologue(text, duration, priority=2, is_urgent=false)
|
||||
## GameState.lattice_profile selects colour palette
|
||||
class_name TestMonologueDisplay
|
||||
extends GdUnitTestSuite
|
||||
|
||||
@@ -35,14 +34,11 @@ func _label_text(d: Node) -> String:
|
||||
|
||||
func before_test() -> void:
|
||||
## Reset GameState fields touched by this suite so tests don't bleed into each other.
|
||||
## lattice_profile: tests that care about colour set it explicitly — default to baseline.
|
||||
## current_monologue: GameState integration tests need null as start state.
|
||||
GameState.current_monologue = null
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.current_monologue = null
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -310,34 +306,10 @@ func test_text_has_color_bbcode() -> void:
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lattice colour palette
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_augmented_colour_differs_from_baseline() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
|
||||
GameState.lattice_profile = "lattice_augmented"
|
||||
d.show_monologue("Detective.", 5.0)
|
||||
var aug_txt := _label_text(d)
|
||||
d._visible[0].expire_timer = -0.1; d._process(0.0)
|
||||
d._next_fade_in_msec = 0.0
|
||||
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.show_monologue("Smuggler.", 5.0)
|
||||
var base_txt := _label_text(d)
|
||||
|
||||
assert_that(aug_txt).is_not_equal(base_txt)
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_urgent_colour_differs_from_standard() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.show_monologue("Normal.", 5.0, 2, false)
|
||||
var std_txt := _label_text(d)
|
||||
d._visible[0].expire_timer = -0.1; d._process(0.0)
|
||||
@@ -350,17 +322,6 @@ func test_urgent_colour_differs_from_standard() -> void:
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_unknown_profile_falls_back_without_crash() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
GameState.lattice_profile = "lattice_hypothetical_tier_x"
|
||||
d.show_monologue("Future proof.", 5.0)
|
||||
var txt := _label_text(d)
|
||||
assert_that(txt).contains("[color=#") # fallback colour applied, no crash
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slot lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -123,23 +123,26 @@ func test_game_state_warns_on_missing_player() -> void:
|
||||
# -- SimBridge: test data completeness --
|
||||
|
||||
func test_sim_bridge_test_snapshot_has_tiles() -> void:
|
||||
## Protocol uses "visible_tiles" (not "tiles") for test snapshot — updated from stale assertion.
|
||||
SimBridge.reset_test_state()
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.has("tiles")).is_true()
|
||||
assert_that(snap.tiles.size()).is_greater(0)
|
||||
var tile = snap.tiles[0]
|
||||
assert_that(snap.has("visible_tiles")).is_true()
|
||||
assert_that(snap.visible_tiles.size()).is_greater(0)
|
||||
var tile = snap.visible_tiles[0]
|
||||
assert_that(tile.has("x")).is_true()
|
||||
assert_that(tile.has("y")).is_true()
|
||||
assert_that(tile.has("type")).is_true()
|
||||
|
||||
func test_sim_bridge_test_snapshot_has_visible_positions() -> void:
|
||||
## Protocol uses "visible_tiles" for position data — visible_positions is derived client-side.
|
||||
## Updated from stale assertion: TestHarness snapshot never had a top-level "visible_positions".
|
||||
SimBridge.reset_test_state()
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.has("visible_positions")).is_true()
|
||||
assert_that(snap.visible_positions.size()).is_greater(0)
|
||||
var pos = snap.visible_positions[0]
|
||||
assert_that(pos.has("x")).is_true()
|
||||
assert_that(pos.has("y")).is_true()
|
||||
assert_that(snap.has("visible_tiles")).is_true()
|
||||
assert_that(snap.visible_tiles.size()).is_greater(0)
|
||||
var vtile = snap.visible_tiles[0]
|
||||
assert_that(vtile.has("x")).is_true()
|
||||
assert_that(vtile.has("y")).is_true()
|
||||
|
||||
func test_sim_bridge_test_snapshot_has_player_entity() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
@@ -162,10 +165,11 @@ func test_sim_bridge_test_snapshot_has_npc() -> void:
|
||||
assert_that(has_npc).is_true()
|
||||
|
||||
func test_sim_bridge_test_tiles_contain_all_types() -> void:
|
||||
## Protocol uses "visible_tiles" — updated from stale "tiles" assertion.
|
||||
SimBridge.reset_test_state()
|
||||
var snap = SimBridge._test_snapshot()
|
||||
var types: Dictionary = {}
|
||||
for tile in snap.tiles:
|
||||
for tile in snap.visible_tiles:
|
||||
types[tile.type] = true
|
||||
assert_that(types.has("floor")).is_true()
|
||||
assert_that(types.has("wall")).is_true()
|
||||
|
||||
@@ -1,79 +1,17 @@
|
||||
## Sprint 24 — Signal acceptance tests (#588, #590, #592)
|
||||
## Sprint 24 — Signal acceptance tests (#590, #592)
|
||||
##
|
||||
## Client-side acceptance criteria:
|
||||
## - #588: character_archetype field in GameState, StartupMessage, SessionManager persistence
|
||||
## - #590: triangle_crisis_events decoded by Protocol, chimed once per triangle_id
|
||||
## - #592: news_ticker decode + update_from_state hide/show behavior
|
||||
##
|
||||
## Spec: D-032 (monologue pools per character), D-016 (client displays server data only),
|
||||
## D-042 (UI strings in yaml), D-067 (chime on recognition onset)
|
||||
## Spec: D-016 (client displays server data only), D-042 (UI strings in yaml),
|
||||
## D-067 (chime on recognition onset)
|
||||
class_name TestSignalSprint24
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const NEWS_TICKER_SCENE = preload("res://ui/news_ticker.tscn")
|
||||
|
||||
|
||||
# -- #588: Character archetype field ------------------------------------------
|
||||
|
||||
func test_game_state_has_character_archetype_field() -> void:
|
||||
assert_bool("character_archetype" in GameState).override_failure_message(
|
||||
"GameState must have a character_archetype field (#588)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_game_state_character_archetype_default_is_detective() -> void:
|
||||
# Fresh GameState defaults to "detective" (safest fallback for legacy saves).
|
||||
var archetype = GameState.get("character_archetype")
|
||||
assert_str(archetype).override_failure_message(
|
||||
"GameState.character_archetype default must be 'detective'"
|
||||
).is_equal("detective")
|
||||
|
||||
|
||||
func test_protocol_startup_message_unknown_archetype_defaults_to_detective() -> void:
|
||||
# Unknown archetype strings must not silently pass garbage to the server.
|
||||
# The match guard falls back to "Detective" and calls push_error.
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(0, "hacker")
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.status).is_null()
|
||||
assert_str(decoded.value["character_archetype"]).override_failure_message(
|
||||
"Unknown archetype must fall back to 'Detective'"
|
||||
).is_equal("Detective")
|
||||
|
||||
|
||||
func test_protocol_startup_message_includes_character_archetype() -> void:
|
||||
# StartupMessage wire payload must carry "character_archetype" key (#588).
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(12345, "detective")
|
||||
assert_bool(bytes.size() > 0).is_true()
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.status).is_null()
|
||||
var msg: Dictionary = decoded.value
|
||||
assert_bool(msg.has("character_archetype")).override_failure_message(
|
||||
"StartupMessage must contain 'character_archetype' key, got: %s" % str(msg.keys())
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_protocol_startup_message_detective_maps_to_pascal_case() -> void:
|
||||
# "detective" client string must map to "Detective" PascalCase server enum variant.
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(0, "detective")
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_str(decoded.value["character_archetype"]).is_equal("Detective")
|
||||
|
||||
|
||||
func test_protocol_startup_message_smuggler_maps_to_pascal_case() -> void:
|
||||
# "smuggler" client string must map to "Smuggler" PascalCase server enum variant.
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(0, "smuggler")
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_str(decoded.value["character_archetype"]).is_equal("Smuggler")
|
||||
|
||||
|
||||
func test_protocol_startup_message_preserves_world_seed() -> void:
|
||||
# Adding character_archetype must not break world_seed encoding.
|
||||
var seed: int = 0xDEADBEEF
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(seed, "detective")
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_int(decoded.value["world_seed"]).is_equal(seed)
|
||||
|
||||
|
||||
# -- #590: triangle_crisis_events decode --------------------------------------
|
||||
|
||||
func test_protocol_decode_includes_triangle_crisis_events_field() -> void:
|
||||
|
||||
@@ -294,10 +294,12 @@ func test_hud_time_row_updates_after_process() -> void:
|
||||
"tick": 1, "version": 23, "entities": [],
|
||||
"game_time": {"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full"},
|
||||
})
|
||||
instance._process(0.016)
|
||||
|
||||
# In test mode SimBridge is disconnected — poll_snapshot() returns null so the
|
||||
# SnapshotEventRouter inside main._process() never fires. Call the HUD directly
|
||||
# instead, which is what the router would do in a live session.
|
||||
var hud = instance.get_node_or_null("InsertOverlay/HUD")
|
||||
assert_that(hud).is_not_null()
|
||||
hud.update_from_state()
|
||||
assert_that(hud.get_time_text()).is_equal("12:00 · Afternoon · D1")
|
||||
|
||||
|
||||
|
||||
@@ -498,7 +498,8 @@ func _start_confrontation_beat(response_id: String, text: String) -> void:
|
||||
if _active_tween and _active_tween.is_valid():
|
||||
_active_tween.kill()
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
|
||||
if is_instance_valid(panel):
|
||||
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
|
||||
|
||||
confrontation_monologue.emit(
|
||||
UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION
|
||||
|
||||
@@ -82,12 +82,16 @@ func _start_fade_out() -> void:
|
||||
|
||||
|
||||
## Dismiss immediately (e.g. when dialogue opens).
|
||||
## Sets _active = false immediately so is_active() returns false before the fade completes.
|
||||
func dismiss() -> void:
|
||||
if not _active:
|
||||
return
|
||||
_active = false
|
||||
if _dismiss_tween and _dismiss_tween.is_valid():
|
||||
_dismiss_tween.kill()
|
||||
_start_fade_out()
|
||||
var t := create_tween()
|
||||
t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
|
||||
t.tween_callback(func(): visible = false)
|
||||
|
||||
|
||||
func is_active() -> bool:
|
||||
|
||||
@@ -69,9 +69,9 @@ func _draw() -> void:
|
||||
else:
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(tex_w, tex_h)), Color(0.05, 0.07, 0.10, 1.0))
|
||||
|
||||
# Political zone tint (currency zone band — single tint over whole body for MVP)
|
||||
# Political zones — province boundaries from drainage analysis
|
||||
if viewer.is_overlay_visible("political_zones"):
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(tex_w, tex_h)), COLOR_POLITICAL)
|
||||
_draw_province_boundaries(markers)
|
||||
|
||||
# Infrastructure (roads + rail)
|
||||
if viewer.is_overlay_visible("infrastructure"):
|
||||
@@ -238,6 +238,39 @@ static func _city_key(city: Dictionary) -> String:
|
||||
return "h:%d" % city.hash()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Province boundaries (D-205, #927)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
const COLOR_PROVINCE_BORDER: Color = Color(0.45, 0.65, 0.85, 0.55)
|
||||
const COLOR_PROVINCE_FILL: Color = Color(0.25, 0.45, 0.65, 0.08)
|
||||
const PROVINCE_BORDER_WIDTH: float = 1.2
|
||||
|
||||
|
||||
func _draw_province_boundaries(markers: Dictionary) -> void:
|
||||
var provinces: Array = markers.get("provinces", [])
|
||||
if provinces.is_empty():
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(viewer.get_heightmap_texture().get_width(), viewer.get_heightmap_texture().get_height())), COLOR_POLITICAL)
|
||||
return
|
||||
for prov: Dictionary in provinces:
|
||||
var path: Array = prov.get("path", [])
|
||||
if path.size() < 3:
|
||||
continue
|
||||
var points: PackedVector2Array = _province_path_to_canvas(path)
|
||||
if points.size() >= 3:
|
||||
draw_colored_polygon(points, COLOR_PROVINCE_FILL)
|
||||
draw_polyline(points, COLOR_PROVINCE_BORDER, PROVINCE_BORDER_WIDTH, true)
|
||||
|
||||
|
||||
func _province_path_to_canvas(path: Array) -> PackedVector2Array:
|
||||
var out: PackedVector2Array = PackedVector2Array()
|
||||
for pt: Variant in path:
|
||||
if pt is Array and pt.size() >= 2:
|
||||
out.append(viewer.grid_to_canvas(Vector2(float(pt[1]), float(pt[0]))))
|
||||
return out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Overlay placeholders (populated by server side signals eventually)
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
extends MetaScreen
|
||||
extends "res://ui/meta/meta_screen.gd"
|
||||
## #258: Main menu — New Game / Continue / Load Game / Quit.
|
||||
## New Game: opens character creation screen, then starts game.
|
||||
## Continue: loads most recent save directory.
|
||||
|
||||
@@ -9,7 +9,6 @@ extends Control
|
||||
# (>= tiebreak = FIFO: newest replaces oldest at same priority).
|
||||
#
|
||||
# Stagger: 0.15s minimum gap between consecutive fade-ins (spec §5.4).
|
||||
# Colour: lattice_profile passed in at call time — no autoload access in renderer.
|
||||
# is_urgent=true → opacity 1.0 and elevated colour variant (bloom deferred).
|
||||
|
||||
const MAX_VISIBLE: int = 3
|
||||
@@ -20,29 +19,16 @@ const FADE_IN_SEC: float = 0.3
|
||||
const FADE_OUT_SEC: float = 0.5
|
||||
const MIN_DURATION: float = FADE_IN_SEC + 0.1 # clamp: line must survive its own fade-in
|
||||
|
||||
# Lattice colour palette — keyed by lattice_profile passed from GameState at show time.
|
||||
# standard opacity = 0.85, urgent opacity = 1.0.
|
||||
# Monologue colour palette — standard opacity = 0.85, urgent opacity = 1.0.
|
||||
# Source: Tyre architecture review, Sprint 14.
|
||||
const _LATTICE_COLORS: Dictionary = {
|
||||
"lattice_augmented":
|
||||
{ # detective
|
||||
"standard": Color("#d0d4e0"),
|
||||
"urgent": Color("#e0e8f8"),
|
||||
},
|
||||
"lattice_baseline":
|
||||
{ # smuggler
|
||||
"standard": Color("#d8d0c4"),
|
||||
"urgent": Color("#f0e4d4"),
|
||||
},
|
||||
}
|
||||
const _FALLBACK_STANDARD: Color = Color("#c8d0e0")
|
||||
const _FALLBACK_URGENT: Color = Color("#e0e8f8")
|
||||
const _STANDARD_COLOR: Color = Color("#c8d0e0")
|
||||
const _URGENT_COLOR: Color = Color("#e0e8f8")
|
||||
const _NOTIFICATION_COLOR: Color = Color("#8890a0") # #554: neutral system notification
|
||||
const _NOTIFICATION_DURATION: float = 2.5
|
||||
|
||||
# Visible slot: {node: Control, expire_timer: float, priority: int, tween: Tween}
|
||||
var _visible: Array[Dictionary] = []
|
||||
# Queue entry: {text, duration, priority, is_urgent, lattice_profile}
|
||||
# Queue entry: {text, duration, priority, is_urgent}
|
||||
var _queue: Array[Dictionary] = []
|
||||
# Msec timestamp when the next fade-in may begin (stagger enforcement)
|
||||
var _next_fade_in_msec: float = 0.0
|
||||
@@ -69,19 +55,15 @@ func _process(delta: float) -> void:
|
||||
if next.get("is_notification", false):
|
||||
_show_notification_line(next.text)
|
||||
else:
|
||||
_show_line(
|
||||
next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile
|
||||
)
|
||||
_show_line(next.text, next.duration, next.priority, next.is_urgent)
|
||||
|
||||
|
||||
# Display a monologue line.
|
||||
# priority: higher number = more important (default 2; urgent beats normal).
|
||||
# is_urgent: visual flag — full opacity + elevated colour. Bloom deferred.
|
||||
# Empty text is silently ignored — no slot created, no queue entry.
|
||||
# lattice_profile is read from GameState here and passed down — renderer stays
|
||||
# decoupled from the autoload (D-020 renderer contract).
|
||||
# #554: Show a brief system notification (save/load result, connection status).
|
||||
# Uses neutral color, short duration, bypasses lattice_profile styling.
|
||||
# Uses neutral color, short duration.
|
||||
func show_notification(text: String) -> void:
|
||||
if text.is_empty():
|
||||
return
|
||||
@@ -94,7 +76,6 @@ func show_notification(text: String) -> void:
|
||||
duration = _NOTIFICATION_DURATION,
|
||||
priority = 1,
|
||||
is_urgent = false,
|
||||
lattice_profile = "",
|
||||
is_notification = true
|
||||
}
|
||||
if _queue.size() < MAX_QUEUE:
|
||||
@@ -116,12 +97,11 @@ func show_monologue(
|
||||
) -> void:
|
||||
if text.is_empty():
|
||||
return
|
||||
var profile := GameState.lattice_profile
|
||||
var now := float(Time.get_ticks_msec())
|
||||
if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec:
|
||||
_show_line(text, duration, priority, is_urgent, profile)
|
||||
_show_line(text, duration, priority, is_urgent)
|
||||
else:
|
||||
_enqueue(text, duration, priority, is_urgent, profile)
|
||||
_enqueue(text, duration, priority, is_urgent)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -159,9 +139,9 @@ func _show_notification_line(text: String) -> void:
|
||||
|
||||
|
||||
func _show_line(
|
||||
text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String
|
||||
text: String, duration: float, priority: int, is_urgent: bool
|
||||
) -> void:
|
||||
var line_node := _build_line_node(text, is_urgent, lattice_profile)
|
||||
var line_node := _build_line_node(text, is_urgent)
|
||||
_vbox.add_child(line_node)
|
||||
|
||||
var slot := {
|
||||
@@ -192,7 +172,7 @@ func _retire_slot(slot: Dictionary) -> void:
|
||||
|
||||
|
||||
func _enqueue(
|
||||
text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String
|
||||
text: String, duration: float, priority: int, is_urgent: bool
|
||||
) -> void:
|
||||
if _queue.size() < MAX_QUEUE:
|
||||
_queue.append(
|
||||
@@ -201,7 +181,6 @@ func _enqueue(
|
||||
duration = duration,
|
||||
priority = priority,
|
||||
is_urgent = is_urgent,
|
||||
lattice_profile = lattice_profile
|
||||
}
|
||||
)
|
||||
_queue.sort_custom(
|
||||
@@ -216,7 +195,6 @@ func _enqueue(
|
||||
duration = duration,
|
||||
priority = priority,
|
||||
is_urgent = is_urgent,
|
||||
lattice_profile = lattice_profile
|
||||
}
|
||||
_queue.sort_custom(
|
||||
func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority
|
||||
@@ -232,13 +210,8 @@ func _lowest_priority_idx() -> int:
|
||||
return idx
|
||||
|
||||
|
||||
func _build_line_node(text: String, is_urgent: bool, lattice_profile: String) -> Control:
|
||||
var palette: Dictionary = _LATTICE_COLORS.get(lattice_profile, {})
|
||||
var color: Color = (
|
||||
palette.get("urgent", _FALLBACK_URGENT)
|
||||
if is_urgent
|
||||
else palette.get("standard", _FALLBACK_STANDARD)
|
||||
)
|
||||
func _build_line_node(text: String, is_urgent: bool) -> Control:
|
||||
var color: Color = _URGENT_COLOR if is_urgent else _STANDARD_COLOR
|
||||
|
||||
var container := MarginContainer.new()
|
||||
container.add_theme_constant_override("margin_left", 4)
|
||||
|
||||
+477
-1
@@ -761,4 +761,480 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
|
||||
---
|
||||
|
||||
*54 decisions. Last updated: 2026-04-21 (D-192 — drop PROTOCOL_VERSION lockstep handshake, sprint 36 client triage)*
|
||||
### D-194: Three-Component District Mix Algorithm for City District Type Distribution
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** District type distribution for a generated city is computed from three components combined at generation time:
|
||||
1. **Population tier guarantees** — minimum district counts enforced by city size. Population tier is `floor(log10(pop / 1_000_000))`, capped at 5. Larger populations guarantee minimum counts of Transit, Commercial, and Residential districts.
|
||||
2. **10×9 economic multiplier table** — rows are 10 `economic_role` values (manufacturing, financial, agricultural, extraction, service_mixed, institutional, transit_hub, research, military, residential); columns are 9 `DistrictType` variants. Each cell is a weight multiplier (0.0–3.0) applied to that district type's base probability for cities of that economic role.
|
||||
3. **Political archetype modifiers** — `PoliticalArchetype` shifts weights for Institutional, Restricted-access, and Civic district types. Corporate archetype boosts Commercial + Restricted. Commission archetype boosts Institutional + Administrative. Pioneer archetype boosts Mixed-use + Organic residential.
|
||||
- **Founding age character** is applied as a post-mix adjustment to `BlockIrregularity` (see D-216), not to the district type distribution itself.
|
||||
- The mix is self-contained per city: two cities with the same economic role, population tier, and political archetype produce the same district type distribution (modulo seed-driven noise). No city-to-city state dependency.
|
||||
- Integer weights throughout — no f32 for D-010 determinism.
|
||||
- **Rationale:** Economic role should visibly shape a city's physical form. A financial hub looks different from a mining hub. Population tier prevents cities from being too small to sustain their economic function. Political archetype encodes power structure in spatial form — Corporate settlements are commercially dense, Commission settlements are institutionally heavy. The three-component model is the minimum set to produce legible variety; adding more inputs risks over-constraining the generator.
|
||||
- **Ticket:** #920
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-191 (atlas pipeline), D-213 (DistrictType enum), D-214 (PoliticalArchetype), D-216 (BlockIrregularity)
|
||||
|
||||
### D-195: Attractor-Matching Compatibility Matrix for Generative City Placement
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** City placement on a planetary surface uses an attractor-matching model. A `GeographicAttractor` is a terrain feature that increases city placement score at nearby positions. Seven `AttractorType` variants: `RiverMouth`, `CoastalAccess`, `RiverCrossing`, `ValleyFloor`, `PassEntrance`, `LakeShore`, `PlainCenter`. A `CompatibilityMatrix` is a 10×7 scoring table (10 `economic_role` values × 7 attractor types) whose cells are float weights (0.0–3.0) representing how strongly that economic role favors that terrain feature. Examples: manufacturing → RiverMouth 2.8, ValleyFloor 2.1; financial → CoastalAccess 2.5, PlainCenter 1.8; agricultural → ValleyFloor 3.0, PlainCenter 2.5. The matrix is authored data (not computed at runtime). Attractor extraction from heightmaps is defined in D-209. Matching algorithm is defined in D-211.
|
||||
- **Rationale:** Terrain-naive city placement produces spatially incoherent worlds. The compatibility matrix gives different city types different terrain affinities, so financial hubs appear on coasts and agricultural cities appear in fertile valleys — without hard-coding placement rules per city type. The float weight matrix gives graduated preference, not binary requirement.
|
||||
- **Ticket:** #919, #925
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-191 (atlas pipeline), D-208 (D8 drainage — attractor extraction), D-209 (feature tag extraction), D-211 (attractor-matching pipeline)
|
||||
|
||||
### D-196: SettlementClass Enum and Latent Settlement Active/Ghost Logic
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** Every settlement (city marker in `atlas_city_names`) has a `SettlementClass` that determines how it enters and exits active simulation:
|
||||
```rust
|
||||
enum SettlementClass {
|
||||
NameLocked, // Named in wiki; always active regardless of population
|
||||
PopulationBudget, // Active if pop > threshold; ghost if below
|
||||
EconomicTriggered, // Active only while economic role condition is met
|
||||
OrganicGrowth, // Emergent; generated by simulation, no prior wiki record
|
||||
}
|
||||
```
|
||||
- **Active threshold** (applies to `PopulationBudget`): population ≥ 50,000 for a city to receive full Phase 1 district skeleton generation. Below threshold: 1-district stub with Minimal ComplexityTier.
|
||||
- **Ghost threshold** (applies to `PopulationBudget`): population < 5,000. Settlement is present in atlas data but receives no NPC population; structures are generated as abandoned (Worn/Derelict condition baseline).
|
||||
- `NameLocked` settlements bypass both thresholds — they are always simulated regardless of population (handles narrative-significant small towns).
|
||||
- `EconomicTriggered` settlements collapse to ghost state when their triggering economic condition lapses (e.g., a mining outpost depopulates when the mine is exhausted).
|
||||
- `OrganicGrowth` settlements are not in `atlas_city_names` at generation time; they are written to the table during simulation when a settlement emerges organically.
|
||||
- **Rationale:** Not every named location needs full generation, and not every simulated location is named. The classification separates authorial intent (NameLocked) from economic reality (PopulationBudget, EconomicTriggered) and simulation emergence (OrganicGrowth). Ghost settlements are important for world texture — abandoned mining towns and depopulated frontier outposts are as legible as thriving hubs.
|
||||
- **Ticket:** #913
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-191 (atlas pipeline), D-200 (CityGenerationContext), D-203 (BodyWorldState), D-207 (atlas_city_names)
|
||||
|
||||
### D-197: prosperity_baseline Derivation Formula with Topographic Gradient
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** Each city's `prosperity_baseline` (f32, 0.0–1.0, used as economic pressure state seed) is derived at generation time from four components:
|
||||
1. **Economic role base** — lookup per `economic_role` value: manufacturing=0.55, financial=0.70, agricultural=0.50, extraction=0.45, service_mixed=0.60, institutional=0.65, transit_hub=0.60, research=0.65, military=0.55, residential=0.50.
|
||||
2. **Population log-scale bonus** — `0.04 × floor(log10(pop / 1_000_000 + 1))`, capped at +0.12. Larger cities are generally more prosperous.
|
||||
3. **Topographic gradient bonus** — terrain features that historically correlate with prosperity add to the baseline: river mouth +0.08, coastal access +0.06, valley floor +0.04, pass entrance +0.03. At most one terrain bonus applies (the highest-scoring attractor at the city's position).
|
||||
4. **Seed noise** — ±0.05 uniform noise applied last (integer-seeded per city, D-010 determinism).
|
||||
- Formula: `base + pop_bonus + terrain_bonus + noise`, clamped to [0.1, 0.95].
|
||||
- `prosperity_baseline` is not the current prosperity level — it is the simulation's starting point and decay/growth target. The live pressure simulation (D-026) drifts from this value based on trade flows, events, and faction pressure.
|
||||
- **Rationale:** A flat random baseline produces economically incoherent worlds. Terrain-informed prosperity encodes real-world patterns: port cities are wealthy, river-mouth cities are strategic. The log-scale population bonus prevents megacities from dominating without eliminating small-city character. Clamping to [0.1, 0.95] prevents degenerate all-thriving or all-collapsing starting states.
|
||||
- **Ticket:** #920 (consumer of prosperity_baseline)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-194 (district mix — consumes prosperity_baseline), D-195 (attractor types that produce terrain bonus)
|
||||
|
||||
### D-198: Economic Simulation Independence from Layer 1–2 Spatial Data
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** The economics simulation (Phase 2, D-026 background tier) runs independently of Layer 1 (galaxy graph) and Layer 2 (location profiles / planetary topography). Economic state is seeded at game start from `systems.db` data (economic roles, trade flows, corporate presence) and then drifts via the pressure simulation. The generator (Layer 7 district skeleton) reads economic pressure state as an input but does not feed back into the simulation model. The two layers communicate one-way: simulation → generator (pressure state used to set district condition and density), never generator → simulation.
|
||||
- **Prohibited:** Generator code must not modify `PressureState`. Generator code must not query live simulation state during async background generation tasks (race condition risk). Generator reads a snapshot of pressure state taken at generation dispatch time.
|
||||
- **Allowed:** The generator reads `economic_health`, `prosperity_baseline`, `industries`, and `faction_influence` from the snapshot. These are read-only inputs to Phase 1 skeleton classification and Phase 2 condition application.
|
||||
- **Rationale:** Bidirectional coupling between generator and simulation creates initialization order dependencies and potential circular references. The one-way data flow (simulation → generator snapshot → generator) keeps both systems independently testable and avoids race conditions in the Rayon thread pool (D-206). The generator is a consumer of economic state, not a participant in economic evolution.
|
||||
- **Ticket:** #915 (CityGenerationContext reads economic snapshot)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-026 (simulation tiers), D-200 (CityGenerationContext), D-206 (background generation queue)
|
||||
|
||||
### D-199: 6-Field Minimum Economic Read Set for City Generation Context
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** When building a `CityGenerationContext` (D-200), the generator reads exactly 6 fields from the economic pressure snapshot per city. Reading more fields is permitted but these 6 are the minimum required for correct Phase 1 skeleton classification:
|
||||
1. `economic_role` — primary function of the city (determines DistrictType distribution via D-194)
|
||||
2. `prosperity_baseline` — starting economic health (0.0–1.0, see D-197)
|
||||
3. `population` — city population (determines ComplexityTier ceiling, BlockSkeleton density)
|
||||
4. `dominant_faction` — faction with highest `faction_influence` at this location (affects Institutional and Restricted district bias)
|
||||
5. `founding_age_years` — years since settlement founding (drives BlockIrregularity via D-216, era distribution)
|
||||
6. `settlement_class` — `SettlementClass` enum value (D-196, determines whether to generate at all)
|
||||
- Fields 1–5 are read from `systems.db` (bodies table + economics tables). Field 6 is derived at generator dispatch time.
|
||||
- All 6 fields must be present before a generation task is dispatched. Missing fields abort the task with a logged error; generation does not proceed with partial context.
|
||||
- **Rationale:** A fixed minimum read set prevents generators from accumulating unbounded dependencies on simulation state. The 6 fields cover the minimum information needed to produce a correctly-classified skeleton. The abort-on-missing-fields rule ensures generator output is always deterministic from a complete context, never silently degraded from a partial one.
|
||||
- **Ticket:** #915 (CityGenerationContext implementation)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-196 (SettlementClass), D-197 (prosperity_baseline), D-198 (economic simulation independence), D-200 (CityGenerationContext struct)
|
||||
|
||||
### D-200: Three-Tier Execution Model (Build-Time / Runtime-Background / Runtime-On-Demand)
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** The generation pipeline operates at three distinct execution tiers with no cross-tier mutation:
|
||||
1. **Build-time (Python pipeline):** Runs `make regen-db`. Produces `systems.db` tables including `atlas_body_heightmaps`, `atlas_city_names`, `atlas_province_boundaries`, `body_radius_km`. Output is a static artifact committed to the repo. Never runs during gameplay.
|
||||
2. **Runtime-background (Rayon thread pool, D-206):** Triggered by content-spidering events (player approaches a system, NPC names a location, news ticker references a place). Runs D8 drainage analysis (D-208), attractor extraction (D-209), settlement placement, and Phase 1 district skeleton generation. Output goes into `BodyWorldState` cache (D-203). Transparent to main tick thread.
|
||||
3. **Runtime-on-demand (main tick thread):** Triggered when the player crosses a chunk boundary. Runs Phase 2 chunk fill for the approaching chunk. Must complete within 5ms. Reads from `BodyWorldState` cache (always populated before this tier runs).
|
||||
- **Tier boundary rules:**
|
||||
- Build-time outputs are read-only at runtime.
|
||||
- Runtime-background tasks read from `systems.db` and write to `BodyWorldState` only.
|
||||
- Runtime-on-demand reads from `BodyWorldState` and writes to the active ECS world (chunk tile data, NPC spawns).
|
||||
- No tier may write to a higher tier's outputs. No circular dependencies.
|
||||
- `CityGenerationContext` struct (see below) is the data contract between tiers 1→2.
|
||||
```rust
|
||||
struct CityGenerationContext {
|
||||
city_id: u64,
|
||||
political_archetype: PoliticalArchetype,
|
||||
prosperity_baseline: f32,
|
||||
surrounding_biome: SettingType,
|
||||
road_entry_directions: Vec<u8>, // compass octants (0–7)
|
||||
footprint_radius_km: f32,
|
||||
founding_orientation: FoundingOrientation,
|
||||
world_tier: WorldTier,
|
||||
}
|
||||
```
|
||||
- **Rationale:** Three tiers with explicit boundaries eliminates the "where does this code run?" question. Build-time is deterministic and committable. Runtime-background is parallelizable. Runtime-on-demand has strict latency budgets. Cross-tier mutation would create race conditions between the Rayon thread pool and the main tick thread.
|
||||
- **Ticket:** #915
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-026 (simulation tiers), D-194 (district mix), D-203 (BodyWorldState), D-206 (background generation queue), tyre-sw1r3.md Layer 5/6 architecture
|
||||
|
||||
### D-201: Spatial Hierarchy — Eight Tiers with Locked Dimensions
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** The generation pipeline has eight spatial tiers from galaxy to tile. Dimensions are locked and cannot be changed without amending this decision:
|
||||
|
||||
| Tier | Name | Dimensions | Purpose |
|
||||
|------|------|------------|---------|
|
||||
| 1 | Galaxy | 300 systems | Galaxy graph, gate topology, cultural corridors |
|
||||
| 2 | System | — | Orbital mechanics, body catalog |
|
||||
| 3 | Body | ~512×256 pixels (equirectangular heightmap) | Planetary topography, climate zones |
|
||||
| 4 | Region | ~50–500km | Province boundaries (watershed-derived, D-205), biome zones |
|
||||
| 5 | Settlement | ~1–30km radius | City footprint, district layout |
|
||||
| 6 | District | 512×512 sim tiles (256m) | Phase 1 skeleton, 4×4 block grid (D-094) |
|
||||
| 7 | Block | 128×128 sim tiles (64m) | Generator planning unit, 2×2 chunks (D-094) |
|
||||
| 8 | Chunk | 64×64 sim tiles (32m) | Streaming/serialization unit (D-094) |
|
||||
|
||||
- Tiers 6–8 are locked by D-094 (district spatial hierarchy). This decision formalizes Tiers 1–5 with equivalent lock status.
|
||||
- Tier 3 heightmap resolution (512×256 equirectangular at 1024×512 PNG) is the canonical format. Deviation requires amending D-191.
|
||||
- Tier 4 province boundaries are pre-computed at build-time and stored in `atlas_province_boundaries` (D-205). They are not re-computed at runtime.
|
||||
- The `SettingType` enum on `DistrictSkeleton` is the interface between Tier 5 (settlement planning) and Tier 6 (district generation).
|
||||
- **Rationale:** Locking spatial dimensions prevents the generative layers from drifting in incompatible directions. The heightmap pipeline, atlas pipeline, and district generator all assume these dimensions and would need coordinated migration if they changed. Formalization prevents silent per-system variation.
|
||||
- **Ticket:** #912 (WorldTier enum), #913 (SettlementClass)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-094 (district hierarchy — Tiers 6–8), D-191 (atlas pipeline — Tier 3), D-205 (province boundaries — Tier 4), D-208 (D8 drainage — Tier 3 analysis)
|
||||
|
||||
### D-202: Heightmap BLOB Storage Schema (atlas_body_heightmaps)
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** Heightmap elevation data is stored in `systems.db` as a BLOB in the `atlas_body_heightmaps` table. Schema:
|
||||
```sql
|
||||
CREATE TABLE atlas_body_heightmaps (
|
||||
body_id INTEGER PRIMARY KEY REFERENCES bodies(id),
|
||||
width INTEGER NOT NULL, -- pixel columns (canonical: 512)
|
||||
height INTEGER NOT NULL, -- pixel rows (canonical: 256)
|
||||
data BLOB NOT NULL, -- float32 little-endian, row-major, width×height floats
|
||||
sea_level REAL NOT NULL DEFAULT 0.0,
|
||||
imported_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
```
|
||||
- `data` is a `float32` little-endian BLOB. Size: `width × height × 4` bytes. Canonical: 512×256×4 = ~512KB per body.
|
||||
- Values are normalized elevation in [0.0, 1.0]. `sea_level` is the fraction below which terrain is underwater (default 0.0 = no ocean, overridden per body).
|
||||
- The Rust loader reads the BLOB via `bytemuck::cast_slice::<u8, f32>()` after fetching from SQLite. No endian conversion needed on LE-native systems; the pipeline stores LE explicitly.
|
||||
- Only inhabited bodies receive heightmap rows at build-time. Uninhabited bodies are generated on-demand (runtime-background tier, D-200).
|
||||
- This table is populated by the `import_heightmaps` build-time step in the asset pipeline (D-191 §9 pipeline order). It is read-only at runtime.
|
||||
- **Rationale:** Storing heightmaps in `systems.db` keeps the DB as the single source of truth for all generation inputs, avoids a separate file-fetching path in the Rust server, and allows the pre-push hook (asset pipeline rules) to detect stale heightmap data. The float32 LE layout matches what NumPy and PIL produce natively, minimizing conversion overhead in the Python pipeline.
|
||||
- **Ticket:** #901 (schema), #906 (import), #916 (Rust loader)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-191 (atlas pipeline — heightmap source), D-203 (BodyWorldState — consumer), D-208 (D8 drainage — reads this table)
|
||||
|
||||
### D-203: BodyWorldState Bevy Resource with LRU Cache
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** `BodyWorldState` is a Bevy `Resource` holding the Layer 1–2 output for each recently-accessed planetary body. It functions as an LRU (Least Recently Used) cache:
|
||||
- **Cache capacity:** 50 bodies.
|
||||
- **Memory budget:** ~5MB total (50 bodies × ~100KB per entry average). A single body's Layer 1–2 data includes: processed heightmap (float32 grid, ~512KB pre-downsampled to ~8KB working resolution), river network (`RiverNetwork` struct: river cells, confluences, mouths), drainage basin polygons, attractor list, province boundary references.
|
||||
- **Eviction policy:** On cache overflow, evict the body with the oldest `last_accessed` timestamp. Bodies that are the current player location or adjacent-system neighbors are pinned (not evicted).
|
||||
- **Population:** The runtime-background tier (D-200) populates cache entries via Rayon tasks. Main thread reads are always from the cache; main thread code must never perform blocking DB reads for heightmap data.
|
||||
- **Struct:**
|
||||
```rust
|
||||
struct BodyWorldState {
|
||||
body_id: u64,
|
||||
heightmap: Vec<f32>, // downsampled working grid
|
||||
river_network: RiverNetwork, // D-208 output
|
||||
drainage_basins: Vec<DrainageBasin>,
|
||||
attractors: Vec<GeographicAttractor>, // D-195 types
|
||||
last_accessed: SimTick,
|
||||
}
|
||||
```
|
||||
- The resource is initialized empty and populated on demand. Accessing a body not in the cache triggers a background generation task (D-206).
|
||||
- **Rationale:** The D8 drainage analysis (D-208) and attractor extraction (D-209) are expensive (target: ~50ms/body). Running them on the main tick thread would cause frame drops. The LRU cache ensures the main thread only reads pre-computed data. 50-body capacity covers the typical gameplay scenario (player in one system, neighboring system pre-cached) with margin.
|
||||
- **Ticket:** #917
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-200 (three-tier execution model), D-202 (heightmap BLOB — input), D-206 (background generation queue — populates cache), D-208 (D8 drainage — produces river network)
|
||||
|
||||
### D-204: body_radius_km Column on bodies Table
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** A `body_radius_km REAL` column is added to the `bodies` table in `systems.db`. This value is the mean radius of the planetary body in kilometers, used to:
|
||||
- Compute `area_count` (number of districts a settlement can contain, scales with surface area)
|
||||
- Convert province boundary pixel coordinates to real-world km distances
|
||||
- Derive the `footprint_radius_km` field on `CityGenerationContext` (D-200)
|
||||
- Schema change: `ALTER TABLE bodies ADD COLUMN body_radius_km REAL` (nullable, populated by import step)
|
||||
- **Fallback derivation** (applied when `body_radius_km IS NULL`): `planet_class` lookup table with canonical radii:
|
||||
- `super_earth`: 8,000 km
|
||||
- `earth_like`: 6,371 km
|
||||
- `sub_earth`: 4,500 km
|
||||
- `ocean_world`: 6,500 km
|
||||
- `arid`: 5,800 km
|
||||
- `ice_world`: 3,000 km
|
||||
- `gas_giant`: 50,000 km (no settlements)
|
||||
- `moon`: 1,737 km
|
||||
- `other` / unknown: 6,371 km (Earth default)
|
||||
- Fallback is applied at query time, not stored back. The column remains NULL until authoritative data is available.
|
||||
- **Rationale:** Surface area scales with radius squared; a body twice Earth's radius has four times the potential settlement density. Without this field the generator must use a flat default for all planets, producing physically implausible city counts on super-earths and moons alike. The fallback ensures the generator works before all bodies have explicit radius data.
|
||||
- **Ticket:** #905 (schema), #910 (populate from planet_class fallback)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-191 (atlas pipeline — body catalog), D-200 (CityGenerationContext — footprint_radius_km)
|
||||
|
||||
### D-205: Province Boundary Pre-Computation (atlas_province_boundaries)
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** Province boundaries (drainage basin divides) are pre-computed at build time from the D8 drainage analysis (D-208) and stored in `atlas_province_boundaries`:
|
||||
```sql
|
||||
CREATE TABLE atlas_province_boundaries (
|
||||
body_id INTEGER NOT NULL REFERENCES bodies(id),
|
||||
basin_id INTEGER NOT NULL,
|
||||
path TEXT NOT NULL, -- JSON array [[row, col], ...] pixel-space polyline
|
||||
area_pct REAL NOT NULL, -- fraction of body surface area in this basin
|
||||
PRIMARY KEY (body_id, basin_id)
|
||||
);
|
||||
```
|
||||
- Boundaries are stored as pixel-space polylines in the same `[row, col]` convention as `markers.json` (D-191 §8 canonical format).
|
||||
- `area_pct` is the fraction of the body's total surface area contained within this drainage basin.
|
||||
- Province boundaries are the basis for district-level political zoning and cultural corridor assignment at Tier 4 (Region) in D-201.
|
||||
- **Province count target:** 4–12 provinces per inhabited body, derived naturally from watershed analysis. Bodies with less topographic relief (plains worlds, ocean worlds) produce fewer, larger provinces.
|
||||
- **At runtime:** Province boundaries are read from `atlas_province_boundaries` at generation dispatch time and cached in `BodyWorldState` as `drainage_basins` (D-203). They are not re-computed at runtime.
|
||||
- **Rationale:** Province boundaries define the cultural geography of a world — the mountain ranges and river systems that separated civilizations and produced distinct regional identities. Pre-computing them at build time keeps the runtime-background tier focused on city placement and district generation rather than watershed analysis. Storing as polylines (not rasterized masks) keeps the table compact and human-readable.
|
||||
- **Ticket:** #904 (schema), #907 (populate from watershed analysis)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-191 (atlas pipeline), D-201 (spatial hierarchy — Tier 4 Region), D-203 (BodyWorldState — caches province data), D-208 (D8 drainage — source of basin divides)
|
||||
|
||||
### D-206: Background Generation Priority Queue and Rayon Thread Infrastructure
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** All non-urgent generator work runs through a prioritized Rayon thread pool:
|
||||
- **Thread count:** `available_parallelism - 2`, minimum 1. Reserves 2 cores for the main tick thread and Bevy scheduler.
|
||||
- **Priority queue:** Four levels: `Immediate` (player will arrive within 1 game-minute), `High` (player will arrive within 5 minutes), `Medium` (player is in the same system), `Low` (player has seen or heard of this location via NPC or news). Work items at higher priority pre-empt lower-priority items.
|
||||
- **Work item types:** `AnalyzeBody(body_id)` (D8 drainage + attractor extraction), `GenerateSkeleton(city_id, context)` (Phase 1 DistrictSkeleton), `FillChunk(district_id, block_pos)` (Phase 2 chunk fill for pre-loading).
|
||||
- **Event-driven pre-generation:** A `SystemNameIndex` (Aho-Corasick automaton over all body/system names from `systems.db`) scans NPC dialogue output and news ticker text. When a scan match hits, the referenced body is queued at `Low` priority if not already cached. This is the mechanism by which "NPC mentions a place → player travels there → world is already generated on arrival."
|
||||
- **Completion notification:** Completed tasks send a `GenerationComplete` event to the main tick thread via a `crossbeam` channel. The main thread drains this channel once per tick.
|
||||
- **Rationale:** The Rayon thread pool handles the D-200 runtime-background tier. The priority queue prevents low-priority speculation from blocking urgent work (player approaching). The Aho-Corasick name index enables cheap always-on scanning — NPC dialogue is low-bandwidth enough that scanning every output line has negligible cost. Pre-generation triggered by narrative content (NPC mentions a place) is the mechanism for making the world feel pre-existing rather than loading-on-demand.
|
||||
- **Ticket:** #924 (background queue), #926 (SystemNameIndex)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-200 (three-tier execution model — runtime-background tier), D-203 (BodyWorldState — output of background tasks), D-208 (D8 drainage — enqueued as AnalyzeBody)
|
||||
|
||||
### D-207: Fully Generative Placement — markers.json Stripped to Topographic Features
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** The `atlas_city_names` table replaces the authored city positions in `markers.json`. Going forward, `markers.json` files contain only topographic features (rivers, oceans, mountain ranges — per D-191 §8 canonical format). City positions, road networks, and rail networks are NOT authored in `markers.json`; they are generated from the terrain data and stored in `atlas_city_names` and derived tables.
|
||||
```sql
|
||||
CREATE TABLE atlas_city_names (
|
||||
id INTEGER PRIMARY KEY,
|
||||
body_id INTEGER NOT NULL REFERENCES bodies(id),
|
||||
name TEXT NOT NULL,
|
||||
economic_role TEXT NOT NULL,
|
||||
population INTEGER NOT NULL,
|
||||
corp_id INTEGER REFERENCES corporations(id), -- nullable, corp HQ if applicable
|
||||
reserved INTEGER NOT NULL DEFAULT 0, -- 1 = reserved for authored scenario use
|
||||
kind TEXT NOT NULL DEFAULT 'city' -- 'capital' | 'city'
|
||||
);
|
||||
```
|
||||
- `name` and position in the markers.json come from different sources: position is generated by the city placement algorithm; name is either authored (wiki), LLM-generated (Gemma 2 naming pipeline), or reserved (corp HQ name). The split allows position generation and naming to run independently.
|
||||
- `corp_id` links to the `corporations` table when a city is a corporation's headquarters or major hub city.
|
||||
- `reserved = 1` rows are scenario-specific cities that must not be relocated by the generation algorithm; the generator places other cities around them.
|
||||
- **`markers.json` authored city data** (hand-written city center positions in the 6 hand-authored templates: Lendel, Edict, Vuurkloof, Røros, Cairnside, Estrade) is migrated to `atlas_city_names` and treated as `reserved = 1` rows. The markers.json files for these templates then have their city arrays cleared.
|
||||
- **Rationale:** Authored city positions in markers.json created a split between hand-authored content and procedurally generated content that was impossible to query, diff, or validate consistently. Moving city identity to a table allows: SQL joins against economic data, corp HQ cross-references, scenario reservations, and attractor-matching validation. The topographic features (rivers, mountains) remain in JSON because they are polygon/polyline geometry better suited to JSON than relational rows.
|
||||
- **Ticket:** #902 (schema), #908 (populate from wiki), #909 (corp HQ cross-reference)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-191 (atlas pipeline — markers.json canonical format), D-196 (SettlementClass — column in atlas_city_names), D-200 (CityGenerationContext — reads from this table)
|
||||
|
||||
### D-208: D8 Priority-Flood Drainage Routing — Layer 1 Empty World
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** Drainage routing (the computation of flow direction, flow accumulation, and river network extraction) uses the **D8 priority-flood** algorithm on the body's float32 heightmap. This is the first Layer 1 computation run on a body before any city placement or attractor extraction.
|
||||
- **Algorithm:** D8 assigns each cell's flow direction to one of 8 neighbors based on the steepest descent. Priority-flood fills depression cells before routing to avoid spurious sinks. Flow accumulation is the count of upstream cells draining through each cell.
|
||||
- **River threshold:** A cell is classified as a river cell when `flow_accumulation > 200`. This threshold produces river networks of realistic density on canonical 512×256 heightmaps.
|
||||
- **Outputs** stored in `BodyWorldState.river_network`:
|
||||
- `river_cells: Vec<(u16, u16)>` — pixel positions of all river cells
|
||||
- `confluences: Vec<(u16, u16)>` — positions where two or more rivers merge
|
||||
- `mouths: Vec<(u16, u16)>` — positions where rivers reach sea level or the heightmap edge
|
||||
- **Province/basin output:** Cells that divide adjacent drainage basins become province boundary candidates (D-205). Boundaries are traced as polylines after flow accumulation is complete.
|
||||
- **Performance target:** ~50ms per body on a single Rayon thread for canonical 512×256 resolution.
|
||||
- **Determinism:** Integer-only arithmetic throughout. No f32 in the priority-flood comparisons (use integer-scaled elevation). D-010 compliant.
|
||||
- **Rationale:** D8 is the standard GIS drainage routing algorithm and produces the river networks that drive attractor scoring (river mouths, confluences = high-value `RiverMouth` attractors). The flow accumulation threshold of 200 was chosen empirically against the Lendel heightmap to produce ~8–15 named rivers per inhabited body — enough for cultural geography without over-fragmenting the landscape.
|
||||
- **Ticket:** #918
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-195 (attractor types — river mouth is highest-scoring), D-203 (BodyWorldState — output stored here), D-205 (province boundaries — derived from drainage divides), D-209 (feature tag extraction — reads river network)
|
||||
|
||||
### D-209: Geographic Feature Tag Extraction (7 Settlement Attractor Tags)
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** After D8 drainage analysis, 7 `AttractorType` tags are extracted from the heightmap + river network and stored as `Vec<GeographicAttractor>` in `BodyWorldState`. Each attractor has a position `[row, col]` and a `strength: f32` (0.0–1.0) derived from local terrain quality.
|
||||
- **Extraction rules per type:**
|
||||
- `RiverMouth`: cells in `river_network.mouths`. Strength = `flow_accumulation[cell] / max_flow_accumulation` (normalized). Always high-value.
|
||||
- `CoastalAccess`: cells within 3 pixels of a sea/ocean polygon (from `oceans[]` in markers.json), not already `RiverMouth`. Strength = 0.6 baseline + coast length bonus.
|
||||
- `RiverCrossing`: cells at confluences or where a river crosses a topographic saddle. Strength = `flow_accumulation / max_flow_accumulation × 0.7`.
|
||||
- `ValleyFloor`: local elevation minima in non-river cells with positive habitability score (slope < 5°, elevation 10–60% of range). Strength = habitability score.
|
||||
- `PassEntrance`: local saddle points between adjacent drainage basins. Strength = inverse of elevation percentile (lower passes score higher).
|
||||
- `LakeShore`: cells adjacent to `lake` polygons in markers.json. Strength = 0.5 baseline.
|
||||
- `PlainCenter`: cells in flat terrain (slope < 2°) away from all other attractors. Strength = habitability score × 0.4.
|
||||
- Sub-biome classification (vegetation, aridity, temperature zones) is derived in parallel and stored as `SubBiomeVariant` on the attractor for use by the ZonePalette modifier system (D-101).
|
||||
- **Rationale:** The 7 attractor types cover the terrain features that historically determine city placement. Their extraction from the heightmap is deterministic and cheap given the D8 analysis is already complete. The strength normalization ensures attractor scores are comparable across bodies with different elevation ranges.
|
||||
- **Ticket:** #925 (types), #919 (matching pipeline that consumes these)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-195 (CompatibilityMatrix — attractor types), D-203 (BodyWorldState — storage), D-208 (D8 drainage — prerequisite), D-211 (attractor-matching pipeline — consumer)
|
||||
|
||||
### D-210: Sub-Biome Variant Classification and terrain_modification_cost
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** Each `GeographicAttractor` (D-209) carries a `sub_biome: SubBiomeVariant` tag that classifies the local terrain more finely than the top-level `SettingType`. This drives two systems: ZonePalette modifier selection (which visual variant to use) and `terrain_modification_cost` (how expensive it is to build infrastructure at this location).
|
||||
- `SubBiomeVariant` values: `TropicalWet`, `TemperateForest`, `TemperateGrassland`, `BorealForest`, `Tundra`, `Desert`, `Savanna`, `Alpine`, `Wetland`, `CoastalLowland`, `Volcanic`.
|
||||
- `terrain_modification_cost: f32` (1.0 = baseline, higher = more expensive): derived from sub-biome + local slope. Flat grassland = 1.0. Volcanic = 4.5. Wetland = 3.2. Alpine = 3.8. Coastal lowland = 1.4. Used by the attractor-matching pipeline (D-211) to penalize high-cost terrain for economically marginal cities.
|
||||
- Sub-biome classification uses: elevation percentile (of body total), local slope, moisture proxy (distance to nearest river mouth or coast), and temperature proxy (latitude of the equirectangular pixel).
|
||||
- Sub-biome data is stored in `BodyWorldState` alongside the attractors; it is not a separate DB table.
|
||||
- **Rationale:** Two cities on coastal terrain feel different when one is a tropical lowland port and the other is a cold Nordic fjord. Sub-biome tags enable the ZonePalette to select the correct visual register (T6 beach/coastal with tropical modifier vs T7 mountain/high with coastal modifier). The `terrain_modification_cost` gives the generator a principled reason to prefer some attractor positions over others for lower-prosperity cities.
|
||||
- **Ticket:** #919 (attractor matching — uses terrain_modification_cost)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-101 (ZonePalette modifier system — consumes sub_biome), D-195 (attractor types), D-209 (feature tag extraction — assigns sub_biome)
|
||||
|
||||
### D-211: Attractor-Matching Five-Phase Pipeline for Settlement Placement
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** Given a body's `Vec<GeographicAttractor>` and a set of cities from `atlas_city_names`, settlement placement runs a five-phase matching pipeline:
|
||||
1. **Score matrix build:** Compute a `city_count × attractor_count` score matrix. Each cell = `CompatibilityMatrix[economic_role][attractor_type] × attractor.strength × (1.0 / terrain_modification_cost)`.
|
||||
2. **Tier A greedy assignment:** For each city with `SettlementClass::NameLocked` or population ≥ 1,000,000, assign the highest-scoring unoccupied attractor using greedy selection. These cities must be placed first to anchor the spatial layout.
|
||||
3. **Hungarian algorithm for Tier B+C:** Apply the Hungarian algorithm to the remaining cities (population 50,000–999,999) and remaining attractors. Produces optimal global assignment maximizing total score.
|
||||
4. **Synthetic attractor overflow:** Cities that cannot be matched to a real attractor (attractor pool exhausted) receive a synthetic `PlainCenter` attractor generated at a position that respects minimum city spacing (15 pixels minimum on 512×256 grid = ~50km minimum separation).
|
||||
5. **Name fulfillment check:** After placement, verify that all `atlas_city_names` entries for this body have been assigned a position. Log a warning for any unplaced city.
|
||||
- **Two-tier mismatch flagging:** If a matched city-attractor pair has score < 0.35, log a `WARNING` (below expected quality). If score < 0.15, log an `ERROR` and flag for manual review. Generation proceeds in both cases; the flags are for content auditing, not hard blockers.
|
||||
- **Output:** `Vec<CityPlacement { city_id, position: [row, col], attractor: AttractorType, score: f32 }>` written to `atlas_city_positions` at build time.
|
||||
- **Rationale:** Greedy-first for large/locked cities ensures anchor cities (capitals, corp HQs, wiki-named cities) are placed at terrain features that match their lore role. Hungarian for medium cities finds the globally optimal assignment, not just locally optimal. Synthetic overflow prevents the algorithm from failing on bodies where city count exceeds natural attractor count (dense, flat worlds). The two-tier warning system enables content QA without blocking generation.
|
||||
- **Ticket:** #919, #925
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-195 (CompatibilityMatrix), D-207 (atlas_city_names), D-209 (GeographicAttractor — input), D-210 (terrain_modification_cost — input)
|
||||
|
||||
### D-212: TerritorialStatus Priority-Ordered Derivation Algorithm
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** Each `Province` (watershed-derived drainage basin, D-205) receives a `TerritorialStatus` value derived by priority-ordered classification at generation time:
|
||||
```rust
|
||||
enum TerritorialStatus {
|
||||
CommissionControlled, // Commission faction_influence ≥ 0.6 in this province
|
||||
CorpTerritory, // Single corporation faction_influence ≥ 0.5
|
||||
ContestedZone, // Two or more factions each ≥ 0.3, no dominant faction
|
||||
FrontierUnclaimed, // No faction with influence ≥ 0.2
|
||||
IndigenousHeld, // Cultural corridor has indigenous autonomy flag
|
||||
Derelict, // population_density < 0.01 AND no faction ≥ 0.1
|
||||
}
|
||||
```
|
||||
- Classification applies checks in priority order: `CommissionControlled` checked first, `Derelict` last. The first condition that is true sets the status.
|
||||
- `placed_at_generation: bool` flag on `Province` distinguishes classification at build time (true) from runtime re-classification during simulation (false). Build-time status is the starting state; simulation can change it, and the flag ensures the original classification is recoverable for reset/new-game scenarios.
|
||||
- Faction influence values are read from `systems.db` (economics tables) at build time using the same D-199 economic read pattern.
|
||||
- **Rationale:** Territory status is a high-level descriptor visible to the player on the Atlas overlay (D-191 §7, political zones overlay). It must be derivable from the generation inputs without runtime simulation state. The priority-ordered algorithm ensures clear, predictable classification — no ambiguous provinces. The `placed_at_generation` flag enables the game to show "how this province was at settlement time" vs. "how it is now."
|
||||
- **Ticket:** #921
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-191 (atlas overlay — political zones), D-199 (economic read set), D-205 (Province — this status is a field on it)
|
||||
|
||||
### D-213: FoundingOrientation Enum and Spatial Grid Rotation
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** `FoundingOrientation` describes the primary spatial axis of a city's original street grid, derived from the terrain feature that anchored the founding settlement. It controls the rotation of the district grid skeleton.
|
||||
```rust
|
||||
enum FoundingOrientation {
|
||||
Coastal { facing_degrees: u16 }, // street grid perpendicular to coastline
|
||||
RiverAligned { bearing_degrees: u16 }, // street grid parallel to founding river
|
||||
TerrainFollowing, // grid rotated to follow local contours
|
||||
Cardinal, // grid aligned to N/S/E/W (commission-planned)
|
||||
Free { bearing_degrees: u16 }, // arbitrary bearing (pioneer settlements)
|
||||
}
|
||||
```
|
||||
- `facing_degrees` and `bearing_degrees` are integer degrees 0–359 (0 = North, clockwise). Integer to preserve D-010 determinism.
|
||||
- The founding orientation is derived from the matched attractor type (D-211): `RiverMouth` → `Coastal`; `RiverAligned`; `CoastalAccess` → `Coastal`; `ValleyFloor` → `TerrainFollowing`; `PlainCenter` + Commission-controlled province → `Cardinal`; `PlainCenter` + other → `Free`.
|
||||
- The district skeleton generator (Phase 1) applies `FoundingOrientation` as the base rotation for the outermost district ring. Interior districts inherit the orientation unless overridden by a `PoliticalArchetype` modifier.
|
||||
- **Hard constraint:** Maximum ±45° deviation from the parent orientation per district (same limit as D-096 `BlockPlacement.rotation_steps`). Beyond ±45°, tile-based pathfinding produces movement artifacts.
|
||||
- **Rationale:** Street grids reflect the terrain and founding logic of the original settlement. Roman camps faced cardinal directions. River towns align with the river. Coastal cities face the water. Encoding this as a named enum rather than a raw angle makes the orientation legible in the data model and debuggable during generation.
|
||||
- **Ticket:** #914
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-096 (DistrictLayoutMode — inherits orientation), D-211 (attractor-matching — derives orientation), D-214 (PoliticalArchetype — may override orientation), D-215 (spatial arrangement patterns — uses orientation)
|
||||
|
||||
### D-214: PoliticalArchetype Enum and Settlement Spatial Character
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** `PoliticalArchetype` classifies a settlement's dominant power structure and its physical expression in district layout:
|
||||
```rust
|
||||
enum PoliticalArchetype {
|
||||
Commission, // Top-down Commission planning; rectilinear, institutional core
|
||||
Corporate, // Corp-dominated; commercial density, restricted zones, campus blocks
|
||||
Pioneer, // Self-organized; organic growth, mixed use, ad-hoc infrastructure
|
||||
Military, // Garrison or fortification origin; defensible geometry, restricted perimeter
|
||||
Academic, // University or research origin; campus-quad structure, green space
|
||||
Industrial, // Factory-first; large-footprint industrial blocks, worker residential rings
|
||||
}
|
||||
```
|
||||
- `PoliticalArchetype` is derived at generation time from `TerritorialStatus` (D-212) + `economic_role`: `CommissionControlled` province → `Commission`; `CorpTerritory` → `Corporate`; `FrontierUnclaimed` → `Pioneer`; military economic role → `Military`; research economic role → `Academic`; manufacturing + extraction → `Industrial`.
|
||||
- When multiple signals conflict (e.g., Commission-controlled manufacturing hub), `TerritorialStatus` takes precedence over `economic_role` for archetype derivation.
|
||||
- **Spatial effect on district mix:** See D-194. Each archetype applies weight multipliers to district type selection.
|
||||
- **`AttractorAssignment` disambiguation:** `OrganicGrowth` (a `DistrictType` value and also an `EraCause` value) is always unambiguous in context. On `DistrictType`, it means the district grew without a planning mandate. As `EraCause`, it means the era tag was acquired through organic settlement expansion rather than a discrete historical event. Both usages are permitted; the type system distinguishes them.
|
||||
- **Rationale:** Power structure should be legible in a city's spatial form without the player reading a wiki entry. Commission cities look different from Corporate cities look different from Pioneer cities — not just in palette, but in street geometry, district type distribution, and building scale. Encoding this as a named enum ensures the distinction is consistent across all generation code.
|
||||
- **Ticket:** #914
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-194 (district mix — archetype modifiers), D-212 (TerritorialStatus — primary input), D-213 (FoundingOrientation — archetype may override), D-215 (spatial arrangement patterns)
|
||||
|
||||
### D-215: Five Explicit Political Archetype Spatial Arrangement Patterns
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** Each `PoliticalArchetype` maps to one of five spatial arrangement patterns that govern district adjacency and the placement of landmark multi-block reservations:
|
||||
1. **Radial core** (Commission, Academic): Central landmark (civic square, institutional plaza, or university quad) surrounded by mixed-use rings. Transit spokes radiate outward. Districts are denser near center.
|
||||
2. **Campus grid** (Corporate): Restricted campus block occupies 2–4 blocks in the district interior. Commercial districts ring the exterior. Worker residential on periphery.
|
||||
3. **Ribbon development** (Pioneer, Industrial): Districts string along a linear feature (river, road, industrial rail). No dominant center. Mixed adjacency at every edge.
|
||||
4. **Fortified perimeter** (Military): Restricted and Secured districts at the edge of the footprint. Open access in the interior core. Single controlled access point per district edge.
|
||||
5. **Hub-and-spoke** (transit_hub economic role, any archetype): Transit district at center, all other district types accessible via direct corridors. Maximum 2-district travel between any two districts.
|
||||
- The arrangement pattern constrains block adjacency during Phase 1 skeleton generation. Specifically: the first 2–3 districts placed in a settlement follow the pattern. Later districts are constrained only by the road network, not by the pattern.
|
||||
- Arrangement patterns must **vary in angular orientation** per seed (not just position) — the same archetype's radial core must not always face the same direction across seeds.
|
||||
- **Rationale:** The 14 D-ready items from the generator-architecture workshop established that spatial arrangement should encode power structure. These five patterns are the minimal set to cover the 6 archetypes (Pioneer and Industrial share ribbon development; hub-and-spoke is a cross-archetype pattern for transit-primary cities). Pattern variation in angular orientation prevents players from pattern-matching settlement layout after the first playthrough.
|
||||
- **Ticket:** #914 (types), #899 (implementation — Phase 1 skeleton generator)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-094 (spatial hierarchy — district sizes), D-194 (district mix — archetype modifiers), D-214 (PoliticalArchetype — pattern assignment)
|
||||
|
||||
### D-216: BlockIrregularity from founding_age — Layout Age Character
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** `block_irregularity: f32` is a derived value on each block (range 0.0–1.0) that controls how much a block deviates from the district's canonical grid. It is computed from `founding_age_years` and `PoliticalArchetype`. The formula:
|
||||
```
|
||||
base_irregularity = (founding_age_years / 1000.0).min(1.0)
|
||||
archetype_step = match archetype {
|
||||
Commission | Military => -0.3, // suppresses organic deviation
|
||||
Corporate | Academic => -0.1,
|
||||
Industrial => 0.0,
|
||||
Pioneer => +0.3,
|
||||
}
|
||||
block_irregularity = (base_irregularity + archetype_step).max(0.05).min(1.0)
|
||||
```
|
||||
- Minimum 0.05 is enforced — no block is perfectly regular, even new Commission-planned settlements.
|
||||
- `block_irregularity` feeds the `BlockPlacement.offset` magnitude in `DistrictLayoutMode::Organic`: `max_offset_sim_tiles = (block_irregularity × 16.0) as i16`.
|
||||
- An old Pioneer settlement (age 800+ years) can have `block_irregularity ≈ 1.0`, producing maximum ±16 sim tile offsets and ±45° rotations. A new Commission district (age < 50 years) will have `block_irregularity ≈ 0.05`.
|
||||
- All arithmetic uses integer-scaled intermediates wherever possible (age is integer years; archetype_step is stored as integer basis points internally). The f32 in the formula above is for documentation clarity only.
|
||||
- **Rationale:** Age is the single most reliable predictor of urban irregularity in the real world. Old cities that grew organically have crooked streets; new planned cities have grids. Encoding this as a formula rather than a lookup table allows continuous variation along the age axis while preserving the political meaning of the archetype modifier.
|
||||
- **Ticket:** #922
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-096 (DistrictLayoutMode::Organic — consumes block_irregularity), D-194 (district mix — founding_age is an input), D-214 (PoliticalArchetype — archetype_step source)
|
||||
|
||||
### D-217: Tile Condition Thresholds (0.63 / 0.43 / 0.23)
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** A tile's visual condition is derived from the district's `prosperity_score` (live pressure simulation value, 0.0–1.0) using four threshold bands:
|
||||
| Band | Condition | prosperity_score range | Tile visual state |
|
||||
|------|-----------|----------------------|-------------------|
|
||||
| 1 | Intact | > 0.63 | Clean, undamaged, well-maintained |
|
||||
| 2 | Worn | 0.43 – 0.63 | Scuff marks, minor discoloration, partial repairs |
|
||||
| 3 | Cracked | 0.23 – 0.43 | Visible damage, incomplete repair, graffiti |
|
||||
| 4 | Broken | < 0.23 | Structural damage, debris, derelict appearance |
|
||||
- **Cache invalidation:** A tile's condition only changes when `prosperity_score` crosses a threshold boundary (from band N to band N±1). This avoids per-tick visual updates. The simulation checks threshold crossings once per game-minute (D-031 day-phase tick rate).
|
||||
- **Baseline floor:** The block's `EraCause` sets a minimum condition floor:
|
||||
- `Decay` era: minimum Cracked (no tile in a Decay-era block is ever Intact or Worn without an active renovation event)
|
||||
- `EmergencyExtension` era: minimum Worn
|
||||
- All other eras: no floor (condition follows prosperity_score freely)
|
||||
- **Phase 2 application:** Chunk fill applies the baseline condition at fill time. Subsequent condition updates from simulation crossing thresholds are applied as `ChunkMutations.tile_overrides`.
|
||||
- Condition thresholds are authored constants, not computed. Any change to the thresholds (0.63 / 0.43 / 0.23) requires amending this D-record.
|
||||
- **Rationale:** Threshold-crossing invalidation is a standard visual LOD technique that avoids expensive per-frame recalculation. The four bands (Intact/Worn/Cracked/Broken) match the visual fidelity budget for the current art direction — more bands require more tile variants per palette. The era-based floor ensures that historical context is always visible: a Decay-era block cannot spontaneously look pristine from a prosperity spike alone.
|
||||
- **Ticket:** #923
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-100 (DamageOverlay — post-condition modification), D-194 (district mix — prosperity_baseline is the seed for prosperity_score)
|
||||
|
||||
### D-218: WorldTier Enum Canonical Values (Epicenter/Regional/Backwater/Passage/Waypoint)
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** The canonical `WorldTier` enum values are:
|
||||
```rust
|
||||
enum WorldTier {
|
||||
Epicenter, // Hub system. Full simulation. High faction pressure. Multi-district cities.
|
||||
Regional, // Regional hub. 1–4 districts per city. Partial full-budget districts.
|
||||
Backwater, // Small community. Dense isolated settlement. Full sim budget — NOT capped.
|
||||
Passage, // Transit stop. Pass-through. ComplexityTier ceiling: Moderate.
|
||||
Waypoint, // Not simulated until player approaches. ComplexityTier ceiling: Minimal.
|
||||
}
|
||||
```
|
||||
- The values `Peripheral`, `Connected`, and `Core` used in generator.rs prior to Sprint 38 are **incorrect** — they were stubbed values that do not match the workshop design (workshop-outcomes.md §WorldTier and ComplexityTier). They must be replaced with the five canonical values above.
|
||||
- **ComplexityTier ceiling per WorldTier:**
|
||||
- `Epicenter` → Full
|
||||
- `Regional` → Full
|
||||
- `Backwater` → Full (critical: `Backwater` is network-insignificant, NOT budget-capped; isolated communities can be socially complex)
|
||||
- `Passage` → Moderate
|
||||
- `Waypoint` → Minimal
|
||||
- **Source of truth:** workshop-outcomes.md §WorldTier and ComplexityTier table (generator-architecture workshop, lead decision L-3).
|
||||
- All code referencing `WorldTier::Peripheral`, `WorldTier::Connected`, or `WorldTier::Core` must be updated to the canonical values. This includes generator.rs, any tests, and any serialized data that references these variants.
|
||||
- **Rationale:** The three-value stub (Peripheral/Connected/Core) was authored before the generator architecture workshop established the five-value canonical model. The mismatch between the code and the design means any generator code built against the stub types would need rewriting anyway. Correcting it now before the Phase 1 implementation work begins eliminates that rework. The `Backwater` full-budget exception is architecturally significant: dense isolated communities (mining towns, research outposts) should be as socially rich as regional hubs — their isolation is their drama, not their limitation.
|
||||
- **Ticket:** #900 (bug fix), #912 (full enum implementation)
|
||||
- **Raised by:** Generation cascade workshop (#897). Original workshop-outcomes.md §WorldTier (generator-architecture workshop, lead decision L-3).
|
||||
- **Cross-reference:** D-096 (DistrictLayoutMode — WorldTier is a DistrictSkeleton field), D-097 (GuaranteeAuditResult — tier-conditional guarantees), D-201 (spatial hierarchy — WorldTier assigned at Tier 2 System level)
|
||||
|
||||
---
|
||||
|
||||
*79 decisions (D-001 through D-218, excluding gaps). Last updated: 2026-05-02 (D-218 — WorldTier canonical values, generation cascade workshop Sprint 38)*
|
||||
|
||||
@@ -238,4 +238,15 @@ Narrative, NPCs, dialogue, templates, setting, worldbuilding, and storyteller me
|
||||
|
||||
---
|
||||
|
||||
*25 questions (11 resolved, 2 partially resolved, 12 open). Last updated: 2026-04-21 (Q-095 resolved — D-193 Lattice Commission)*
|
||||
---
|
||||
|
||||
### Q-097: Strip "What They Don't Talk About" from corporation pages
|
||||
- **Status:** Open
|
||||
- **Question:** Should we remove "What They Don't Talk About" sections from corporation wiki pages? Currently 116 of 156 corp pages have this section. The argument: cultural silences are a population/system-level phenomenon — people carry them because of where they live, not who employs them. A mining company doesn't develop its own cultural ethos; its workers inherit the system's silences. Corporate secrecy (trade secrets, undisclosed contracts) is just business, not anthropology. Gate Corporation may be an exception as a civilization-scale institution, but Arbour Aggregates and Rush Mining are regional businesses whose people are system-people first.
|
||||
- **Context:** Pattern originated from Gate Corporation (which plausibly operates at civilization scale) and was applied uniformly to all corp pages during bulk authoring. Now reinforcing itself — reviewers flag its *absence* as a defect (Sprint 38 PR #140 round 1). If left in place, every new corp page will copy the pattern. Counter-argument: some corporate silences *are* distinct from system silences (e.g., a pharmaceutical company's certification history vs. the system's general cultural memory). The question is whether that justifies a dedicated section or whether it belongs inline in Operations/Market Position.
|
||||
- **Affects:** 116 wiki/corporations/*.md files, corp page template, reviewer expectations
|
||||
- **Source:** Sprint 38 PR #140 review discussion (2026-05-02)
|
||||
|
||||
---
|
||||
|
||||
*26 questions (11 resolved, 2 partially resolved, 13 open). Last updated: 2026-05-02 (Q-097 corp silences)*
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,41 @@
|
||||
# Sprint 38: Depth — CI Tasks
|
||||
|
||||
**Goal:** Map the generation pipeline from planetary heightmaps to walkable tile environments, formally park character/apartment work behind the full cascade, and close test/infra debt from Sprint 37.
|
||||
|
||||
**Branch:** `sprint-38/ci`
|
||||
**Agents:** Justine (build/deploy)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Priority | Blocked by |
|
||||
|---|-------|----------|------------|
|
||||
| #887 | Add decisions-orphan-tickets CLI | low | — |
|
||||
| #888 | Switch meta.schema_version to monotonic semver | low | — |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full details.
|
||||
|
||||
## Notes
|
||||
|
||||
**#887 — decisions-orphan-tickets CLI**
|
||||
- `tickets.decision_ref` is free-text — a typo'd D-ID or renumbered decision silently orphans tickets.
|
||||
- Build a CLI tool that scans all tickets with a `decision_ref`, validates each against `decisions/*.md`, and reports orphans.
|
||||
- Output: list of tickets pointing at nonexistent or mismatched D-IDs.
|
||||
|
||||
**#888 — meta.schema_version to monotonic semver**
|
||||
- Current `meta.schema_version` stores a SHA-1 of `server/data/systems-schema.sql`. Two SHAs can't be ordered — you can't tell which is newer.
|
||||
- Switch to a monotonic semver string (e.g. `1.0.0`, `1.1.0`). This enables future savegame migration lineage: a save file can record its schema version and determine what migrations to apply.
|
||||
- Update `import_economics.py` stamp logic and `tooling/check-systems-db-stamp` validation.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#887 (orphan-tickets CLI) → standalone
|
||||
#888 (schema_version semver) → standalone
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI:
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "chore(ci): sprint 38 ci" --description "body" --base main --head sprint-38/ci
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
# Sprint 38: Depth — Client Tasks
|
||||
|
||||
**Goal:** Map the generation pipeline from planetary heightmaps to walkable tile environments, formally park character/apartment work behind the full cascade, and close test/infra debt from Sprint 37.
|
||||
|
||||
**Branch:** `sprint-38/client`
|
||||
**Agents:** Stig (dev), Tyre (arch), Hoshe (QA)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Priority | Blocked by |
|
||||
|---|-------|----------|------------|
|
||||
| #898 | Free camera viewer — WASD pan + scroll zoom, implant/atlas/map access | high | — |
|
||||
| #882 | Strip archetype-driven client code | medium | — |
|
||||
| #879 | Revive fog state behavioral tests | medium | — |
|
||||
| #867 | dialogue_box confrontation_monologue signal bug | medium | — |
|
||||
| #871 | Pre-existing test failures — umbrella triage | medium | — |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/architecture.md` — D-169 (implant UI components), D-170 (HUD visibility)
|
||||
- Implant component library: `client/ui/implant/` (ImplantPanel, ImplantHeader, etc.)
|
||||
- HUD layer manager: `client/scripts/autoloads/hud_groups.gd`
|
||||
|
||||
## Notes
|
||||
|
||||
**#898 — Free camera viewer (bare minimum, throwaway)**
|
||||
- The rendering system is up in the air — this viewer is iterative/disposable. Do not over-engineer.
|
||||
- Current camera is locked to `GameState.player_position` (`client/scripts/autoloads/game_state.gd:18`).
|
||||
- Decouple: add a debug/observer mode where camera position is independent of player entity.
|
||||
- Controls: WASD/arrow key pan, scroll-to-zoom. That's it.
|
||||
- The observer needs access to: implants (D-169/D-170 UI system), atlas, and map. Wire up existing `HudGroups` app paths (`implant/map`, `implant/wiki/gttr`, etc.).
|
||||
- No player entity needed. No server-side observer entity. Just a free camera over whatever tile data exists.
|
||||
- Key files: `client/main.gd` (camera setup), `client/scripts/autoloads/game_state.gd` (player_position), `client/scripts/autoloads/hud_groups.gd` (implant app switching).
|
||||
|
||||
**#882 — Strip archetype client code**
|
||||
- Follow-up to server #878 (done). `CharacterArchetype` trace is Phase 6 filler.
|
||||
- Find all client references to archetype enums/types and remove them.
|
||||
- #878 already removed the server side — client should have no remaining consumers.
|
||||
|
||||
**#879 — Revive fog state behavioral tests**
|
||||
- `test_fog_sprint22.gd` was deleted in Sprint 37 (#870 parse-error cleanup).
|
||||
- Contained unique behavioral tests: `EXP_EXPLORED` persistence after leaving LOS, grow-only bounds invariant.
|
||||
- Rewrite against current `FogState` API. Prefer live Gauntlet testing over mocks per testing preferences.
|
||||
|
||||
**#867 — dialogue_box confrontation signal bug**
|
||||
- `signal_fired` remains false after `_on_option_pressed(0)` on a confrontation option.
|
||||
- `_start_confrontation_beat` likely not firing in headless test mode.
|
||||
- Check signal wiring in `client/ui/dialogue_box.gd`.
|
||||
|
||||
**#871 — Pre-existing test failures umbrella triage**
|
||||
- 13 suites affected. For each: investigate, determine if it's stale assertion or real regression, then either fix inline or create a child ticket.
|
||||
- Per feedback rules: broken tests need a fix or a dated ticket, never a shrug.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#898 (free camera viewer) → standalone, start immediately
|
||||
#882 (strip archetype code) → standalone (#878 done)
|
||||
#879 (fog tests) → standalone
|
||||
#867 (signal bug) → standalone
|
||||
#871 (test triage) → standalone, spawns child tickets
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI:
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(client): sprint 38 client" --description "body" --base main --head sprint-38/client
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
# Sprint 38: Depth — Copy Tasks
|
||||
|
||||
**Goal:** Map the generation pipeline from planetary heightmaps to walkable tile environments, formally park character/apartment work behind the full cascade, and close test/infra debt from Sprint 37.
|
||||
|
||||
**Branch:** `sprint-38/copy`
|
||||
**Agents:** Mellanie (author), Paula (narrative), Gestalt (systems)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Priority | Blocked by |
|
||||
|---|-------|----------|------------|
|
||||
| #884 | Copy-team review: wiki/corporations authored by server in #860 | medium | — |
|
||||
|
||||
Use `tooling/db/ticket show 884` for full details.
|
||||
|
||||
## Notes
|
||||
|
||||
**#884 — Wiki corporations review**
|
||||
- Sprint 37 server ticket #860 had Dudley author/modify 21 `wiki/corporations/*.md` files to resolve the economy-db coverage gate.
|
||||
- Per team scope rules, `wiki/` is copy-team territory. These files need a voice/lore review pass.
|
||||
- Check: naming consistency, tone/voice alignment with existing wiki style, lore accuracy, factual consistency with economics data in `tooling/economy-db/`.
|
||||
- Light-touch — fix voice issues and flag lore contradictions, don't rewrite from scratch.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#884 (wiki corps review) → standalone
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI:
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "content(wiki): sprint 38 copy" --description "body" --base main --head sprint-38/copy
|
||||
```
|
||||
@@ -0,0 +1,60 @@
|
||||
# Sprint 38: Depth — Planning Tasks
|
||||
|
||||
**Goal:** Map the generation pipeline from planetary heightmaps to walkable tile environments, formally park character/apartment work behind the full cascade, and close test/infra debt from Sprint 37.
|
||||
|
||||
**Branch:** `sprint-38/planning`
|
||||
**Agents:** Gestalt (systems), Tyre (technical), Miri (worldbuilding), Qatux (documenter), SI (project manager)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Priority | Blocked by |
|
||||
|---|-------|----------|------------|
|
||||
| #897 | Generation pipeline cascade — map all layers, produce D-records | high | — |
|
||||
|
||||
Use `tooling/db/ticket show 897` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/architecture.md` — D-110 (signed z-levels), D-108 (MobileChunk)
|
||||
- `decisions/scope.md` — development cascade phases (CLAUDE.md §Development Cascade)
|
||||
|
||||
## Notes
|
||||
|
||||
**#897 — Generation pipeline cascade D-records**
|
||||
|
||||
This is the load-bearing planning ticket for the sprint. The team keeps cycling back to character and apartment topics before the generation pipeline is complete. This ticket produces the authoritative reference that prevents that.
|
||||
|
||||
**What exists:**
|
||||
- Galactic → system → planetary heightmaps: done (`tooling/planet-gen/generate_atlas.py`, `planet_simulation.py`)
|
||||
- Atlas markers with city placement, roads, rail, rivers: done (`markers.json` per body)
|
||||
- Generator data model in `server/src/simulation/generator.rs`: complete type hierarchy (DistrictSkeleton, BlockSkeleton, FloorZone, ChunkLayout, etc.) but ALL generation logic is stubs — no code actually produces filled instances
|
||||
- `server/src/simulation/chunk_streaming.rs`: chunk load/unload system exists but has nothing to load
|
||||
- `server/src/bin/generator_spike.rs`: spike binary, likely exploratory
|
||||
|
||||
**What this ticket must deliver:**
|
||||
1. A complete map of every generation layer from planetary heightmap to walkable tile environment: what the layer is, what generates it, what its inputs and outputs are, what exists vs. what's missing
|
||||
2. D-records in `decisions/` for each layer — at minimum one D-record defining the full pipeline, possibly per-layer records if they're complex enough
|
||||
3. Ticket dependency chain: create tickets for missing pipeline layers and set up `ticket_deps` so that character work (#694, #619) and apartment work (#681, #682) are explicitly blocked by the generation pipeline tickets
|
||||
4. Update existing stale tickets that reference premature work (e.g. #615 tycoon starting state, #616 economic verb vocabulary) — either re-scope them behind generation pipeline blockers or defer them with a note
|
||||
|
||||
**Discussion structure:**
|
||||
- Round 1 (inventory): What exists at each scale? What does each generator produce? Where are the gaps?
|
||||
- Round 2 (proposals): For each gap, what's the minimum viable generator? What are the inputs/outputs? What decisions are needed?
|
||||
- Round 3 (convergence): Lock D-records, create tickets, wire dependencies
|
||||
|
||||
**Output:** D-records in `decisions/`, ticket dependency graph, updated blockers on character/apartment tickets.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#897 (generation cascade D-records) → standalone, blocks everything downstream
|
||||
→ creates blocker tickets for #694, #619, #681, #682
|
||||
→ unblocks #899 (district skeleton server ticket)
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI:
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "plan(scope): generation pipeline cascade D-records" --description "body" --base main --head sprint-38/planning
|
||||
```
|
||||
@@ -0,0 +1,62 @@
|
||||
# Sprint 38: Depth — Server Tasks
|
||||
|
||||
**Goal:** Map the generation pipeline from planetary heightmaps to walkable tile environments, formally park character/apartment work behind the full cascade, and close test/infra debt from Sprint 37.
|
||||
|
||||
**Branch:** `sprint-38/server`
|
||||
**Agents:** Dudley (dev), Tyre (arch), Hoshe (QA)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Priority | Blocked by |
|
||||
|---|-------|----------|------------|
|
||||
| #899 | District skeleton generator — Phase 1 implementation | high | #897 |
|
||||
| #885 | Multiple baseline tests panic with Bevy Resource-does-not-exist | medium | — |
|
||||
| #892 | Expand check-systems-db-stamp to cover naming helpers | medium | — |
|
||||
| #886 | Generator polish: suffix monotony auto-fix + cultural-history prompting | low | — |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/architecture.md` — D-110 (signed z-levels), D-108 (MobileChunk)
|
||||
- Generator data model: `server/src/simulation/generator.rs` defines the full type hierarchy
|
||||
|
||||
## Notes
|
||||
|
||||
**#885 — Bevy baseline panics (fix first)**
|
||||
- 6 tests fail on clean main with `Resource<X> does not exist` panics. Confirmed not a sprint regression — present at commit f0465e40.
|
||||
- @hoshe assigned. This is the first ticket to close — other server work should not land on a broken test baseline.
|
||||
- Likely missing `.init_resource::<T>()` or `.insert_resource(T::default())` calls in test setup.
|
||||
|
||||
**#892 — Stamp expansion for naming helpers**
|
||||
- `tooling/check-systems-db-stamp` only tracks `generate_atlas.py` source. The naming pipeline (`gemma_naming.py`, `naming_core.py`) is not covered — changes to naming helpers won't trigger stale-stamp detection.
|
||||
- Add these files to the `GENERATOR_SOURCES` dict in `tooling/check-systems-db-stamp`.
|
||||
- Mirror the change in the `/pr-push` skill's source-file watch list.
|
||||
|
||||
**#886 — Generator polish (suffix monotony + cultural-history)**
|
||||
- Follow-up to Sprint 37 #853. Two partial items remain:
|
||||
- §3 suffix monotony: `gemma_naming.py` detects clustering but doesn't auto-fix. Add retry logic.
|
||||
- §6 cultural-history prompting: explicit history context in the few-shot prompt for richer names.
|
||||
|
||||
**#899 — District skeleton generator Phase 1 (BLOCKED by #897)**
|
||||
- DO NOT start until the planning ticket #897 closes and the generation cascade D-records exist.
|
||||
- The data model in `server/src/simulation/generator.rs` is complete: `DistrictSkeleton`, `BlockSkeleton`, `BlockPlacement`, `FloorZone`, `ZonePalette`, etc. All generation logic is stubs.
|
||||
- Phase 1 scope: given a city entry from `markers.json` + `planet_class` + economy node, produce a filled `DistrictSkeleton` with real block assignments, setting types, zone palettes.
|
||||
- No chunk-level tile generation yet — skeleton structure only.
|
||||
- Key integration: `chunk_streaming.rs` consumes chunk data. The skeleton feeds into chunk fill (Phase 2, future sprint).
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#885 (Bevy panics) → standalone, fix first
|
||||
#892 (stamp expansion) → standalone
|
||||
#886 (generator polish) → standalone
|
||||
#899 (district skeleton) → blocked by #897 (planning)
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI:
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(simulation): sprint 38 server" --description "body" --base main --head sprint-38/server
|
||||
```
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: "Workshop Brief"
|
||||
description: "Audit the generation pipeline implementation state, map all layers from heightmap to walkable tile, produce D-records and ticket dependency flows"
|
||||
type: workshop
|
||||
status: active
|
||||
workshop: generation-cascade
|
||||
agent: ""
|
||||
round: 0
|
||||
created: 2026-04-24
|
||||
---
|
||||
|
||||
# Generation Cascade Workshop Brief
|
||||
|
||||
**Goal:** Audit the full generation pipeline from planetary heightmaps to walkable tile environments. For each layer: document what exists, what's stub, and what's missing. Produce D-records and a ticket dependency chain that formally blocks character and apartment work behind the complete pipeline.
|
||||
|
||||
**Ticket:** #897
|
||||
**Priority:** HIGH — load-bearing for Sprint 38 and all downstream Phase 4/5 work
|
||||
**Participants:** Gestalt (systems design), Tyre (architecture/feasibility), Miri (worldbuilding/cultural inputs)
|
||||
**Source:** Lead directive (2026-04-24): "we keep cycling back to these topics. I want them parked behind the full cascade from now on."
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The generator-architecture workshop (2026-02-27) designed the full data model: DistrictSkeleton, BlockSkeleton, ChunkData, two-phase generation (Phase 1 skeleton, Phase 2 chunk fill). The outcomes are at `docs/workshops/generator-architecture/workshop-outcomes.md`.
|
||||
|
||||
**The architecture is designed. The implementation is mostly stubs.** The Rust types in `server/src/simulation/generator.rs` compile but no code actually produces filled instances. The team keeps drifting to character creation, apartment generators, and tycoon starting states — all of which sit several pipeline layers below what's actually built.
|
||||
|
||||
This workshop is NOT a redesign. It's an implementation audit and cascade formalization.
|
||||
|
||||
### What exists (confirmed working)
|
||||
|
||||
| Layer | What | Status |
|
||||
|-------|------|--------|
|
||||
| Galactic | System definitions, star data | Done — `systems.db`, wiki |
|
||||
| System | Body definitions, orbital mechanics | Done — `systems.db` |
|
||||
| Planetary surface | Heightmaps, terrain simulation | Done — `tooling/planet-gen/planet_simulation.py` |
|
||||
| Atlas markers | City placement, roads, rail, rivers, mountains | Done — `tooling/planet-gen/generate_atlas.py`, `markers.json` |
|
||||
| City naming | Gemma-driven cultural naming | Done — `tooling/planet-gen/gemma_naming.py` |
|
||||
| Economics | Supply chains, corporations, brands, trade flows | Done — `tooling/economy-db/`, `systems.db` |
|
||||
|
||||
### What exists as types only (stubs, no generation logic)
|
||||
|
||||
| Layer | What | Location |
|
||||
|-------|------|----------|
|
||||
| District skeleton | `DistrictSkeleton`, `BlockSkeleton`, enums | `server/src/simulation/generator.rs` |
|
||||
| Chunk streaming | Load/unload system | `server/src/simulation/chunk_streaming.rs` |
|
||||
| Triangle system | `TriangleAssignment`, `TrianglePurpose` | `server/src/simulation/triangle.rs` |
|
||||
|
||||
### What's missing entirely
|
||||
|
||||
This is what the workshop must map. Suspected gaps include:
|
||||
- Regional/continental subdivision (between heightmap and city)
|
||||
- City-to-district decomposition (how does a city marker become N districts?)
|
||||
- District skeleton generation (the actual Phase 1 code)
|
||||
- Block fill / zoning assignment
|
||||
- Chunk tile generation (Phase 2)
|
||||
- Infrastructure placement within districts (roads, utilities at local scale)
|
||||
- Vertical structure generation (multi-floor buildings)
|
||||
|
||||
---
|
||||
|
||||
## Key Questions to Resolve
|
||||
|
||||
### 1. Pipeline Inventory (all participants)
|
||||
|
||||
For each layer from planetary heightmap to walkable tile:
|
||||
- What is the layer's input and output?
|
||||
- What code/data exists today?
|
||||
- What's the minimum viable implementation?
|
||||
- What decisions from the generator-architecture workshop apply?
|
||||
|
||||
### 2. Layer Dependencies (Tyre)
|
||||
|
||||
- What is the strict dependency order? Which layers can be parallelized?
|
||||
- Where are the data format boundaries (file vs. runtime, Python vs. Rust)?
|
||||
- What's the testing strategy per layer? Can each layer be validated independently?
|
||||
|
||||
### 3. Cultural and Worldbuilding Inputs (Miri)
|
||||
|
||||
- At which layers do cultural inputs (society profiles, naming, architectural style) enter the pipeline?
|
||||
- What wiki/content data is needed before each layer can generate?
|
||||
- Are there content gaps that block generation even if the code existed?
|
||||
|
||||
### 4. System Interactions (Gestalt)
|
||||
|
||||
- How does each generation layer interact with the economics layer?
|
||||
- Where do social sites, NPC population, and zone palettes enter?
|
||||
- What's the minimum viable "viewable world" — the thinnest vertical slice from heightmap to rendered tiles?
|
||||
|
||||
### 5. Ticket Dependency Chain (all participants)
|
||||
|
||||
- What tickets exist for missing layers? What new tickets are needed?
|
||||
- What is the formal dependency chain that blocks character work (#694, #619) and apartment work (#681, #682)?
|
||||
- Which existing tickets (#615, #616) should be re-scoped or deferred?
|
||||
|
||||
---
|
||||
|
||||
## Workshop Format
|
||||
|
||||
**3 rounds:**
|
||||
|
||||
### Round 1 — Inventory
|
||||
Each participant audits the pipeline from their domain perspective. List every layer, its state (done / stub / missing), inputs, outputs, and the key file paths. Write findings to `docs/workshops/generation-cascade/{agent}-round1.md`.
|
||||
|
||||
### Round 2 — Proposals
|
||||
Based on the combined inventory, propose: the ordered implementation plan, the ticket dependency graph, and the D-records needed. Identify the thinnest vertical slice that produces viewable output. Write proposals to `docs/workshops/generation-cascade/{agent}-round2.md`.
|
||||
|
||||
### Round 3 — Convergence
|
||||
Lock the D-records, finalize the ticket dependency chain, and produce the formal blockers. Each participant reviews the proposed D-records and flags disagreements. Write final positions to `docs/workshops/generation-cascade/{agent}-round3.md`.
|
||||
|
||||
---
|
||||
|
||||
## Required Reading
|
||||
|
||||
Before Round 1, all participants must read:
|
||||
- `docs/workshops/generator-architecture/workshop-outcomes.md` — the designed architecture
|
||||
- `server/src/simulation/generator.rs` — the current data model (stubs)
|
||||
- `server/src/simulation/chunk_streaming.rs` — chunk load/unload system
|
||||
- `tooling/planet-gen/generate_atlas.py` — what the atlas generator produces
|
||||
- `decisions/architecture.md` — D-110 (signed z-levels), D-108 (MobileChunk)
|
||||
- CLAUDE.md §Development Cascade — the phase definitions
|
||||
|
||||
---
|
||||
|
||||
## Expected Outputs
|
||||
|
||||
1. **D-record(s)** in `decisions/` defining the generation pipeline layers, their order, and their implementation status
|
||||
2. **Ticket dependency graph** — new tickets for missing layers, `ticket_deps` entries blocking character/apartment work
|
||||
3. **Updated existing tickets** — #615, #616, #619, #681, #682, #694 re-scoped or formally blocked
|
||||
4. **Implementation priority order** — which layer to build next (informs #899 and future sprints)
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
name: The Settled Reach
|
||||
version: 0.1.37
|
||||
version: 0.2.0
|
||||
repository: settled-reach
|
||||
|
||||
|
||||
|
||||
Generated
+2
@@ -1296,9 +1296,11 @@ dependencies = [
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
"bevy_tasks",
|
||||
"bytemuck",
|
||||
"clap",
|
||||
"crossbeam-channel",
|
||||
"econ-sim",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.37"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
@@ -28,7 +28,9 @@ crossbeam-channel = "0.5"
|
||||
sysinfo = "0.35"
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
bytemuck = "1"
|
||||
toml = "0.8"
|
||||
aho-corasick = "1"
|
||||
# Economics simulation — Leontief + tâtonnement + D-180 event port (#821)
|
||||
econ-sim = { path = "../tooling/econ-sim" }
|
||||
|
||||
|
||||
@@ -175,6 +175,11 @@ CREATE TABLE IF NOT EXISTS bodies (
|
||||
cultural_corridor TEXT, -- override system corridor if different
|
||||
industrial_corridor TEXT, -- MVG, Gate_Corp, DSMC, Prometheus, Agricultural_Syndic
|
||||
|
||||
-- Physical dimensions (D-204, #905)
|
||||
-- Mean radius in km. NULL until authoritative data is available; fallback
|
||||
-- derivation from planet_class is applied at query time by the generator.
|
||||
body_radius_km REAL,
|
||||
|
||||
-- Rendering
|
||||
-- terrain_reference: repo-root-relative path to the body's heightmap PNG.
|
||||
-- Convention (enforced by populate_terrain_reference.py and assumed by
|
||||
@@ -455,6 +460,72 @@ CREATE INDEX IF NOT EXISTS idx_atlas_pois_kind ON atlas_pois(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_rivers_body ON atlas_rivers(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_oceans_body ON atlas_oceans(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_mountain_ranges_body ON atlas_mountain_ranges(body_id);
|
||||
-- Heightmap BLOB storage — float32 LE, row-major (D-202, #901)
|
||||
-- Only inhabited bodies receive rows at build time; uninhabited bodies are
|
||||
-- generated on-demand by the runtime-background tier.
|
||||
CREATE TABLE IF NOT EXISTS atlas_body_heightmaps (
|
||||
body_id TEXT PRIMARY KEY REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
width INTEGER NOT NULL DEFAULT 512,
|
||||
height INTEGER NOT NULL DEFAULT 256,
|
||||
data BLOB NOT NULL, -- float32 LE, row-major, width×height values
|
||||
sea_level REAL NOT NULL DEFAULT 0.0,
|
||||
imported_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- City name reservations — replaces authored city positions in markers.json (D-207, #902)
|
||||
-- Position is generated by the city placement algorithm; name is authored or LLM-generated.
|
||||
CREATE TABLE IF NOT EXISTS atlas_city_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'city', -- 'capital' | 'city'
|
||||
economic_role TEXT NOT NULL,
|
||||
population INTEGER NOT NULL,
|
||||
settlement_class TEXT, -- D-196 SettlementClass variant; NULL until placement
|
||||
corp_id TEXT REFERENCES corporations(corp_id), -- nullable, corp HQ if applicable
|
||||
reserved INTEGER NOT NULL DEFAULT 0, -- 1 = reserved for authored scenario use
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Geographic feature name reservations — rivers, mountains, passes (D-207 adjacent, #903)
|
||||
CREATE TABLE IF NOT EXISTS atlas_feature_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
feature_type TEXT NOT NULL, -- 'river' | 'mountain' | 'pass' | 'ocean' | 'region'
|
||||
priority INTEGER NOT NULL DEFAULT 0, -- higher = applied first during naming
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Province boundaries — watershed drainage basin polylines (D-205, #904)
|
||||
-- Pre-computed at build time from D8 drainage analysis.
|
||||
CREATE TABLE IF NOT EXISTS atlas_province_boundaries (
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
basin_id INTEGER NOT NULL,
|
||||
path TEXT NOT NULL, -- JSON array [[row, col], ...] pixel-space polyline
|
||||
area_pct REAL NOT NULL, -- fraction of body surface area in this basin
|
||||
PRIMARY KEY (body_id, basin_id)
|
||||
);
|
||||
|
||||
-- City positions — attractor-matched placement output (D-211, #34)
|
||||
-- Written at build time by the attractor-matching pipeline. Each row maps one
|
||||
-- atlas_city_names entry to its terrain position and the attractor that placed it.
|
||||
CREATE TABLE IF NOT EXISTS atlas_city_positions (
|
||||
city_names_id INTEGER PRIMARY KEY REFERENCES atlas_city_names(id) ON DELETE CASCADE,
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
row INTEGER NOT NULL, -- pixel row in heightmap grid [0, GRID_H)
|
||||
col INTEGER NOT NULL, -- pixel col in heightmap grid [0, GRID_W)
|
||||
attractor_type TEXT NOT NULL, -- AttractorType variant name
|
||||
score REAL NOT NULL -- match quality [0.0, 1.0]
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_body_heightmaps_body ON atlas_body_heightmaps(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_body ON atlas_city_names(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_kind ON atlas_city_names(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_corp ON atlas_city_names(corp_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_feature_names_body ON atlas_feature_names(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_province_boundaries_body ON atlas_province_boundaries(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_positions_body ON atlas_city_positions(body_id);
|
||||
-- END ATLAS INDEX (D-191 §8, #832)
|
||||
|
||||
-- Indexes
|
||||
@@ -481,10 +552,13 @@ CREATE INDEX IF NOT EXISTS idx_star_systems_currency ON star_systems(currency_zo
|
||||
CREATE INDEX IF NOT EXISTS idx_gate_links_from ON gate_links(from_system_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gate_links_to ON gate_links(to_system_id);
|
||||
|
||||
-- Generator metadata stamp (#855, #856)
|
||||
-- Generator metadata stamp (#855, #856, #888)
|
||||
-- One row per generator, updated on each successful non-dry-run.
|
||||
-- schema_version: SHA-1 of server/data/systems-schema.sql content at generation time
|
||||
-- generator_sha: SHA-1 of the generator source file(s) content
|
||||
-- schema_version: monotonic semver string (e.g. "1.0.0") — bump on backwards-incompatible changes.
|
||||
-- Orderable, enabling savegame migration lineage (Phase 5+).
|
||||
-- Defined as SCHEMA_VERSION constant in tooling/schema_version.py.
|
||||
-- schema_sha: SHA-1 hex of systems-schema.sql content at generation time (tamper detection).
|
||||
-- generator_sha: SHA-1 hex of the generator source file(s) content
|
||||
-- generated_at: ISO-8601 UTC timestamp of the run
|
||||
--
|
||||
-- Used by:
|
||||
@@ -492,8 +566,9 @@ CREATE INDEX IF NOT EXISTS idx_gate_links_to ON gate_links(to_system_id);
|
||||
-- .config/hooks/pre-push — rejects pushes with stale DB (#857)
|
||||
-- /pr-push skill — triggers make regen-db if stale (#858)
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
generator_name TEXT PRIMARY KEY, -- 'import_economics' | 'generate_atlas' | 'generate_brands'
|
||||
schema_version TEXT NOT NULL, -- SHA-1 hex of systems-schema.sql content
|
||||
generator_name TEXT PRIMARY KEY, -- 'import_economics' | 'generate_atlas'
|
||||
schema_version TEXT NOT NULL, -- monotonic semver string (e.g. "1.0.0") — see #888
|
||||
schema_sha TEXT, -- SHA-1 hex of systems-schema.sql content (tamper detection)
|
||||
generator_sha TEXT NOT NULL, -- SHA-1 hex of generator source file(s) content
|
||||
generated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,622 @@
|
||||
//! Attractor-matching five-phase pipeline for settlement placement (D-211).
|
||||
//!
|
||||
//! Given a body's `Vec<GeographicAttractor>` and a list of cities, assigns
|
||||
//! each city to the terrain feature that best fits its economic role and
|
||||
//! population tier.
|
||||
//!
|
||||
//! **Phases (D-211):**
|
||||
//! 1. Score matrix build: `CompatibilityMatrix[economic_role][attractor_type] × strength × (1/cost)`
|
||||
//! 2. Tier A greedy: `NameLocked` or pop ≥ 1,000,000 — assigned first, highest-score greedy.
|
||||
//! 3. Hungarian (Tier B+C): pop 50,000–999,999 cities — optimal global assignment.
|
||||
//! 4. Synthetic overflow: any remaining city gets a synthetic `PlainCenter` attractor.
|
||||
//! 5. Name fulfillment check: warn if any atlas city was not placed.
|
||||
//!
|
||||
//! **Mismatch flagging (D-211):**
|
||||
//! - score < 0.35 → WARNING
|
||||
//! - score < 0.15 → ERROR (flagged for manual review; generation continues)
|
||||
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::simulation::generator::{
|
||||
AttractorType, CompatibilityMatrix, GeographicAttractor, SettlementClass,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One city record from atlas_city_names, projected for matching.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CityRecord {
|
||||
pub city_id: u64,
|
||||
pub name: String,
|
||||
pub settlement_class: SettlementClass,
|
||||
pub population: i64,
|
||||
/// One of: manufacturing, financial, agricultural, extraction,
|
||||
/// service_mixed, institutional, transit_hub, research, military, residential.
|
||||
pub economic_role: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Result of matching one city to one attractor (real or synthetic).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CityPlacement {
|
||||
pub city_id: u64,
|
||||
pub position: (u16, u16),
|
||||
pub attractor_type: AttractorType,
|
||||
pub score: f32,
|
||||
pub synthetic: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Score matrix helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Row index in CompatibilityMatrix for an economic_role string.
|
||||
/// Order from D-195: manufacturing(0), financial(1), agricultural(2), extraction(3),
|
||||
/// service_mixed(4), institutional(5), transit_hub(6), research(7), military(8), residential(9).
|
||||
fn role_row(economic_role: &str) -> usize {
|
||||
match economic_role {
|
||||
"manufacturing" => 0,
|
||||
"financial" => 1,
|
||||
"agricultural" => 2,
|
||||
"extraction" => 3,
|
||||
"service_mixed" => 4,
|
||||
"institutional" => 5,
|
||||
"transit_hub" => 6,
|
||||
"research" => 7,
|
||||
"military" => 8,
|
||||
_ => 9,
|
||||
}
|
||||
}
|
||||
|
||||
/// Column index in CompatibilityMatrix for an AttractorType.
|
||||
/// Order from D-195: RiverMouth(0), CoastalAccess(1), RiverCrossing(2), ValleyFloor(3),
|
||||
/// PassEntrance(4), LakeShore(5), PlainCenter(6).
|
||||
fn attractor_col(at: &AttractorType) -> usize {
|
||||
match at {
|
||||
AttractorType::RiverMouth => 0,
|
||||
AttractorType::CoastalAccess => 1,
|
||||
AttractorType::RiverCrossing => 2,
|
||||
AttractorType::ValleyFloor => 3,
|
||||
AttractorType::PassEntrance => 4,
|
||||
AttractorType::LakeShore => 5,
|
||||
AttractorType::PlainCenter => 6,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the raw match score between a city and an attractor.
|
||||
/// Score = matrix_weight × attractor.strength × (1.0 / terrain_modification_cost).
|
||||
fn cell_score(
|
||||
city: &CityRecord,
|
||||
attractor: &GeographicAttractor,
|
||||
matrix: &CompatibilityMatrix,
|
||||
terrain_cost: f32,
|
||||
) -> f32 {
|
||||
let row = role_row(&city.economic_role);
|
||||
let col = attractor_col(&attractor.attractor_type);
|
||||
let weight = matrix.weights[row][col];
|
||||
let cost_factor = if terrain_cost > 0.0 {
|
||||
1.0 / terrain_cost
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
weight * attractor.strength * cost_factor
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3: Hungarian algorithm (minimization)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// O(n³) Hungarian algorithm for assignment problem.
|
||||
///
|
||||
/// Input: `cost[i][j]` — cost of assigning task j to worker i.
|
||||
/// Lower cost = better fit. Converts the maximization problem to minimization
|
||||
/// by using `max_score - score` as cost.
|
||||
///
|
||||
/// Returns `assignment[i] = j` for each row i.
|
||||
fn hungarian(cost: &[Vec<f32>]) -> Vec<usize> {
|
||||
let n = cost.len();
|
||||
if n == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let m = cost[0].len();
|
||||
if m == 0 {
|
||||
return vec![usize::MAX; n];
|
||||
}
|
||||
|
||||
// Pad to square n×n if m < n (more cities than attractors handled by overflow).
|
||||
let sz = n.max(m);
|
||||
let mut c: Vec<Vec<f32>> = vec![vec![0.0; sz]; sz];
|
||||
for i in 0..n {
|
||||
for j in 0..m {
|
||||
c[i][j] = cost[i][j];
|
||||
}
|
||||
// Pad extra columns with high cost so overflow cities pick them last.
|
||||
for item in c[i].iter_mut().take(sz).skip(m) {
|
||||
*item = f32::MAX / 2.0;
|
||||
}
|
||||
}
|
||||
// Pad extra rows with 0 cost (dummy workers).
|
||||
// Already initialized to 0.
|
||||
|
||||
// Standard O(n³) Hungarian.
|
||||
let inf = f32::MAX / 2.0;
|
||||
let mut u = vec![0.0f32; sz + 1];
|
||||
let mut v = vec![0.0f32; sz + 1];
|
||||
let mut p = vec![0usize; sz + 1]; // p[j] = row assigned to column j (1-indexed)
|
||||
let mut way = vec![0usize; sz + 1];
|
||||
|
||||
for i in 1..=sz {
|
||||
p[0] = i;
|
||||
let mut j0 = 0usize;
|
||||
let mut minv = vec![inf; sz + 1];
|
||||
let mut used = vec![false; sz + 1];
|
||||
loop {
|
||||
used[j0] = true;
|
||||
let i0 = p[j0];
|
||||
let mut delta = inf;
|
||||
let mut j1 = 0usize;
|
||||
for j in 1..=sz {
|
||||
if used[j] {
|
||||
continue;
|
||||
}
|
||||
let cur = c[i0 - 1][j - 1] - u[i0] - v[j];
|
||||
if cur < minv[j] {
|
||||
minv[j] = cur;
|
||||
way[j] = j0;
|
||||
}
|
||||
if minv[j] < delta {
|
||||
delta = minv[j];
|
||||
j1 = j;
|
||||
}
|
||||
}
|
||||
for j in 0..=sz {
|
||||
if used[j] {
|
||||
u[p[j]] += delta;
|
||||
v[j] -= delta;
|
||||
} else {
|
||||
minv[j] -= delta;
|
||||
}
|
||||
}
|
||||
j0 = j1;
|
||||
if p[j0] == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
loop {
|
||||
let j1 = way[j0];
|
||||
p[j0] = p[j1];
|
||||
j0 = j1;
|
||||
if j0 == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract assignment: for each row i (1-indexed), find column j where p[j] == i.
|
||||
let mut result = vec![usize::MAX; n];
|
||||
for j in 1..=sz {
|
||||
if p[j] > 0 && p[j] <= n {
|
||||
let col = j - 1;
|
||||
if col < m {
|
||||
result[p[j] - 1] = col;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Synthetic PlainCenter placement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Minimum pixel separation between synthetic attractor positions.
|
||||
const MIN_SPACING: u16 = 15;
|
||||
|
||||
fn synthetic_attractor(placed: &[CityPlacement], grid_w: u32, grid_h: u32) -> GeographicAttractor {
|
||||
// Place at grid center as default, then walk until spacing is satisfied.
|
||||
let mut row = (grid_h / 2) as u16;
|
||||
let mut col = (grid_w / 4) as u16;
|
||||
|
||||
// Simple search: try positions in a grid until spacing is met.
|
||||
'outer: for dr in 0..(grid_h as u16 / MIN_SPACING) {
|
||||
for dc in 0..(grid_w as u16 / MIN_SPACING) {
|
||||
let r = (dr * MIN_SPACING).min(grid_h as u16 - 1);
|
||||
let c = (dc * MIN_SPACING).min(grid_w as u16 - 1);
|
||||
let ok = placed.iter().all(|p| {
|
||||
let dr2 = (p.position.0 as i32 - r as i32).unsigned_abs() as u16;
|
||||
let dc2 = (p.position.1 as i32 - c as i32).unsigned_abs() as u16;
|
||||
dr2.max(dc2) >= MIN_SPACING
|
||||
});
|
||||
if ok {
|
||||
row = r;
|
||||
col = c;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GeographicAttractor {
|
||||
position: (row, col),
|
||||
attractor_type: AttractorType::PlainCenter,
|
||||
strength: 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run the five-phase attractor-matching pipeline (D-211).
|
||||
///
|
||||
/// `terrain_costs` maps attractor index → terrain_modification_cost (1.0 = baseline).
|
||||
/// If `None`, all costs default to 1.0.
|
||||
pub fn match_cities(
|
||||
cities: &[CityRecord],
|
||||
attractors: &[GeographicAttractor],
|
||||
matrix: &CompatibilityMatrix,
|
||||
terrain_costs: Option<&[f32]>,
|
||||
grid_w: u32,
|
||||
grid_h: u32,
|
||||
) -> Vec<CityPlacement> {
|
||||
let default_cost = vec![1.0f32; attractors.len()];
|
||||
let costs = terrain_costs.unwrap_or(&default_cost);
|
||||
|
||||
let mut placements: Vec<CityPlacement> = Vec::with_capacity(cities.len());
|
||||
let mut used_attractors: Vec<bool> = vec![false; attractors.len()];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Phase 1: Score matrix
|
||||
// -------------------------------------------------------------------------
|
||||
let scores: Vec<Vec<f32>> = cities
|
||||
.iter()
|
||||
.map(|city| {
|
||||
attractors
|
||||
.iter()
|
||||
.zip(costs.iter())
|
||||
.map(|(att, &cost)| cell_score(city, att, matrix, cost))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Phase 2: Tier A greedy — NameLocked or pop ≥ 1_000_000
|
||||
// -------------------------------------------------------------------------
|
||||
let tier_a_indices: Vec<usize> = cities
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| {
|
||||
c.settlement_class == SettlementClass::NameLocked || c.population >= 1_000_000
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
for &ci in &tier_a_indices {
|
||||
if attractors.is_empty() {
|
||||
break;
|
||||
}
|
||||
// Highest-scoring unused attractor.
|
||||
let best = scores[ci]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(ai, _)| !used_attractors[*ai])
|
||||
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
if let Some((ai, &score)) = best {
|
||||
used_attractors[ai] = true;
|
||||
flag_mismatch(&cities[ci].name, score);
|
||||
placements.push(CityPlacement {
|
||||
city_id: cities[ci].city_id,
|
||||
position: attractors[ai].position,
|
||||
attractor_type: attractors[ai].attractor_type.clone(),
|
||||
score,
|
||||
synthetic: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Phase 3: Hungarian — Tier B+C (50,000–999,999)
|
||||
// -------------------------------------------------------------------------
|
||||
let tier_bc_indices: Vec<usize> = cities
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, c)| {
|
||||
!tier_a_indices.contains(i) && c.population >= 50_000 && c.population < 1_000_000
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
let free_attractors: Vec<usize> = (0..attractors.len())
|
||||
.filter(|&ai| !used_attractors[ai])
|
||||
.collect();
|
||||
|
||||
if !tier_bc_indices.is_empty() && !free_attractors.is_empty() {
|
||||
// Build cost sub-matrix (maximization → minimization via complement).
|
||||
let scores_ref = &scores;
|
||||
let max_score: f32 = tier_bc_indices
|
||||
.iter()
|
||||
.flat_map(|&ci| free_attractors.iter().map(move |&ai| scores_ref[ci][ai]))
|
||||
.fold(0.0f32, f32::max);
|
||||
|
||||
let cost: Vec<Vec<f32>> = tier_bc_indices
|
||||
.iter()
|
||||
.map(|&ci| {
|
||||
free_attractors
|
||||
.iter()
|
||||
.map(|&ai| max_score - scores_ref[ci][ai])
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let assignment = hungarian(&cost);
|
||||
|
||||
for (local_i, &ci) in tier_bc_indices.iter().enumerate() {
|
||||
let local_j = assignment[local_i];
|
||||
if local_j == usize::MAX || local_j >= free_attractors.len() {
|
||||
continue; // overflow — handled in phase 4
|
||||
}
|
||||
let ai = free_attractors[local_j];
|
||||
let score = scores[ci][ai];
|
||||
used_attractors[ai] = true;
|
||||
flag_mismatch(&cities[ci].name, score);
|
||||
placements.push(CityPlacement {
|
||||
city_id: cities[ci].city_id,
|
||||
position: attractors[ai].position,
|
||||
attractor_type: attractors[ai].attractor_type.clone(),
|
||||
score,
|
||||
synthetic: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Phase 4: Synthetic overflow — all remaining cities
|
||||
// -------------------------------------------------------------------------
|
||||
let placed_ids: std::collections::BTreeSet<u64> =
|
||||
placements.iter().map(|p| p.city_id).collect();
|
||||
|
||||
for city in cities {
|
||||
if placed_ids.contains(&city.city_id) {
|
||||
continue;
|
||||
}
|
||||
let synthetic = synthetic_attractor(&placements, grid_w, grid_h);
|
||||
let score = cell_score(city, &synthetic, matrix, 1.0);
|
||||
flag_mismatch(&city.name, score);
|
||||
placements.push(CityPlacement {
|
||||
city_id: city.city_id,
|
||||
position: synthetic.position,
|
||||
attractor_type: AttractorType::PlainCenter,
|
||||
score,
|
||||
synthetic: true,
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Phase 5: Name fulfillment check
|
||||
// -------------------------------------------------------------------------
|
||||
let placed_ids: std::collections::BTreeSet<u64> =
|
||||
placements.iter().map(|p| p.city_id).collect();
|
||||
for city in cities {
|
||||
if !placed_ids.contains(&city.city_id) {
|
||||
warn!(
|
||||
city = %city.name,
|
||||
city_id = city.city_id,
|
||||
"atlas city was not placed — missing from pipeline output"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
placements
|
||||
}
|
||||
|
||||
fn flag_mismatch(city_name: &str, score: f32) {
|
||||
if score < 0.15 {
|
||||
error!(
|
||||
city = %city_name,
|
||||
score,
|
||||
"attractor mismatch score < 0.15 — flagged for manual review"
|
||||
);
|
||||
} else if score < 0.35 {
|
||||
warn!(
|
||||
city = %city_name,
|
||||
score,
|
||||
"attractor mismatch score < 0.35 — below expected quality"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FoundingOrientation derivation from matched attractor (D-211, D-213)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use crate::simulation::generator::FoundingOrientation;
|
||||
use crate::simulation::generator::TerritorialStatus;
|
||||
|
||||
/// Derive `FoundingOrientation` from the attractor type that anchored the city (D-211, D-213).
|
||||
///
|
||||
/// `river_bearing` and `coastal_facing` are compass degrees 0–359.
|
||||
/// Pass 0 as default when the terrain doesn't dictate a specific bearing.
|
||||
pub fn founding_orientation(
|
||||
attractor_type: &AttractorType,
|
||||
territorial_status: &TerritorialStatus,
|
||||
river_bearing: u16,
|
||||
coastal_facing: u16,
|
||||
) -> FoundingOrientation {
|
||||
match attractor_type {
|
||||
AttractorType::RiverMouth | AttractorType::CoastalAccess => FoundingOrientation::Coastal {
|
||||
facing_degrees: coastal_facing,
|
||||
},
|
||||
AttractorType::RiverCrossing => FoundingOrientation::RiverAligned {
|
||||
bearing_degrees: river_bearing,
|
||||
},
|
||||
AttractorType::ValleyFloor => FoundingOrientation::TerrainFollowing,
|
||||
AttractorType::PlainCenter => {
|
||||
if matches!(territorial_status, TerritorialStatus::CommissionControlled) {
|
||||
FoundingOrientation::Cardinal
|
||||
} else {
|
||||
FoundingOrientation::Free { bearing_degrees: 0 }
|
||||
}
|
||||
}
|
||||
AttractorType::PassEntrance | AttractorType::LakeShore => {
|
||||
FoundingOrientation::TerrainFollowing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor};
|
||||
|
||||
fn uniform_matrix() -> CompatibilityMatrix {
|
||||
CompatibilityMatrix {
|
||||
weights: [[1.0; 7]; 10],
|
||||
}
|
||||
}
|
||||
|
||||
fn make_attractor(row: u16, col: u16, at: AttractorType, strength: f32) -> GeographicAttractor {
|
||||
GeographicAttractor {
|
||||
position: (row, col),
|
||||
attractor_type: at,
|
||||
strength,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_city(id: u64, class: SettlementClass, pop: i64) -> CityRecord {
|
||||
CityRecord {
|
||||
city_id: id,
|
||||
name: format!("City{id}"),
|
||||
settlement_class: class,
|
||||
population: pop,
|
||||
economic_role: "manufacturing".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_city_single_attractor() {
|
||||
let cities = vec![make_city(1, SettlementClass::NameLocked, 500_000)];
|
||||
let attractors = vec![make_attractor(10, 20, AttractorType::RiverMouth, 0.8)];
|
||||
let matrix = uniform_matrix();
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
assert_eq!(placements.len(), 1);
|
||||
assert_eq!(placements[0].city_id, 1);
|
||||
assert_eq!(placements[0].position, (10, 20));
|
||||
assert!(!placements[0].synthetic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_a_gets_priority() {
|
||||
// NameLocked city should get the best attractor (high strength).
|
||||
let cities = vec![
|
||||
make_city(1, SettlementClass::NameLocked, 100_000),
|
||||
make_city(2, SettlementClass::PopulationBudget, 200_000),
|
||||
];
|
||||
let attractors = vec![
|
||||
make_attractor(5, 5, AttractorType::RiverMouth, 0.9), // best
|
||||
make_attractor(10, 10, AttractorType::ValleyFloor, 0.4), // second
|
||||
];
|
||||
let matrix = uniform_matrix();
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
let p1 = placements.iter().find(|p| p.city_id == 1).unwrap();
|
||||
assert_eq!(p1.position, (5, 5), "NameLocked should get best attractor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_produces_synthetic() {
|
||||
// 2 cities, 1 attractor → second city gets synthetic.
|
||||
let cities = vec![
|
||||
make_city(1, SettlementClass::NameLocked, 2_000_000),
|
||||
make_city(2, SettlementClass::PopulationBudget, 60_000),
|
||||
];
|
||||
let attractors = vec![make_attractor(0, 0, AttractorType::RiverMouth, 1.0)];
|
||||
let matrix = uniform_matrix();
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
assert_eq!(placements.len(), 2);
|
||||
let p2 = placements.iter().find(|p| p.city_id == 2).unwrap();
|
||||
assert!(p2.synthetic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_cities_placed() {
|
||||
let cities: Vec<CityRecord> = (1..=5)
|
||||
.map(|i| make_city(i, SettlementClass::PopulationBudget, 100_000))
|
||||
.collect();
|
||||
let attractors = vec![
|
||||
make_attractor(10, 10, AttractorType::RiverMouth, 0.9),
|
||||
make_attractor(20, 20, AttractorType::CoastalAccess, 0.7),
|
||||
];
|
||||
let matrix = uniform_matrix();
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
assert_eq!(placements.len(), 5, "all cities must be placed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hungarian_assigns_optimally() {
|
||||
// 2 cities, 2 attractors. City A scores best on attractor 0, city B best on attractor 1.
|
||||
let mut matrix = uniform_matrix();
|
||||
// agricultural (row 2) scores high on ValleyFloor (col 3) = 3.0
|
||||
matrix.weights[2][3] = 3.0;
|
||||
// transit_hub (row 6) scores high on RiverCrossing (col 2) = 3.0
|
||||
matrix.weights[6][2] = 3.0;
|
||||
let cities = vec![
|
||||
CityRecord {
|
||||
city_id: 1,
|
||||
name: "Farm".to_string(),
|
||||
settlement_class: SettlementClass::PopulationBudget,
|
||||
population: 60_000,
|
||||
economic_role: "agricultural".to_string(),
|
||||
},
|
||||
CityRecord {
|
||||
city_id: 2,
|
||||
name: "Hub".to_string(),
|
||||
settlement_class: SettlementClass::PopulationBudget,
|
||||
population: 80_000,
|
||||
economic_role: "transit_hub".to_string(),
|
||||
},
|
||||
];
|
||||
let attractors = vec![
|
||||
make_attractor(5, 5, AttractorType::ValleyFloor, 1.0),
|
||||
make_attractor(10, 10, AttractorType::RiverCrossing, 1.0),
|
||||
];
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
assert_eq!(placements.len(), 2);
|
||||
let farm = placements.iter().find(|p| p.city_id == 1).unwrap();
|
||||
let hub = placements.iter().find(|p| p.city_id == 2).unwrap();
|
||||
// Farm should be on ValleyFloor (5,5), Hub on RiverCrossing (10,10).
|
||||
assert_eq!(farm.position, (5, 5));
|
||||
assert_eq!(hub.position, (10, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn founding_orientation_from_attractor() {
|
||||
use crate::simulation::generator::TerritorialStatus;
|
||||
let status = TerritorialStatus::FrontierUnclaimed;
|
||||
let o = founding_orientation(&AttractorType::RiverMouth, &status, 90, 270);
|
||||
assert!(matches!(
|
||||
o,
|
||||
FoundingOrientation::Coastal {
|
||||
facing_degrees: 270
|
||||
}
|
||||
));
|
||||
|
||||
let o2 = founding_orientation(
|
||||
&AttractorType::PlainCenter,
|
||||
&TerritorialStatus::CommissionControlled,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
assert!(matches!(o2, FoundingOrientation::Cardinal));
|
||||
|
||||
let o3 = founding_orientation(&AttractorType::ValleyFloor, &status, 0, 0);
|
||||
assert!(matches!(o3, FoundingOrientation::TerrainFollowing));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! BlockIrregularity derivation from founding_age and PoliticalArchetype (D-216).
|
||||
//!
|
||||
//! `block_irregularity` (0.0–1.0) controls how much a block deviates from
|
||||
//! the district's canonical grid. Minimum 0.05 — no block is perfectly regular.
|
||||
//!
|
||||
//! **Formula (D-216):**
|
||||
//! ```text
|
||||
//! base_irregularity = (founding_age_years / 1000.0).min(1.0)
|
||||
//! archetype_step = Commission|Military → -0.3, Corporate|Academic → -0.1,
|
||||
//! Industrial → 0.0, Pioneer → +0.3
|
||||
//! block_irregularity = (base + step).clamp(0.05, 1.0)
|
||||
//! ```
|
||||
//!
|
||||
//! **Determinism (D-010, D-216):** Integer-scaled intermediates; archetype_step
|
||||
//! stored as basis points (i32, 1 bp = 0.001). Final result is f32 from integer
|
||||
//! arithmetic to match the D-216 formula.
|
||||
|
||||
use crate::simulation::generator::PoliticalArchetype;
|
||||
|
||||
/// Compute the `block_irregularity` value for one block.
|
||||
///
|
||||
/// - `founding_age_years`: years since the settlement was founded (integer).
|
||||
/// - `archetype`: the settlement's political archetype.
|
||||
///
|
||||
/// Returns a value in [0.05, 1.0].
|
||||
pub fn block_irregularity(founding_age_years: u32, archetype: &PoliticalArchetype) -> f32 {
|
||||
// base_irregularity in integer basis-points (0–1000, where 1000 = 1.0).
|
||||
let base_bp: i32 = (founding_age_years as i32).min(1000);
|
||||
|
||||
// archetype_step in basis-points.
|
||||
let step_bp: i32 = archetype_step_bp(archetype);
|
||||
|
||||
// block_irregularity_bp clamped to [50, 1000] (0.05–1.0).
|
||||
let result_bp = (base_bp + step_bp).clamp(50, 1000);
|
||||
|
||||
result_bp as f32 / 1000.0
|
||||
}
|
||||
|
||||
fn archetype_step_bp(archetype: &PoliticalArchetype) -> i32 {
|
||||
match archetype {
|
||||
PoliticalArchetype::Commission | PoliticalArchetype::Military => -300,
|
||||
PoliticalArchetype::Corporate | PoliticalArchetype::Academic => -100,
|
||||
PoliticalArchetype::Industrial => 0,
|
||||
PoliticalArchetype::Pioneer => 300,
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the maximum block offset in sim tiles from `block_irregularity`.
|
||||
///
|
||||
/// Used by `DistrictLayoutMode::Organic`: `max_offset = (irregularity × 16.0) as i16`.
|
||||
pub fn max_offset_sim_tiles(irregularity: f32) -> i16 {
|
||||
(irregularity * 16.0) as i16
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn minimum_is_0_05() {
|
||||
// New Commission city (age 0) → base 0, step -300 → clamp to 50bp = 0.05.
|
||||
let v = block_irregularity(0, &PoliticalArchetype::Commission);
|
||||
assert!((v - 0.05).abs() < 1e-6, "expected 0.05, got {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_is_1_0() {
|
||||
// Old Pioneer city (age 1000+) → base 1000, step +300 → clamp to 1000bp = 1.0.
|
||||
let v = block_irregularity(1500, &PoliticalArchetype::Pioneer);
|
||||
assert!((v - 1.0).abs() < 1e-6, "expected 1.0, got {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pioneer_more_irregular_than_commission() {
|
||||
let pioneer = block_irregularity(400, &PoliticalArchetype::Pioneer);
|
||||
let commission = block_irregularity(400, &PoliticalArchetype::Commission);
|
||||
assert!(
|
||||
pioneer > commission,
|
||||
"Pioneer ({pioneer}) should be more irregular than Commission ({commission})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn age_increases_irregularity() {
|
||||
let young = block_irregularity(50, &PoliticalArchetype::Industrial);
|
||||
let old = block_irregularity(800, &PoliticalArchetype::Industrial);
|
||||
assert!(
|
||||
old > young,
|
||||
"Older settlement ({old}) should be more irregular than young ({young})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_offset_scales_with_irregularity() {
|
||||
assert_eq!(max_offset_sim_tiles(0.05), 0); // 0.05 × 16 = 0.8 → 0
|
||||
assert_eq!(max_offset_sim_tiles(1.0), 16);
|
||||
assert_eq!(max_offset_sim_tiles(0.5), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_archetypes_produce_valid_range() {
|
||||
let archetypes = [
|
||||
PoliticalArchetype::Commission,
|
||||
PoliticalArchetype::Corporate,
|
||||
PoliticalArchetype::Pioneer,
|
||||
PoliticalArchetype::Military,
|
||||
PoliticalArchetype::Academic,
|
||||
PoliticalArchetype::Industrial,
|
||||
];
|
||||
for a in &archetypes {
|
||||
let v = block_irregularity(300, a);
|
||||
assert!(
|
||||
v >= 0.05 && v <= 1.0,
|
||||
"archetype {:?} gave {v} out of [0.05, 1.0]",
|
||||
a
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
//! BodyWorldState — per-body Layer 1–2 cache (D-203).
|
||||
//!
|
||||
//! `BodyWorldStateCache` is a Bevy `Resource` holding pre-computed generation
|
||||
//! data for up to 50 planetary bodies. Populated by the runtime-background
|
||||
//! tier (D-206) via Rayon tasks; read by the main tick thread without blocking.
|
||||
//!
|
||||
//! Eviction policy: LRU — the body with the oldest `last_accessed` tick is
|
||||
//! evicted on overflow, unless it is pinned (current player location or an
|
||||
//! adjacent-system neighbor).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use bevy_ecs::prelude::Resource;
|
||||
|
||||
use crate::simulation::generator::GeographicAttractor;
|
||||
|
||||
/// Simulation tick counter — monotonically increasing u64.
|
||||
pub type SimTick = u64;
|
||||
|
||||
/// Maximum number of bodies the cache holds before evicting the LRU entry.
|
||||
pub const CACHE_CAPACITY: usize = 50;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub types — filled in by D-208 (#918) and D-205 (#907 Rust side)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// River network extracted by the D8 drainage algorithm (D-208).
|
||||
/// Stub — replaced when #918 is implemented.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RiverNetwork {
|
||||
/// Pixel positions (row, col) of all river cells (flow_accumulation > 200).
|
||||
pub river_cells: Vec<(u16, u16)>,
|
||||
/// Positions where two or more rivers merge.
|
||||
pub confluences: Vec<(u16, u16)>,
|
||||
/// Positions where rivers reach sea level or the heightmap edge.
|
||||
pub mouths: Vec<(u16, u16)>,
|
||||
}
|
||||
|
||||
/// One drainage basin / province derived from watershed analysis (D-205).
|
||||
/// Stub — boundary polyline data comes from atlas_province_boundaries.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DrainageBasin {
|
||||
pub basin_id: u32,
|
||||
/// Boundary polyline as pixel-space (row, col) points.
|
||||
pub boundary: Vec<(u16, u16)>,
|
||||
/// Fraction of the body's surface area in this basin.
|
||||
pub area_pct: f32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BodyWorldState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pre-computed Layer 1–2 generation data for one planetary body.
|
||||
///
|
||||
/// Produced by the runtime-background tier and stored in `BodyWorldStateCache`.
|
||||
/// The main tick thread reads this data without performing any DB or CPU work.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BodyWorldState {
|
||||
pub body_id: String,
|
||||
/// Downsampled working elevation grid (float32, row-major).
|
||||
/// Full-resolution data lives in atlas_body_heightmaps; this is reduced
|
||||
/// for the ~8KB working-resolution budget described in D-203.
|
||||
pub heightmap: Vec<f32>,
|
||||
pub heightmap_width: u32,
|
||||
pub heightmap_height: u32,
|
||||
/// D8 drainage analysis output (D-208). Empty until drainage task completes.
|
||||
pub river_network: RiverNetwork,
|
||||
/// Drainage basins from watershed analysis (D-205).
|
||||
pub drainage_basins: Vec<DrainageBasin>,
|
||||
/// Geographic attractors (D-195, D-209). Empty until attractor task completes.
|
||||
pub attractors: Vec<GeographicAttractor>,
|
||||
/// Last sim tick this entry was read. Used for LRU eviction.
|
||||
pub last_accessed: SimTick,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BodyWorldStateCache — Bevy Resource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bevy `Resource` holding the LRU cache of per-body world state (D-203).
|
||||
///
|
||||
/// Initialized empty at server startup. Entries are inserted by the
|
||||
/// background generation queue (D-206) and read by main-thread systems.
|
||||
///
|
||||
/// All mutations go through the provided methods to maintain the
|
||||
/// invariant that `entries.len() <= capacity`.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct BodyWorldStateCache {
|
||||
entries: BTreeMap<String, BodyWorldState>,
|
||||
/// Body IDs that must not be evicted regardless of `last_accessed`.
|
||||
pinned: BTreeSet<String>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl BodyWorldStateCache {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
entries: BTreeMap::new(),
|
||||
pinned: BTreeSet::new(),
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert or replace a `BodyWorldState` entry.
|
||||
///
|
||||
/// If the cache is at capacity, evicts the LRU unpinned entry before
|
||||
/// inserting. If all entries are pinned and the cache is full, the new
|
||||
/// entry is inserted anyway (capacity is a soft limit against unbounded
|
||||
/// growth, not a hard reject).
|
||||
pub fn insert(&mut self, state: BodyWorldState) {
|
||||
if self.entries.len() >= self.capacity && !self.entries.contains_key(&state.body_id) {
|
||||
self.evict_lru();
|
||||
}
|
||||
self.entries.insert(state.body_id.clone(), state);
|
||||
}
|
||||
|
||||
/// Get a reference to the state for `body_id`, bumping `last_accessed`.
|
||||
pub fn get(&mut self, body_id: &str, current_tick: SimTick) -> Option<&BodyWorldState> {
|
||||
if let Some(entry) = self.entries.get_mut(body_id) {
|
||||
entry.last_accessed = current_tick;
|
||||
}
|
||||
self.entries.get(body_id)
|
||||
}
|
||||
|
||||
/// Get a reference without bumping `last_accessed` (read-only path).
|
||||
pub fn peek(&self, body_id: &str) -> Option<&BodyWorldState> {
|
||||
self.entries.get(body_id)
|
||||
}
|
||||
|
||||
/// Returns `true` if the cache has an entry for `body_id`.
|
||||
pub fn contains(&self, body_id: &str) -> bool {
|
||||
self.entries.contains_key(body_id)
|
||||
}
|
||||
|
||||
/// Pin `body_id` — exempt from LRU eviction.
|
||||
pub fn pin(&mut self, body_id: &str) {
|
||||
self.pinned.insert(body_id.to_string());
|
||||
}
|
||||
|
||||
/// Unpin `body_id` — allow eviction again.
|
||||
pub fn unpin(&mut self, body_id: &str) {
|
||||
self.pinned.remove(body_id);
|
||||
}
|
||||
|
||||
/// Number of entries currently in the cache.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
fn evict_lru(&mut self) {
|
||||
// Find the unpinned entry with the smallest last_accessed tick.
|
||||
let victim = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|(id, _)| !self.pinned.contains(*id))
|
||||
.min_by_key(|(_, s)| s.last_accessed)
|
||||
.map(|(id, _)| id.clone());
|
||||
|
||||
if let Some(id) = victim {
|
||||
self.entries.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[allow(unused_imports)]
|
||||
use crate::simulation::generator::GeographicAttractor;
|
||||
|
||||
fn make_state(body_id: &str, tick: SimTick) -> BodyWorldState {
|
||||
BodyWorldState {
|
||||
body_id: body_id.to_string(),
|
||||
heightmap: vec![0.5; 16],
|
||||
heightmap_width: 4,
|
||||
heightmap_height: 4,
|
||||
river_network: RiverNetwork::default(),
|
||||
drainage_basins: vec![],
|
||||
attractors: vec![],
|
||||
last_accessed: tick,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get() {
|
||||
let mut cache = BodyWorldStateCache::new(50);
|
||||
cache.insert(make_state("Alpha", 1));
|
||||
assert!(cache.contains("Alpha"));
|
||||
assert!(!cache.contains("Beta"));
|
||||
let entry = cache.get("Alpha", 5).unwrap();
|
||||
assert_eq!(entry.body_id, "Alpha");
|
||||
assert_eq!(entry.last_accessed, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evicts_lru_on_overflow() {
|
||||
let mut cache = BodyWorldStateCache::new(3);
|
||||
cache.insert(make_state("A", 10));
|
||||
cache.insert(make_state("B", 20));
|
||||
cache.insert(make_state("C", 30));
|
||||
// Cache is full; inserting D should evict A (oldest tick = 10).
|
||||
cache.insert(make_state("D", 40));
|
||||
assert_eq!(cache.len(), 3);
|
||||
assert!(!cache.contains("A"), "A should have been evicted");
|
||||
assert!(cache.contains("B"));
|
||||
assert!(cache.contains("C"));
|
||||
assert!(cache.contains("D"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_body_not_evicted() {
|
||||
let mut cache = BodyWorldStateCache::new(3);
|
||||
cache.insert(make_state("A", 10));
|
||||
cache.insert(make_state("B", 20));
|
||||
cache.insert(make_state("C", 30));
|
||||
// Pin A so it cannot be evicted.
|
||||
cache.pin("A");
|
||||
// Inserting D must evict B (oldest unpinned).
|
||||
cache.insert(make_state("D", 40));
|
||||
assert!(cache.contains("A"), "pinned A must not be evicted");
|
||||
assert!(!cache.contains("B"), "B should have been evicted instead");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_last_accessed_on_get() {
|
||||
let mut cache = BodyWorldStateCache::new(3);
|
||||
cache.insert(make_state("A", 1));
|
||||
cache.insert(make_state("B", 2));
|
||||
cache.insert(make_state("C", 3));
|
||||
// Cache is full. Get A at tick 100 — bumps its last_accessed above C and B.
|
||||
cache.get("A", 100);
|
||||
// Insert D to trigger eviction; B (tick 2) is now LRU, not A (tick 100).
|
||||
cache.insert(make_state("D", 4));
|
||||
assert!(
|
||||
cache.contains("A"),
|
||||
"A was recently accessed — must survive"
|
||||
);
|
||||
assert!(
|
||||
!cache.contains("B"),
|
||||
"B had oldest access time — should be evicted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_capacity_is_zero() {
|
||||
// Default resource starts empty.
|
||||
let cache = BodyWorldStateCache::default();
|
||||
assert!(cache.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
//! Three-component district mix algorithm for city district type distribution (D-194).
|
||||
//!
|
||||
//! Given a city's population, economic role, and political archetype, produces
|
||||
//! a district type distribution (count of each DistrictType) used by the
|
||||
//! Phase 1 district skeleton generator.
|
||||
//!
|
||||
//! **Components (D-194):**
|
||||
//! 1. Population tier guarantees — minimum district counts by city size.
|
||||
//! 2. 10×9 economic multiplier table — economic role × DistrictType weights.
|
||||
//! 3. Political archetype modifiers — shift weights for specific district types.
|
||||
//!
|
||||
//! **Determinism (D-010, D-194):** Integer weights throughout. No f32 in the
|
||||
//! district count computation. Seed-driven noise uses seeded RNG.
|
||||
|
||||
use crate::atlas::rng::AtlasRng;
|
||||
use crate::simulation::generator::{DistrictType, PoliticalArchetype};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Population tier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Population tier: `floor(log10(pop / 1_000_000))`, capped at [0, 5].
|
||||
pub fn population_tier(population: i64) -> u8 {
|
||||
if population <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let ratio = population as f64 / 1_000_000.0;
|
||||
if ratio <= 0.0 {
|
||||
return 0;
|
||||
}
|
||||
let tier = ratio.log10().floor() as i32;
|
||||
tier.clamp(0, 5) as u8
|
||||
}
|
||||
|
||||
/// Minimum district counts guaranteed by population tier (D-194).
|
||||
///
|
||||
/// Returns `(transit_min, commercial_min, residential_min)`.
|
||||
pub fn tier_guarantees(tier: u8) -> (u32, u32, u32) {
|
||||
match tier {
|
||||
0 => (0, 0, 1),
|
||||
1 => (0, 1, 1),
|
||||
2 => (1, 1, 2),
|
||||
3 => (1, 2, 3),
|
||||
4 => (2, 3, 4),
|
||||
5 => (3, 4, 6),
|
||||
_ => (3, 4, 6),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Economic multiplier table (10×9, integer weights × 10 for precision)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// District type column order (0–8).
|
||||
/// Matches DistrictType enum variants: LogisticsHub, Residential, Commercial,
|
||||
/// Industrial, Administrative, Entertainment, MixedUse, Transit, Specialized.
|
||||
const DIST_COLS: [DistrictType; 9] = [
|
||||
DistrictType::LogisticsHub,
|
||||
DistrictType::Residential,
|
||||
DistrictType::Commercial,
|
||||
DistrictType::Industrial,
|
||||
DistrictType::Administrative,
|
||||
DistrictType::Entertainment,
|
||||
DistrictType::MixedUse,
|
||||
DistrictType::Transit,
|
||||
DistrictType::Specialized,
|
||||
];
|
||||
|
||||
/// Map a DistrictType to its column index.
|
||||
fn dist_col(dt: &DistrictType) -> usize {
|
||||
match dt {
|
||||
DistrictType::LogisticsHub => 0,
|
||||
DistrictType::Residential => 1,
|
||||
DistrictType::Commercial => 2,
|
||||
DistrictType::Industrial => 3,
|
||||
DistrictType::Administrative => 4,
|
||||
DistrictType::Entertainment => 5,
|
||||
DistrictType::MixedUse => 6,
|
||||
DistrictType::Transit => 7,
|
||||
DistrictType::Specialized => 8,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an economic role to its row index (0–9).
|
||||
fn role_row(economic_role: &str) -> usize {
|
||||
match economic_role {
|
||||
"manufacturing" => 0,
|
||||
"financial" => 1,
|
||||
"agricultural" => 2,
|
||||
"extraction" => 3,
|
||||
"service_mixed" => 4,
|
||||
"institutional" => 5,
|
||||
"transit_hub" => 6,
|
||||
"research" => 7,
|
||||
"military" => 8,
|
||||
_ => 9,
|
||||
}
|
||||
}
|
||||
|
||||
/// 10×9 economic multiplier table. Values are integer weights × 10.
|
||||
/// Rows: manufacturing(0), financial(1), agricultural(2), extraction(3),
|
||||
/// service_mixed(4), institutional(5), transit_hub(6), research(7),
|
||||
/// military(8), residential(9).
|
||||
/// Columns: LogisticsHub(0), Residential(1), Commercial(2), Industrial(3),
|
||||
/// Administrative(4), Entertainment(5), MixedUse(6), Transit(7),
|
||||
/// Specialized(8).
|
||||
#[rustfmt::skip]
|
||||
const ECON_TABLE: [[u32; 9]; 10] = [
|
||||
// LH Re Co In Ad En Mu Tr Sp
|
||||
[25, 10, 15, 30, 10, 5, 10, 20, 10], // manufacturing
|
||||
[10, 15, 30, 10, 20, 15, 20, 15, 10], // financial
|
||||
[20, 20, 10, 15, 10, 5, 20, 10, 5], // agricultural
|
||||
[30, 10, 10, 30, 10, 5, 5, 15, 10], // extraction
|
||||
[15, 20, 25, 10, 10, 20, 25, 20, 10], // service_mixed
|
||||
[10, 15, 10, 10, 30, 10, 10, 10, 20], // institutional
|
||||
[25, 10, 15, 10, 10, 10, 10, 30, 10], // transit_hub
|
||||
[10, 15, 10, 15, 20, 10, 10, 10, 30], // research
|
||||
[10, 20, 5, 15, 20, 5, 5, 10, 15], // military
|
||||
[10, 30, 15, 5, 10, 15, 25, 10, 5], // residential
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Political archetype modifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Additive integer modifiers to column weights based on `PoliticalArchetype`.
|
||||
/// Returns `[mod; 9]` for columns in `DIST_COLS` order.
|
||||
fn archetype_modifiers(archetype: &PoliticalArchetype) -> [i32; 9] {
|
||||
match archetype {
|
||||
PoliticalArchetype::Commission => {
|
||||
// Boosts Administrative + Institutional-style Specialized.
|
||||
[0, 0, 0, 0, 10, 0, 0, 0, 5]
|
||||
}
|
||||
PoliticalArchetype::Corporate => {
|
||||
// Boosts Commercial + Specialized (restricted campus zones).
|
||||
[0, -5, 15, 0, 0, 5, 0, 0, 10]
|
||||
}
|
||||
PoliticalArchetype::Pioneer => {
|
||||
// Boosts MixedUse + organic Residential.
|
||||
[0, 10, 5, 0, -5, 5, 15, 0, 0]
|
||||
}
|
||||
PoliticalArchetype::Military => {
|
||||
// Boosts Administrative + reduces Entertainment.
|
||||
[0, 5, -5, 5, 15, -10, 0, 0, 10]
|
||||
}
|
||||
PoliticalArchetype::Academic => {
|
||||
// Boosts Specialized (research labs) + Administrative.
|
||||
[0, 5, 0, 0, 10, 5, 5, 0, 20]
|
||||
}
|
||||
PoliticalArchetype::Industrial => {
|
||||
// Boosts Industrial + LogisticsHub.
|
||||
[10, -5, 5, 20, 0, -5, 0, 5, 5]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// District mix computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The district type distribution for a generated city.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DistrictMix {
|
||||
/// Ordered list of district types for the city, with repetition (district_count items total).
|
||||
pub districts: Vec<DistrictType>,
|
||||
/// Total district count.
|
||||
pub total: u32,
|
||||
}
|
||||
|
||||
/// Compute the district mix for one city (D-194).
|
||||
///
|
||||
/// `total_districts` is the number of districts to allocate. A good default is
|
||||
/// `max(4, population_tier * 2)`.
|
||||
///
|
||||
/// `seed` is the city-level RNG seed (D-010 determinism).
|
||||
pub fn compute_district_mix(
|
||||
population: i64,
|
||||
economic_role: &str,
|
||||
archetype: &PoliticalArchetype,
|
||||
total_districts: u32,
|
||||
seed: u64,
|
||||
) -> DistrictMix {
|
||||
let tier = population_tier(population);
|
||||
let (transit_min, commercial_min, residential_min) = tier_guarantees(tier);
|
||||
let row = role_row(economic_role);
|
||||
let arch_mods = archetype_modifiers(archetype);
|
||||
|
||||
// Build effective weights (integer, clamped to ≥ 1).
|
||||
let mut weights: [u32; 9] = [0; 9];
|
||||
for col in 0..9 {
|
||||
let base = ECON_TABLE[row][col] as i32;
|
||||
let modified = base + arch_mods[col];
|
||||
weights[col] = modified.max(1) as u32;
|
||||
}
|
||||
|
||||
// Allocate districts proportionally from weights using a seeded LCG.
|
||||
// We avoid f32 by using integer weighted random selection.
|
||||
let weight_sum: u32 = weights.iter().sum();
|
||||
let mut counts: [u32; 9] = [0; 9];
|
||||
let mut lcg = AtlasRng::new(seed.wrapping_add(1));
|
||||
|
||||
for _ in 0..total_districts {
|
||||
let mut pick = lcg.next_u32() % weight_sum;
|
||||
for col in 0..9 {
|
||||
if pick < weights[col] {
|
||||
counts[col] += 1;
|
||||
break;
|
||||
}
|
||||
pick -= weights[col];
|
||||
}
|
||||
}
|
||||
|
||||
// Apply tier guarantees (add if under minimum).
|
||||
let transit_col = dist_col(&DistrictType::Transit);
|
||||
let commercial_col = dist_col(&DistrictType::Commercial);
|
||||
let residential_col = dist_col(&DistrictType::Residential);
|
||||
|
||||
if counts[transit_col] < transit_min {
|
||||
counts[transit_col] = transit_min;
|
||||
}
|
||||
if counts[commercial_col] < commercial_min {
|
||||
counts[commercial_col] = commercial_min;
|
||||
}
|
||||
if counts[residential_col] < residential_min {
|
||||
counts[residential_col] = residential_min;
|
||||
}
|
||||
|
||||
// Build the flat ordered list.
|
||||
let mut districts: Vec<DistrictType> = Vec::new();
|
||||
for (col, &count) in counts.iter().enumerate() {
|
||||
for _ in 0..count {
|
||||
districts.push(DIST_COLS[col].clone());
|
||||
}
|
||||
}
|
||||
|
||||
let total = districts.len() as u32;
|
||||
DistrictMix { districts, total }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn population_tier_values() {
|
||||
assert_eq!(population_tier(0), 0);
|
||||
assert_eq!(population_tier(50_000), 0); // 0.05M → log10 < 0 → tier 0
|
||||
assert_eq!(population_tier(1_000_000), 0); // 1M → log10(1) = 0 → tier 0
|
||||
assert_eq!(population_tier(10_000_000), 1); // 10M → log10(10) = 1 → tier 1
|
||||
assert_eq!(population_tier(100_000_000), 2); // 100M → tier 2
|
||||
assert_eq!(population_tier(1_000_000_000_000), 5); // capped at 5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_sums_at_least_to_requested() {
|
||||
let mix = compute_district_mix(
|
||||
5_000_000,
|
||||
"manufacturing",
|
||||
&PoliticalArchetype::Industrial,
|
||||
8,
|
||||
42,
|
||||
);
|
||||
// total may exceed requested due to guarantees
|
||||
assert!(mix.total >= 8, "district count should be >= requested");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_guarantees_applied() {
|
||||
// Tier 2 city: pop/1M = 100–999, log10(100) = 2.
|
||||
// 100M population → pop_tier = floor(log10(100)) = 2 → (1 Transit, 1 Commercial, 2 Residential).
|
||||
let mix = compute_district_mix(
|
||||
100_000_000,
|
||||
"service_mixed",
|
||||
&PoliticalArchetype::Pioneer,
|
||||
6,
|
||||
7,
|
||||
);
|
||||
let transit = mix
|
||||
.districts
|
||||
.iter()
|
||||
.filter(|d| matches!(d, DistrictType::Transit))
|
||||
.count();
|
||||
let commercial = mix
|
||||
.districts
|
||||
.iter()
|
||||
.filter(|d| matches!(d, DistrictType::Commercial))
|
||||
.count();
|
||||
let residential = mix
|
||||
.districts
|
||||
.iter()
|
||||
.filter(|d| matches!(d, DistrictType::Residential))
|
||||
.count();
|
||||
assert!(transit >= 1, "transit guarantee not met: {transit}");
|
||||
assert!(
|
||||
commercial >= 1,
|
||||
"commercial guarantee not met: {commercial}"
|
||||
);
|
||||
assert!(
|
||||
residential >= 2,
|
||||
"residential guarantee not met: {residential}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determinism_same_seed() {
|
||||
let mix1 = compute_district_mix(
|
||||
5_000_000,
|
||||
"financial",
|
||||
&PoliticalArchetype::Commission,
|
||||
6,
|
||||
99,
|
||||
);
|
||||
let mix2 = compute_district_mix(
|
||||
5_000_000,
|
||||
"financial",
|
||||
&PoliticalArchetype::Commission,
|
||||
6,
|
||||
99,
|
||||
);
|
||||
assert_eq!(mix1, mix2, "same inputs must produce identical output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_archetypes_produce_different_mixes() {
|
||||
let mix_corp = compute_district_mix(
|
||||
5_000_000,
|
||||
"financial",
|
||||
&PoliticalArchetype::Corporate,
|
||||
8,
|
||||
42,
|
||||
);
|
||||
let mix_pioneer =
|
||||
compute_district_mix(5_000_000, "financial", &PoliticalArchetype::Pioneer, 8, 42);
|
||||
// Should differ in at least one district type count.
|
||||
assert_ne!(
|
||||
mix_corp.districts, mix_pioneer.districts,
|
||||
"Corporate and Pioneer archetypes should produce different district mixes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn military_archetype_has_administrative() {
|
||||
let mix = compute_district_mix(2_000_000, "military", &PoliticalArchetype::Military, 8, 10);
|
||||
let admin = mix
|
||||
.districts
|
||||
.iter()
|
||||
.filter(|d| matches!(d, DistrictType::Administrative))
|
||||
.count();
|
||||
assert!(
|
||||
admin >= 1,
|
||||
"military archetype should have Administrative districts"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_district_types_can_appear() {
|
||||
// With enough districts and a balanced role, every type should appear at least once.
|
||||
let mix = compute_district_mix(
|
||||
50_000_000,
|
||||
"service_mixed",
|
||||
&PoliticalArchetype::Pioneer,
|
||||
50,
|
||||
0,
|
||||
);
|
||||
for dt in &DIST_COLS {
|
||||
let present = mix
|
||||
.districts
|
||||
.iter()
|
||||
.any(|d| std::mem::discriminant(d) == std::mem::discriminant(dt));
|
||||
assert!(
|
||||
present,
|
||||
"DistrictType {:?} never appeared in 50-district mix",
|
||||
dt
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
//! D8 drainage routing — flow direction, flow accumulation, river network
|
||||
//! extraction, and drainage basin delineation (D-208).
|
||||
//!
|
||||
//! **Determinism (D-010, D-208):** All flow-direction comparisons use integer
|
||||
//! arithmetic on scaled elevation values (`(elev * 1_000_000.0) as i64`) to
|
||||
//! avoid f32 comparison non-determinism. Tie-breaking uses a fixed D8 neighbor
|
||||
//! priority order. The result is bit-identical across runs on the same inputs.
|
||||
//!
|
||||
//! **Algorithm:**
|
||||
//! 1. Scale f32 elevation to i64 integers.
|
||||
//! 2. Priority-flood depression fill (iterative, convergence in ≤10 passes).
|
||||
//! 3. D8 flow direction: steepest descent, 8-neighbor, wraps horizontally.
|
||||
//! 4. Flow accumulation via topological sort of the D8 DAG.
|
||||
//! 5. River network extraction: cells with accumulation > RIVER_THRESHOLD.
|
||||
//! 6. Basin labeling: flood-fill seeded at pour points.
|
||||
//!
|
||||
//! The grid is row-major. Row 0 is the north pole; row H-1 is the south pole.
|
||||
//! Columns wrap horizontally (the globe is equirectangular).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::atlas::body_world_state::{DrainageBasin, RiverNetwork};
|
||||
|
||||
/// A cell is a river cell when its flow accumulation exceeds this threshold (D-208).
|
||||
pub const RIVER_THRESHOLD: i32 = 200;
|
||||
|
||||
/// Scale factor for converting f32 elevation to integer for deterministic comparison.
|
||||
const ELEV_SCALE: f64 = 1_000_000.0;
|
||||
|
||||
// D8 neighbor offsets (dr, dc) in fixed priority order for deterministic tie-breaking.
|
||||
// Priority: cardinal directions first (N, S, E, W), then diagonals (NE, NW, SE, SW).
|
||||
const D8: [(i32, i32); 8] = [
|
||||
(-1, 0), // N
|
||||
(1, 0), // S
|
||||
(0, 1), // E
|
||||
(0, -1), // W
|
||||
(-1, 1), // NE
|
||||
(-1, -1), // NW
|
||||
(1, 1), // SE
|
||||
(1, -1), // SW
|
||||
];
|
||||
|
||||
/// Result of the full D8 drainage analysis for one body.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DrainageResult {
|
||||
pub river_network: RiverNetwork,
|
||||
pub drainage_basins: Vec<DrainageBasin>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run the full D8 drainage analysis on an elevation grid.
|
||||
///
|
||||
/// `elevation` is a row-major float32 grid of shape `height × width`, values
|
||||
/// in [0.0, 1.0]. `sea_level` is the fraction below which terrain is ocean.
|
||||
///
|
||||
/// Returns `DrainageResult` with the river network and drainage basins.
|
||||
pub fn analyze(elevation: &[f32], width: u32, height: u32, sea_level: f32) -> DrainageResult {
|
||||
let w = width as usize;
|
||||
let h = height as usize;
|
||||
// 1. Scale to integers.
|
||||
let scaled: Vec<i64> = elevation
|
||||
.iter()
|
||||
.map(|&e| (e as f64 * ELEV_SCALE) as i64)
|
||||
.collect();
|
||||
|
||||
// 2. Depression fill.
|
||||
let filled = depression_fill(&scaled, w, h);
|
||||
|
||||
// 3. D8 flow direction. -1 = no outflow (edge or flat peak).
|
||||
let fdir = flow_direction(&filled, w, h);
|
||||
|
||||
// 4. Flow accumulation.
|
||||
let accum = flow_accumulation(&fdir, w, h);
|
||||
|
||||
// 5. River network.
|
||||
let river_network = extract_river_network(&accum, &fdir, w, h, sea_level, elevation);
|
||||
|
||||
// 6. Basin labeling.
|
||||
let labels = label_basins(&fdir, &accum, w, h);
|
||||
|
||||
// 7. Merge small basins + clamp count to [4, 12].
|
||||
let labels = merge_small_basins(labels, w, h, 4, 12);
|
||||
|
||||
// 8. Build DrainageBasin structs.
|
||||
let drainage_basins = build_basins(&labels, w, h);
|
||||
|
||||
DrainageResult {
|
||||
river_network,
|
||||
drainage_basins,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 2: Depression fill
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn depression_fill(scaled: &[i64], w: usize, h: usize) -> Vec<i64> {
|
||||
let mut filled = scaled.to_vec();
|
||||
for _ in 0..10 {
|
||||
let mut changed = false;
|
||||
for r in 1..h.saturating_sub(1) {
|
||||
for c in 0..w {
|
||||
let mut nbr_min = i64::MAX;
|
||||
for &(dr, dc) in &D8 {
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr >= 0 && nr < h as i32 {
|
||||
let val = filled[nr as usize * w + nc];
|
||||
if val < nbr_min {
|
||||
nbr_min = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
if filled[r * w + c] < nbr_min {
|
||||
filled[r * w + c] = nbr_min + 1;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
filled
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 3: D8 flow direction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns per-cell flow direction index into D8 (0–7), or -1 for no outflow.
|
||||
fn flow_direction(filled: &[i64], w: usize, h: usize) -> Vec<i8> {
|
||||
let mut fdir = vec![-1i8; w * h];
|
||||
for r in 0..h {
|
||||
for c in 0..w {
|
||||
let elev = filled[r * w + c];
|
||||
let mut best_drop = 0i64;
|
||||
let mut best_k: i8 = -1;
|
||||
for (k, &(dr, dc)) in D8.iter().enumerate() {
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr < 0 || nr >= h as i32 {
|
||||
continue;
|
||||
}
|
||||
let drop = elev - filled[nr as usize * w + nc];
|
||||
if drop > best_drop {
|
||||
best_drop = drop;
|
||||
best_k = k as i8;
|
||||
}
|
||||
}
|
||||
fdir[r * w + c] = best_k;
|
||||
}
|
||||
}
|
||||
fdir
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 4: Flow accumulation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn flow_accumulation(fdir: &[i8], w: usize, h: usize) -> Vec<i32> {
|
||||
let n = w * h;
|
||||
let mut in_degree = vec![0i32; n];
|
||||
|
||||
for r in 0..h {
|
||||
for c in 0..w {
|
||||
let k = fdir[r * w + c];
|
||||
if k < 0 {
|
||||
continue;
|
||||
}
|
||||
let (dr, dc) = D8[k as usize];
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr >= 0 && nr < h as i32 {
|
||||
in_degree[nr as usize * w + nc] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut queue = VecDeque::new();
|
||||
for (i, °) in in_degree.iter().enumerate().take(n) {
|
||||
if deg == 0 {
|
||||
queue.push_back(i);
|
||||
}
|
||||
}
|
||||
|
||||
let mut accum = vec![1i32; n];
|
||||
while let Some(idx) = queue.pop_front() {
|
||||
let r = idx / w;
|
||||
let c = idx % w;
|
||||
let k = fdir[idx];
|
||||
if k < 0 {
|
||||
continue;
|
||||
}
|
||||
let (dr, dc) = D8[k as usize];
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr >= 0 && nr < h as i32 {
|
||||
let ni = nr as usize * w + nc;
|
||||
accum[ni] += accum[idx];
|
||||
in_degree[ni] -= 1;
|
||||
if in_degree[ni] == 0 {
|
||||
queue.push_back(ni);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
accum
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 5: River network extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn extract_river_network(
|
||||
accum: &[i32],
|
||||
fdir: &[i8],
|
||||
w: usize,
|
||||
h: usize,
|
||||
sea_level: f32,
|
||||
elevation: &[f32],
|
||||
) -> RiverNetwork {
|
||||
let n = w * h;
|
||||
|
||||
// River cells: above threshold AND above sea level.
|
||||
let is_river: Vec<bool> = (0..n)
|
||||
.map(|i| accum[i] > RIVER_THRESHOLD && elevation[i] >= sea_level)
|
||||
.collect();
|
||||
|
||||
let river_cells: Vec<(u16, u16)> = (0..n)
|
||||
.filter(|&i| is_river[i])
|
||||
.map(|i| ((i / w) as u16, (i % w) as u16))
|
||||
.collect();
|
||||
|
||||
// Confluences: river cells with 2+ river neighbors flowing into them.
|
||||
let mut inflow_count = vec![0u8; n];
|
||||
for r in 0..h {
|
||||
for c in 0..w {
|
||||
let i = r * w + c;
|
||||
if !is_river[i] {
|
||||
continue;
|
||||
}
|
||||
let k = fdir[i];
|
||||
if k < 0 {
|
||||
continue;
|
||||
}
|
||||
let (dr, dc) = D8[k as usize];
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr >= 0 && nr < h as i32 {
|
||||
let ni = nr as usize * w + nc;
|
||||
if is_river[ni] {
|
||||
inflow_count[ni] = inflow_count[ni].saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let confluences: Vec<(u16, u16)> = (0..n)
|
||||
.filter(|&i| is_river[i] && inflow_count[i] >= 2)
|
||||
.map(|i| ((i / w) as u16, (i % w) as u16))
|
||||
.collect();
|
||||
|
||||
// Mouths: river cells that flow to a sea cell or to the polar edge.
|
||||
let mouths: Vec<(u16, u16)> = (0..n)
|
||||
.filter(|&i| {
|
||||
if !is_river[i] {
|
||||
return false;
|
||||
}
|
||||
let r = i / w;
|
||||
let c = i % w;
|
||||
let k = fdir[i];
|
||||
if k < 0 {
|
||||
return true; // no outflow — edge
|
||||
}
|
||||
let (dr, dc) = D8[k as usize];
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr < 0 || nr >= h as i32 {
|
||||
return true; // polar edge
|
||||
}
|
||||
// Flows into a sub-sea-level cell = mouth
|
||||
elevation[nr as usize * w + nc] < sea_level
|
||||
})
|
||||
.map(|i| ((i / w) as u16, (i % w) as u16))
|
||||
.collect();
|
||||
|
||||
RiverNetwork {
|
||||
river_cells,
|
||||
confluences,
|
||||
mouths,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 6: Basin labeling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn label_basins(fdir: &[i8], accum: &[i32], w: usize, h: usize) -> Vec<i32> {
|
||||
let n = w * h;
|
||||
let mut labels = vec![-1i32; n];
|
||||
|
||||
// Pour points: local accumulation maxima above river threshold.
|
||||
let mut pour_pts: Vec<usize> = Vec::new();
|
||||
for i in 0..n {
|
||||
if accum[i] <= RIVER_THRESHOLD {
|
||||
continue;
|
||||
}
|
||||
let r = i / w;
|
||||
let c = i % w;
|
||||
let mut is_max = true;
|
||||
for &(dr, dc) in &D8 {
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr >= 0 && nr < h as i32 && accum[nr as usize * w + nc] > accum[i] {
|
||||
is_max = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if is_max {
|
||||
pour_pts.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if pour_pts.is_empty() {
|
||||
// Flat/ocean world — single basin.
|
||||
labels.iter_mut().for_each(|l| *l = 0);
|
||||
return labels;
|
||||
}
|
||||
|
||||
for (basin_id, &idx) in pour_pts.iter().enumerate() {
|
||||
labels[idx] = basin_id as i32;
|
||||
}
|
||||
|
||||
// Trace remaining cells: follow fdir until a labeled cell is reached.
|
||||
for start in 0..n {
|
||||
if labels[start] >= 0 {
|
||||
continue;
|
||||
}
|
||||
// Walk forward, accumulate path.
|
||||
let mut path: Vec<usize> = Vec::new();
|
||||
let mut cur = start;
|
||||
let label = loop {
|
||||
if labels[cur] >= 0 {
|
||||
break labels[cur];
|
||||
}
|
||||
path.push(cur);
|
||||
let k = fdir[cur];
|
||||
if k < 0 {
|
||||
break 0; // no outflow — assign to basin 0
|
||||
}
|
||||
let r = cur / w;
|
||||
let c = cur % w;
|
||||
let (dr, dc) = D8[k as usize];
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr < 0 || nr >= h as i32 {
|
||||
break 0; // polar edge
|
||||
}
|
||||
let next = nr as usize * w + nc;
|
||||
// Cycle guard: if we're visiting a cell already in path, stop.
|
||||
if path.contains(&next) {
|
||||
break 0;
|
||||
}
|
||||
cur = next;
|
||||
};
|
||||
for idx in path {
|
||||
labels[idx] = label;
|
||||
}
|
||||
}
|
||||
|
||||
labels
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 7: Merge small basins
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn merge_small_basins(
|
||||
mut labels: Vec<i32>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
min_count: usize,
|
||||
max_count: usize,
|
||||
) -> Vec<i32> {
|
||||
let n = w * h;
|
||||
let min_frac = 0.02f64; // 2% minimum basin area
|
||||
|
||||
for _ in 0..200 {
|
||||
// Count basin sizes.
|
||||
let mut sizes: std::collections::BTreeMap<i32, usize> = std::collections::BTreeMap::new();
|
||||
for &l in &labels {
|
||||
*sizes.entry(l).or_insert(0) += 1;
|
||||
}
|
||||
let n_basins = sizes.len();
|
||||
|
||||
// Stop if within target range and all basins are large enough.
|
||||
if n_basins <= max_count && sizes.values().all(|&s| s as f64 / n as f64 >= min_frac) {
|
||||
break;
|
||||
}
|
||||
if n_basins <= min_count {
|
||||
break;
|
||||
}
|
||||
|
||||
// Find the smallest basin.
|
||||
let (&smallest_id, &smallest_size) = sizes.iter().min_by_key(|(_, &s)| s).unwrap();
|
||||
|
||||
if n_basins <= max_count && smallest_size as f64 / n as f64 >= min_frac {
|
||||
break;
|
||||
}
|
||||
|
||||
// Find its largest adjacent basin.
|
||||
let nbr_id = find_largest_neighbor(&labels, smallest_id, &sizes, w, h);
|
||||
let merge_into = nbr_id.unwrap_or(0);
|
||||
|
||||
// Merge.
|
||||
for l in labels.iter_mut() {
|
||||
if *l == smallest_id {
|
||||
*l = merge_into;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Renumber contiguously from 0.
|
||||
let unique: Vec<i32> = {
|
||||
let mut set: std::collections::BTreeSet<i32> = std::collections::BTreeSet::new();
|
||||
for &l in &labels {
|
||||
set.insert(l);
|
||||
}
|
||||
set.into_iter().collect()
|
||||
};
|
||||
let remap: std::collections::BTreeMap<i32, i32> = unique
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(new, &old)| (old, new as i32))
|
||||
.collect();
|
||||
for l in labels.iter_mut() {
|
||||
*l = remap[l];
|
||||
}
|
||||
|
||||
labels
|
||||
}
|
||||
|
||||
fn find_largest_neighbor(
|
||||
labels: &[i32],
|
||||
target_id: i32,
|
||||
sizes: &std::collections::BTreeMap<i32, usize>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
) -> Option<i32> {
|
||||
let n = w * h;
|
||||
let mut neighbor_sizes: std::collections::BTreeMap<i32, usize> =
|
||||
std::collections::BTreeMap::new();
|
||||
|
||||
for i in 0..n {
|
||||
if labels[i] != target_id {
|
||||
continue;
|
||||
}
|
||||
let r = i / w;
|
||||
let c = i % w;
|
||||
for &(dr, dc) in &D8 {
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr >= 0 && nr < h as i32 {
|
||||
let nbr_id = labels[nr as usize * w + nc];
|
||||
if nbr_id != target_id {
|
||||
let size = sizes.get(&nbr_id).copied().unwrap_or(0);
|
||||
let e = neighbor_sizes.entry(nbr_id).or_insert(0);
|
||||
if size > *e {
|
||||
*e = size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
neighbor_sizes
|
||||
.into_iter()
|
||||
.max_by_key(|(_, s)| *s)
|
||||
.map(|(id, _)| id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 8: Build DrainageBasin structs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn build_basins(labels: &[i32], w: usize, h: usize) -> Vec<DrainageBasin> {
|
||||
let n = w * h;
|
||||
let mut basin_map: std::collections::BTreeMap<i32, Vec<usize>> =
|
||||
std::collections::BTreeMap::new();
|
||||
|
||||
for (i, &l) in labels.iter().enumerate() {
|
||||
basin_map.entry(l).or_default().push(i);
|
||||
}
|
||||
|
||||
let mut basins: Vec<DrainageBasin> = Vec::with_capacity(basin_map.len());
|
||||
let mut ids: Vec<i32> = basin_map.keys().copied().collect();
|
||||
ids.sort();
|
||||
|
||||
for basin_id in ids {
|
||||
let cells = &basin_map[&basin_id];
|
||||
let area_pct = cells.len() as f32 / n as f32;
|
||||
|
||||
// Boundary cells: in this basin, adjacent to a different basin or edge.
|
||||
let mut boundary: Vec<(u16, u16)> = Vec::new();
|
||||
for &idx in cells {
|
||||
let r = idx / w;
|
||||
let c = idx % w;
|
||||
let mut on_boundary = false;
|
||||
for &(dr, dc) in &D8 {
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr < 0 || nr >= h as i32 {
|
||||
on_boundary = true;
|
||||
break;
|
||||
}
|
||||
if labels[nr as usize * w + nc] != basin_id {
|
||||
on_boundary = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if on_boundary {
|
||||
boundary.push((r as u16, c as u16));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort boundary by angle from centroid for a coherent polygon.
|
||||
if !boundary.is_empty() {
|
||||
let cr = boundary.iter().map(|&(r, _)| r as f32).sum::<f32>() / boundary.len() as f32;
|
||||
let cc = boundary.iter().map(|&(_, c)| c as f32).sum::<f32>() / boundary.len() as f32;
|
||||
boundary.sort_by(|&(r1, c1), &(r2, c2)| {
|
||||
let a1 = (r1 as f32 - cr).atan2(c1 as f32 - cc);
|
||||
let a2 = (r2 as f32 - cr).atan2(c2 as f32 - cc);
|
||||
a1.partial_cmp(&a2).unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
// Subsample to ≤500 points.
|
||||
if boundary.len() > 500 {
|
||||
let step = boundary.len() / 500;
|
||||
boundary = boundary.into_iter().step_by(step).collect();
|
||||
}
|
||||
}
|
||||
|
||||
basins.push(DrainageBasin {
|
||||
basin_id: basin_id as u32,
|
||||
boundary,
|
||||
area_pct,
|
||||
});
|
||||
}
|
||||
|
||||
basins
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn flat_grid(w: u32, h: u32, val: f32) -> Vec<f32> {
|
||||
vec![val; (w * h) as usize]
|
||||
}
|
||||
|
||||
fn slope_grid(w: u32, h: u32) -> Vec<f32> {
|
||||
let n = (w * h) as usize;
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let r = i / w as usize;
|
||||
let c = i % w as usize;
|
||||
// Slope: higher in top-left, drains toward bottom-right.
|
||||
1.0 - (r as f32 / h as f32 * 0.5 + c as f32 / w as f32 * 0.5)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_grid_produces_single_basin() {
|
||||
let elev = flat_grid(16, 8, 0.5);
|
||||
let result = analyze(&elev, 16, 8, 0.3);
|
||||
// Flat world → no pour points → single basin
|
||||
assert_eq!(result.drainage_basins.len(), 1);
|
||||
assert!((result.drainage_basins[0].area_pct - 1.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slope_grid_has_no_river_cells_below_threshold_by_default() {
|
||||
// Small 8×4 grid: max flow_accum ≤ 32, below RIVER_THRESHOLD (200).
|
||||
let elev = slope_grid(8, 4);
|
||||
let result = analyze(&elev, 8, 4, 0.3);
|
||||
// River cells may be empty on this tiny grid — that is acceptable.
|
||||
// What matters: no panic and basin count ≥ 1.
|
||||
assert!(!result.drainage_basins.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_grid_river_cells_nonempty() {
|
||||
// 512×256: max flow accumulation ~131K >> RIVER_THRESHOLD.
|
||||
let elev = slope_grid(512, 256);
|
||||
let result = analyze(&elev, 512, 256, 0.3);
|
||||
assert!(
|
||||
!result.river_network.river_cells.is_empty(),
|
||||
"Expected river cells on a large sloped grid"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basin_area_pcts_sum_to_one() {
|
||||
let elev = slope_grid(64, 32);
|
||||
let result = analyze(&elev, 64, 32, 0.3);
|
||||
let total: f32 = result.drainage_basins.iter().map(|b| b.area_pct).sum();
|
||||
assert!(
|
||||
(total - 1.0).abs() < 0.01,
|
||||
"Basin area fractions must sum to 1, got {}",
|
||||
total
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basin_count_within_target_range() {
|
||||
let elev = slope_grid(128, 64);
|
||||
let result = analyze(&elev, 128, 64, 0.3);
|
||||
let n = result.drainage_basins.len();
|
||||
assert!(
|
||||
n >= 1 && n <= 12,
|
||||
"Basin count {} out of expected range [1, 12]",
|
||||
n
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determinism() {
|
||||
// Running analyze twice on the same input must produce identical results.
|
||||
let elev = slope_grid(64, 32);
|
||||
let r1 = analyze(&elev, 64, 32, 0.3);
|
||||
let r2 = analyze(&elev, 64, 32, 0.3);
|
||||
assert_eq!(
|
||||
r1.river_network.river_cells, r2.river_network.river_cells,
|
||||
"River cells must be deterministic"
|
||||
);
|
||||
assert_eq!(
|
||||
r1.drainage_basins.len(),
|
||||
r2.drainage_basins.len(),
|
||||
"Basin count must be deterministic"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
//! Background generation queue — prioritized Rayon thread pool (D-206).
|
||||
//!
|
||||
//! All runtime-background generation work runs through this queue. The main
|
||||
//! tick thread submits work items (non-blocking) and drains completion events
|
||||
//! once per tick via a `crossbeam` channel.
|
||||
//!
|
||||
//! **Priority levels (D-206):**
|
||||
//! - `Immediate`: player arrives within 1 game-minute. Runs first.
|
||||
//! - `High`: player arrives within 5 game-minutes.
|
||||
//! - `Medium`: player is in the same system.
|
||||
//! - `Low`: player has heard of this location via NPC/news.
|
||||
//!
|
||||
//! **Work item types (D-206):**
|
||||
//! - `AnalyzeBody`: D8 drainage + attractor extraction for a body.
|
||||
//! - `GenerateSkeleton`: Phase 1 DistrictSkeleton for a city.
|
||||
//! - `FillChunk`: Phase 2 chunk fill for a pre-loaded district.
|
||||
//!
|
||||
//! Completion events are delivered to the main thread via
|
||||
//! `GenerationQueue::drain_completions()`, called once per tick from a Bevy
|
||||
//! system in `TickPhase::PreInput`.
|
||||
//!
|
||||
//! **Thread count (D-206):** `available_parallelism - 2`, minimum 1.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use bevy_ecs::prelude::Resource;
|
||||
use crossbeam_channel::{Receiver, Sender};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Priority
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Work priority levels — lower discriminant = higher priority.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum GenPriority {
|
||||
/// Player arrives within ~1 game-minute. Runs before all other levels.
|
||||
Immediate = 0,
|
||||
/// Player arrives within ~5 game-minutes.
|
||||
High = 1,
|
||||
/// Player is in the same system.
|
||||
Medium = 2,
|
||||
/// Player has seen or heard of this location (NPC dialogue, news ticker).
|
||||
Low = 3,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Work item types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A unit of background generation work (D-206).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum GenWorkItem {
|
||||
/// Run D8 drainage analysis + attractor extraction for this body.
|
||||
AnalyzeBody { body_id: String },
|
||||
/// Generate a Phase 1 DistrictSkeleton for this city.
|
||||
GenerateSkeleton { city_id: u64 },
|
||||
/// Pre-fill a chunk in an existing district.
|
||||
FillChunk {
|
||||
district_id: u64,
|
||||
block_pos: (u32, u32),
|
||||
},
|
||||
}
|
||||
|
||||
impl GenWorkItem {
|
||||
pub fn body_id(&self) -> Option<&str> {
|
||||
if let GenWorkItem::AnalyzeBody { body_id } = self {
|
||||
Some(body_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Completion event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Sent back to the main thread when a work item finishes (D-206).
|
||||
#[derive(Debug)]
|
||||
pub enum GenCompletion {
|
||||
BodyAnalyzed {
|
||||
body_id: String,
|
||||
},
|
||||
SkeletonGenerated {
|
||||
city_id: u64,
|
||||
},
|
||||
ChunkFilled {
|
||||
district_id: u64,
|
||||
block_pos: (u32, u32),
|
||||
},
|
||||
/// Work item failed — body_id or city_id for logging.
|
||||
Failed {
|
||||
item: GenWorkItem,
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal queued work
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct QueuedWork {
|
||||
priority: GenPriority,
|
||||
item: GenWorkItem,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GenerationQueue — Bevy Resource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bevy `Resource` managing the background generation queue (D-206).
|
||||
///
|
||||
/// Submit work with `submit()`. Drain completions with `drain_completions()`
|
||||
/// once per tick. The Rayon thread pool runs tasks in priority order.
|
||||
///
|
||||
/// Priority is respected because `dispatch_next()` is gated on pool saturation
|
||||
/// via `in_flight_count`: it only dispatches when fewer than `n_threads` tasks
|
||||
/// are running. This applies to all work item types — `in_flight` (body-id set)
|
||||
/// is only for AnalyzeBody dedup; `in_flight_count` is the general saturation gate.
|
||||
#[derive(Resource)]
|
||||
pub struct GenerationQueue {
|
||||
/// Pending work items, sorted by priority (index 0 = highest priority).
|
||||
pending: Arc<Mutex<Vec<QueuedWork>>>,
|
||||
/// Completions channel — background tasks send here; main thread reads.
|
||||
completion_tx: Sender<GenCompletion>,
|
||||
completion_rx: Receiver<GenCompletion>,
|
||||
/// Rayon thread pool dedicated to generation work.
|
||||
pool: rayon::ThreadPool,
|
||||
/// Set of body_ids currently in-flight — used only for AnalyzeBody dedup.
|
||||
in_flight: Arc<Mutex<std::collections::BTreeSet<String>>>,
|
||||
/// Count of all work items currently executing in the Rayon pool.
|
||||
/// This is the saturation gate — all work item types increment/decrement it.
|
||||
in_flight_count: Arc<Mutex<usize>>,
|
||||
/// Thread count — caps concurrent dispatches so pending items accumulate
|
||||
/// and priority ordering is consulted before the pool has free threads.
|
||||
n_threads: usize,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GenerationQueue {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let pending_len = self.pending.lock().map(|p| p.len()).unwrap_or(0);
|
||||
f.debug_struct("GenerationQueue")
|
||||
.field("pending_count", &pending_len)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl GenerationQueue {
|
||||
/// Create a new queue with the D-206 thread count:
|
||||
/// `available_parallelism - 2`, minimum 1.
|
||||
pub fn new() -> Self {
|
||||
let n_threads = std::thread::available_parallelism()
|
||||
.map(|p| p.get().saturating_sub(2).max(1))
|
||||
.unwrap_or(1);
|
||||
Self::with_threads(n_threads)
|
||||
}
|
||||
|
||||
/// Create a queue with a specific thread count (for testing).
|
||||
pub fn with_threads(n_threads: usize) -> Self {
|
||||
let pool = rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(n_threads)
|
||||
.thread_name(|i| format!("gen-worker-{i}"))
|
||||
.build()
|
||||
.expect("failed to build generation rayon pool");
|
||||
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
|
||||
Self {
|
||||
pending: Arc::new(Mutex::new(Vec::new())),
|
||||
completion_tx: tx,
|
||||
completion_rx: rx,
|
||||
pool,
|
||||
in_flight: Arc::new(Mutex::new(std::collections::BTreeSet::new())),
|
||||
in_flight_count: Arc::new(Mutex::new(0)),
|
||||
n_threads,
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit a work item at the given priority.
|
||||
///
|
||||
/// If an `AnalyzeBody` item for the same body_id is already in-flight or
|
||||
/// pending, the submission is silently ignored (idempotent).
|
||||
pub fn submit(&self, item: GenWorkItem, priority: GenPriority) {
|
||||
// Dedup AnalyzeBody submissions.
|
||||
if let Some(body_id) = item.body_id() {
|
||||
let in_flight = self.in_flight.lock().unwrap();
|
||||
if in_flight.contains(body_id) {
|
||||
return;
|
||||
}
|
||||
drop(in_flight);
|
||||
// Check pending list.
|
||||
let pending = self.pending.lock().unwrap();
|
||||
if pending.iter().any(|q| q.item.body_id() == Some(body_id)) {
|
||||
return;
|
||||
}
|
||||
drop(pending);
|
||||
}
|
||||
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
let pos = pending
|
||||
.iter()
|
||||
.position(|q| q.priority > priority)
|
||||
.unwrap_or(pending.len());
|
||||
pending.insert(pos, QueuedWork { priority, item });
|
||||
drop(pending);
|
||||
|
||||
self.dispatch_next();
|
||||
}
|
||||
|
||||
/// Drain all completed items from the channel and dispatch pending work.
|
||||
///
|
||||
/// Call once per tick from the main thread. Returns all completions
|
||||
/// available without blocking. After draining, dispatches as many pending
|
||||
/// items as there are free thread slots — this is the point where priority
|
||||
/// ordering matters, since the pool was saturated when items were submitted.
|
||||
pub fn drain_completions(&self) -> Vec<GenCompletion> {
|
||||
let mut out = Vec::new();
|
||||
while let Ok(c) = self.completion_rx.try_recv() {
|
||||
out.push(c);
|
||||
}
|
||||
// Fill any newly-freed slots.
|
||||
for _ in 0..out.len() {
|
||||
self.dispatch_next();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Number of items waiting in the pending queue.
|
||||
pub fn pending_count(&self) -> usize {
|
||||
self.pending.lock().unwrap().len()
|
||||
}
|
||||
|
||||
// Dispatch the highest-priority pending item to the Rayon pool.
|
||||
//
|
||||
// Gated on in_flight_count < n_threads — applies to all work item types,
|
||||
// not just AnalyzeBody. When the pool is full, items stay in the sorted
|
||||
// pending Vec so priority ordering is consulted on the next free slot.
|
||||
fn dispatch_next(&self) {
|
||||
let item = {
|
||||
let count = self.in_flight_count.lock().unwrap();
|
||||
if *count >= self.n_threads {
|
||||
return;
|
||||
}
|
||||
drop(count);
|
||||
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
pending.remove(0).item
|
||||
};
|
||||
|
||||
// Mark body as in-flight (AnalyzeBody dedup).
|
||||
if let Some(body_id) = item.body_id() {
|
||||
self.in_flight.lock().unwrap().insert(body_id.to_string());
|
||||
}
|
||||
// Increment general in-flight counter for all item types.
|
||||
*self.in_flight_count.lock().unwrap() += 1;
|
||||
|
||||
let tx = self.completion_tx.clone();
|
||||
let in_flight = Arc::clone(&self.in_flight);
|
||||
let in_flight_count = Arc::clone(&self.in_flight_count);
|
||||
|
||||
self.pool.spawn(move || {
|
||||
let completion = run_work_item(&item);
|
||||
|
||||
// Un-mark body dedup set (AnalyzeBody only).
|
||||
if let Some(body_id) = item.body_id() {
|
||||
in_flight.lock().unwrap().remove(body_id);
|
||||
}
|
||||
// Decrement general counter for all item types.
|
||||
*in_flight_count.lock().unwrap() -= 1;
|
||||
|
||||
let _ = tx.send(completion);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GenerationQueue {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Work execution stub
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Execute one work item. This is the Rayon task body.
|
||||
///
|
||||
/// Currently a stub — real implementations will call `drainage::analyze()`,
|
||||
/// the attractor pipeline, and the district skeleton generator. Stubs return
|
||||
/// immediate success to allow the queue infrastructure to be tested independently.
|
||||
fn run_work_item(item: &GenWorkItem) -> GenCompletion {
|
||||
match item {
|
||||
GenWorkItem::AnalyzeBody { body_id } => GenCompletion::BodyAnalyzed {
|
||||
body_id: body_id.clone(),
|
||||
},
|
||||
GenWorkItem::GenerateSkeleton { city_id } => {
|
||||
GenCompletion::SkeletonGenerated { city_id: *city_id }
|
||||
}
|
||||
GenWorkItem::FillChunk {
|
||||
district_id,
|
||||
block_pos,
|
||||
} => GenCompletion::ChunkFilled {
|
||||
district_id: *district_id,
|
||||
block_pos: *block_pos,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
fn make_queue() -> GenerationQueue {
|
||||
GenerationQueue::with_threads(2)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_and_drain() {
|
||||
let q = make_queue();
|
||||
q.submit(
|
||||
GenWorkItem::AnalyzeBody {
|
||||
body_id: "TestBody".to_string(),
|
||||
},
|
||||
GenPriority::Medium,
|
||||
);
|
||||
// Give Rayon time to complete the (stub) task.
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let completions = q.drain_completions();
|
||||
assert_eq!(completions.len(), 1);
|
||||
assert!(matches!(
|
||||
&completions[0],
|
||||
GenCompletion::BodyAnalyzed { body_id } if body_id == "TestBody"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_analyze_body() {
|
||||
let q = make_queue();
|
||||
// Submit the same body twice before it can complete.
|
||||
q.submit(
|
||||
GenWorkItem::AnalyzeBody {
|
||||
body_id: "Dup".to_string(),
|
||||
},
|
||||
GenPriority::Low,
|
||||
);
|
||||
q.submit(
|
||||
GenWorkItem::AnalyzeBody {
|
||||
body_id: "Dup".to_string(),
|
||||
},
|
||||
GenPriority::Low,
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let completions = q.drain_completions();
|
||||
// Should have completed exactly once.
|
||||
assert_eq!(completions.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_ordering() {
|
||||
// Submit three items rapidly; Immediate should be dispatched first.
|
||||
// Uses 3 threads so all items can dispatch without hitting saturation.
|
||||
let q = GenerationQueue::with_threads(3);
|
||||
// Using GenerateSkeleton (no dedup logic) to test ordering directly.
|
||||
q.submit(
|
||||
GenWorkItem::GenerateSkeleton { city_id: 1 },
|
||||
GenPriority::Low,
|
||||
);
|
||||
q.submit(
|
||||
GenWorkItem::GenerateSkeleton { city_id: 2 },
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
q.submit(
|
||||
GenWorkItem::GenerateSkeleton { city_id: 3 },
|
||||
GenPriority::Medium,
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let completions = q.drain_completions();
|
||||
assert_eq!(completions.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_ordering_respected_under_saturation() {
|
||||
// Single-thread queue: in_flight_count saturates at 1, so the second
|
||||
// item stays in the pending Vec and is dispatched in priority order.
|
||||
// Uses AnalyzeBody (distinct body_ids) so all paths — dedup set AND
|
||||
// in_flight_count — are exercised.
|
||||
let q = GenerationQueue::with_threads(1);
|
||||
// Submit Low first, then Immediate. With 1 thread:
|
||||
// - "BodyA" (Low) dispatches immediately (pool empty).
|
||||
// - "BodyB" (Immediate) is inserted at index 0 of the sorted pending
|
||||
// Vec while "BodyA" is in-flight (in_flight_count = 1 = n_threads).
|
||||
// - When "BodyA" completes, drain_completions() calls dispatch_next()
|
||||
// which picks index 0 = "BodyB" (Immediate).
|
||||
q.submit(
|
||||
GenWorkItem::AnalyzeBody {
|
||||
body_id: "BodyA".to_string(),
|
||||
},
|
||||
GenPriority::Low,
|
||||
);
|
||||
q.submit(
|
||||
GenWorkItem::AnalyzeBody {
|
||||
body_id: "BodyB".to_string(),
|
||||
},
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
// Wait for BodyA to complete.
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
// drain_completions dispatches BodyB (Immediate, index 0 of pending).
|
||||
let first = q.drain_completions();
|
||||
// Wait for BodyB to complete.
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let second = q.drain_completions();
|
||||
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(second.len(), 1);
|
||||
assert!(matches!(&first[0], GenCompletion::BodyAnalyzed { body_id } if body_id == "BodyA"));
|
||||
assert!(
|
||||
matches!(&second[0], GenCompletion::BodyAnalyzed { body_id } if body_id == "BodyB")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_empty_returns_empty() {
|
||||
let q = make_queue();
|
||||
let result = q.drain_completions();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_count_decreases_after_completion() {
|
||||
let q = make_queue();
|
||||
q.submit(
|
||||
GenWorkItem::FillChunk {
|
||||
district_id: 99,
|
||||
block_pos: (0, 0),
|
||||
},
|
||||
GenPriority::High,
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let completions = q.drain_completions();
|
||||
assert!(!completions.is_empty() || q.pending_count() == 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! Heightmap BLOB loader — reads float32 LE elevation grids from systems.db.
|
||||
//!
|
||||
//! Implements the Rust side of D-202. The Python pipeline stores each body's
|
||||
//! elevation grid as a contiguous float32 little-endian BLOB in
|
||||
//! `atlas_body_heightmaps.data`. This module loads that BLOB via `rusqlite`
|
||||
//! and reinterprets the bytes into a `Vec<f32>` using `bytemuck`.
|
||||
//!
|
||||
//! Values are normalized elevation in [0.0, 1.0]. `sea_level` is the fraction
|
||||
//! below which terrain is underwater (0.0 = no ocean).
|
||||
//!
|
||||
//! Canonical grid size: 512 × 256 (GRID_W × GRID_H), row-major.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Canonical grid dimensions matching the Python pipeline (generate_atlas.py).
|
||||
pub const GRID_W: u32 = 512;
|
||||
pub const GRID_H: u32 = 256;
|
||||
|
||||
/// A loaded heightmap for one planetary body.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BodyHeightmap {
|
||||
pub body_id: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Row-major elevation values, normalized to [0.0, 1.0].
|
||||
pub data: Vec<f32>,
|
||||
/// Elevation fraction below which terrain is ocean/sea.
|
||||
pub sea_level: f32,
|
||||
}
|
||||
|
||||
impl BodyHeightmap {
|
||||
/// Returns the elevation at (row, col), or `None` if out of bounds.
|
||||
#[inline]
|
||||
pub fn get(&self, row: u32, col: u32) -> Option<f32> {
|
||||
if row < self.height && col < self.width {
|
||||
Some(self.data[(row * self.width + col) as usize])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the cell at (row, col) is land (above sea level).
|
||||
#[inline]
|
||||
pub fn is_land(&self, row: u32, col: u32) -> bool {
|
||||
self.get(row, col).is_some_and(|e| e >= self.sea_level)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum HeightmapLoadError {
|
||||
#[error("no heightmap row for body '{0}'")]
|
||||
NotFound(String),
|
||||
#[error("BLOB size {actual} does not match declared grid {w}×{h}×4 = {expected}")]
|
||||
BlobSizeMismatch {
|
||||
actual: usize,
|
||||
w: u32,
|
||||
h: u32,
|
||||
expected: usize,
|
||||
},
|
||||
#[error("SQLite error: {0}")]
|
||||
Sql(#[from] rusqlite::Error),
|
||||
}
|
||||
|
||||
/// Load the heightmap for `body_id` from the open `conn`.
|
||||
///
|
||||
/// The BLOB is reinterpreted in-place via `bytemuck::cast_slice` — no copy
|
||||
/// beyond the initial `Vec<u8>` read from SQLite. On little-endian hosts
|
||||
/// (all current targets) this is a zero-cost reinterpret. On big-endian hosts
|
||||
/// the bytes are already stored LE, so each f32 would be byte-swapped; this
|
||||
/// function does not perform that swap — big-endian support is deferred.
|
||||
pub fn load_heightmap(
|
||||
conn: &Connection,
|
||||
body_id: &str,
|
||||
) -> Result<BodyHeightmap, HeightmapLoadError> {
|
||||
let result = conn.query_row(
|
||||
"SELECT width, height, data, sea_level \
|
||||
FROM atlas_body_heightmaps WHERE body_id = ?1",
|
||||
params![body_id],
|
||||
|row| {
|
||||
let width: u32 = row.get(0)?;
|
||||
let height: u32 = row.get(1)?;
|
||||
let blob: Vec<u8> = row.get(2)?;
|
||||
let sea_level: f64 = row.get(3)?;
|
||||
Ok((width, height, blob, sea_level as f32))
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
Err(HeightmapLoadError::NotFound(body_id.to_string()))
|
||||
}
|
||||
Err(e) => Err(HeightmapLoadError::Sql(e)),
|
||||
Ok((width, height, blob, sea_level)) => {
|
||||
let expected = (width * height * 4) as usize;
|
||||
if blob.len() != expected {
|
||||
return Err(HeightmapLoadError::BlobSizeMismatch {
|
||||
actual: blob.len(),
|
||||
w: width,
|
||||
h: height,
|
||||
expected,
|
||||
});
|
||||
}
|
||||
// Reinterpret the LE bytes as f32 values. bytemuck::cast_slice
|
||||
// is safe here: we verified the length is a multiple of 4, and
|
||||
// f32 has no invalid bit patterns.
|
||||
let floats: &[f32] = bytemuck::cast_slice(&blob);
|
||||
let data = floats.to_vec();
|
||||
Ok(BodyHeightmap {
|
||||
body_id: body_id.to_string(),
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
sea_level,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn make_test_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE atlas_body_heightmaps (
|
||||
body_id TEXT PRIMARY KEY,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
sea_level REAL NOT NULL DEFAULT 0.0,
|
||||
imported_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn insert_heightmap(conn: &Connection, body_id: &str, w: u32, h: u32, sea_level: f32) {
|
||||
let floats: Vec<f32> = (0..(w * h)).map(|i| i as f32 / (w * h) as f32).collect();
|
||||
let bytes: &[u8] = bytemuck::cast_slice(&floats);
|
||||
conn.execute(
|
||||
"INSERT INTO atlas_body_heightmaps (body_id, width, height, data, sea_level)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![body_id, w, h, bytes, sea_level],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_canonical_size() {
|
||||
let conn = make_test_db();
|
||||
insert_heightmap(&conn, "TestBody", GRID_W, GRID_H, 0.3);
|
||||
let hm = load_heightmap(&conn, "TestBody").unwrap();
|
||||
assert_eq!(hm.width, GRID_W);
|
||||
assert_eq!(hm.height, GRID_H);
|
||||
assert_eq!(hm.data.len(), (GRID_W * GRID_H) as usize);
|
||||
assert!((hm.sea_level - 0.3).abs() < 1e-6);
|
||||
// First cell is 0.0, last approaches 1.0
|
||||
assert_eq!(hm.data[0], 0.0);
|
||||
assert!(hm.data.last().copied().unwrap() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_and_is_land() {
|
||||
let conn = make_test_db();
|
||||
insert_heightmap(&conn, "LandBody", 4, 2, 0.5);
|
||||
let hm = load_heightmap(&conn, "LandBody").unwrap();
|
||||
// First cell (index 0) = 0.0 / 8 = 0.0 — below sea level
|
||||
assert!(!hm.is_land(0, 0));
|
||||
// Last cell (index 7) = 7.0 / 8 = 0.875 — above sea level
|
||||
assert!(hm.is_land(1, 3));
|
||||
// Out-of-bounds returns false
|
||||
assert!(!hm.is_land(99, 99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_error() {
|
||||
let conn = make_test_db();
|
||||
let err = load_heightmap(&conn, "Ghost").unwrap_err();
|
||||
assert!(matches!(err, HeightmapLoadError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_size_mismatch_error() {
|
||||
let conn = make_test_db();
|
||||
// Insert a truncated BLOB
|
||||
conn.execute(
|
||||
"INSERT INTO atlas_body_heightmaps (body_id, width, height, data, sea_level)
|
||||
VALUES ('BadBlob', 4, 4, X'DEADBEEF', 0.0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let err = load_heightmap(&conn, "BadBlob").unwrap_err();
|
||||
assert!(matches!(err, HeightmapLoadError::BlobSizeMismatch { .. }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Atlas data loaders — reads pre-computed build-time data from systems.db.
|
||||
//!
|
||||
//! These loaders are used by the runtime-background tier (D-200, D-206) when
|
||||
//! populating BodyWorldState (D-203). They are never called on the main tick thread.
|
||||
|
||||
pub mod attractor_matching;
|
||||
pub mod block_irregularity;
|
||||
pub mod body_world_state;
|
||||
pub mod district_mix;
|
||||
pub mod drainage;
|
||||
pub mod gen_queue;
|
||||
pub mod heightmap;
|
||||
pub mod rng;
|
||||
pub mod skeleton_gen;
|
||||
pub mod tile_condition;
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Seeded LCG for deterministic generation (D-010).
|
||||
//!
|
||||
//! Shared by all atlas generation modules that need seeded randomness.
|
||||
//! Uses Knuth's LCG parameters — integer-only arithmetic, no f32, D-010 compliant.
|
||||
//!
|
||||
//! Callers are responsible for any seed pre-mixing before calling `AtlasRng::new`.
|
||||
|
||||
/// Seeded linear congruential generator (D-010).
|
||||
pub struct AtlasRng {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl AtlasRng {
|
||||
/// Create a new RNG from a pre-mixed seed.
|
||||
///
|
||||
/// Callers must ensure the seed is non-degenerate (avoid passing 0 directly
|
||||
/// if the seed could realistically be 0 — add a constant before calling).
|
||||
pub fn new(seed: u64) -> Self {
|
||||
Self { state: seed }
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
self.state = self
|
||||
.state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Next pseudorandom `u32` (top 31 bits of the LCG state).
|
||||
pub fn next_u32(&mut self) -> u32 {
|
||||
(self.next_u64() >> 33) as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deterministic_sequence() {
|
||||
let mut a = AtlasRng::new(42);
|
||||
let mut b = AtlasRng::new(42);
|
||||
for _ in 0..100 {
|
||||
assert_eq!(a.next_u32(), b.next_u32());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_seeds_differ() {
|
||||
let mut a = AtlasRng::new(1);
|
||||
let mut b = AtlasRng::new(2);
|
||||
let vals_a: Vec<u32> = (0..10).map(|_| a.next_u32()).collect();
|
||||
let vals_b: Vec<u32> = (0..10).map(|_| b.next_u32()).collect();
|
||||
assert_ne!(vals_a, vals_b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
//! Phase 1 district skeleton generator (D-194, D-196, D-211, D-213, D-214).
|
||||
//!
|
||||
//! Entry point: [`generate_skeleton`]. Consumes a [`CityGenerationContext`]
|
||||
//! together with the city's raw population and economic role, and produces a
|
||||
//! fully classified [`DistrictSkeleton`] with:
|
||||
//!
|
||||
//! - [`SettingType`] derived from the surrounding biome context.
|
||||
//! - [`ComplexityTier`] derived from population tier × [`WorldTier`].
|
||||
//! - [`DistrictLayoutMode`] derived from [`PoliticalArchetype`].
|
||||
//! - 4×4 block grid with [`ZoningType`] assignments from the district-mix
|
||||
//! algorithm (D-194).
|
||||
//! - [`MultiBlockReservation`]s for parks (pop tier ≥ 2) and transit
|
||||
//! terminals (transit_hub role or pop tier ≥ 3).
|
||||
//!
|
||||
//! **Phase 1 scope only** — no chunk-level tiles, no NPC placement, no tile
|
||||
//! condition data. All stub fields (corridors, social_sites, etc.) are empty.
|
||||
//!
|
||||
//! **Determinism (D-010):** Seeded LCG via the district seed; no floating-point
|
||||
//! in block assignment.
|
||||
|
||||
use crate::atlas::block_irregularity::block_irregularity;
|
||||
use crate::atlas::district_mix::{compute_district_mix, population_tier};
|
||||
use crate::atlas::rng::AtlasRng;
|
||||
use crate::simulation::generator::{
|
||||
BlockPlacement, BlockSkeleton, CityGenerationContext, ComplexityTier, DistrictId,
|
||||
DistrictLayoutMode, DistrictSkeleton, DistrictType, MultiBlockReservation, PoliticalArchetype,
|
||||
ReservationFunction, ReservationId, SettingType, WorldTier, ZoningType,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate a Phase 1 [`DistrictSkeleton`] from a city's generation context.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `context`: Build-time city context (archetype, world tier, orientation…).
|
||||
/// - `population`: Raw population count from atlas_city_names.
|
||||
/// - `economic_role`: Economic role string (one of the 10 canonical values).
|
||||
/// - `district_id`: Content-addressable identifier for this district.
|
||||
/// - `founding_age_years`: Years since founding — controls block irregularity.
|
||||
/// - `seed`: Deterministic seed for this district (derived from master seed via SeedChain).
|
||||
pub fn generate_skeleton(
|
||||
context: &CityGenerationContext,
|
||||
population: i64,
|
||||
economic_role: &str,
|
||||
district_id: DistrictId,
|
||||
founding_age_years: u32,
|
||||
seed: u64,
|
||||
) -> DistrictSkeleton {
|
||||
// ── 1. SettingType ────────────────────────────────────────────────────
|
||||
// Pass through the surrounding_biome from context — it already encodes
|
||||
// the planet/station/wilderness classification established at atlas time.
|
||||
let setting = derive_setting(&context.surrounding_biome, economic_role);
|
||||
|
||||
// ── 2. ComplexityTier ─────────────────────────────────────────────────
|
||||
let tier = population_tier(population);
|
||||
let complexity = derive_complexity(&context.world_tier, tier, population);
|
||||
|
||||
// ── 3. DistrictLayoutMode ─────────────────────────────────────────────
|
||||
let irregularity = block_irregularity(founding_age_years, &context.political_archetype);
|
||||
let layout_mode = derive_layout_mode(&context.political_archetype, irregularity, seed);
|
||||
|
||||
// ── 4. District mix → block grid ─────────────────────────────────────
|
||||
// A single district occupies a 4×4 block grid = 16 blocks.
|
||||
let total_blocks: u32 = 16;
|
||||
let mix = compute_district_mix(
|
||||
population,
|
||||
economic_role,
|
||||
&context.political_archetype,
|
||||
total_blocks,
|
||||
seed,
|
||||
);
|
||||
|
||||
// ── 5. Multi-block reservations ───────────────────────────────────────
|
||||
let reservations = derive_reservations(tier, economic_role, seed);
|
||||
|
||||
// Build the reservation lookup: block position → reservation id.
|
||||
let mut block_reservation: [[Option<ReservationId>; 4]; 4] = [[None, None, None, None]; 4];
|
||||
for (idx, res) in reservations.iter().enumerate() {
|
||||
let rid = idx as u64 + 1; // 1-based stable id within this district
|
||||
for &(row, col) in &res.blocks {
|
||||
let r = row as usize;
|
||||
let c = col as usize;
|
||||
if r < 4 && c < 4 {
|
||||
block_reservation[r][c] = Some(rid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build 4×4 block grid ──────────────────────────────────────────────
|
||||
// Flat district-mix list is already in deterministic order; assign
|
||||
// row-major (row 0 col 0 → row 0 col 3 → row 1 col 0 …).
|
||||
let primary_district_type = mix
|
||||
.districts
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or(DistrictType::MixedUse);
|
||||
let blocks = build_block_grid(&mix.districts, &block_reservation, &primary_district_type);
|
||||
|
||||
// ── Compute z_levels ──────────────────────────────────────────────────
|
||||
// Phase 1: single-storey above ground for all non-reserved blocks.
|
||||
// Reserved blocks carry their own z_levels count.
|
||||
let z_levels: u8 = 1;
|
||||
|
||||
DistrictSkeleton {
|
||||
district_id,
|
||||
seed,
|
||||
district_type: district_type_from_mix(&primary_district_type),
|
||||
context: String::new(), // stub — DistrictContext = String
|
||||
world_tier: context.world_tier.clone(),
|
||||
complexity,
|
||||
setting,
|
||||
layout_mode,
|
||||
blocks,
|
||||
reservations,
|
||||
corridors: Vec::new(),
|
||||
z_levels,
|
||||
social_sites: Vec::new(),
|
||||
access_points: Vec::new(),
|
||||
society_profile: String::new(),
|
||||
zone_palette: Vec::new(),
|
||||
boundaries: String::new(),
|
||||
guarantee_audit: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SettingType derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive SettingType from the city's surrounding biome context.
|
||||
///
|
||||
/// The surrounding_biome on CityGenerationContext already encodes the
|
||||
/// planet/station classification. For city districts we map it to
|
||||
/// Urban (the default for settled cities) or pass Station/Maritime/etc.
|
||||
/// through directly.
|
||||
fn derive_setting(surrounding_biome: &SettingType, economic_role: &str) -> SettingType {
|
||||
match surrounding_biome {
|
||||
// Station bodies → always Station setting regardless of role.
|
||||
SettingType::Station => SettingType::Station,
|
||||
// Orbital platforms.
|
||||
SettingType::Orbital => SettingType::Orbital,
|
||||
// Maritime worlds — coastal city districts are Maritime.
|
||||
SettingType::Maritime => SettingType::Maritime,
|
||||
// Agricultural worlds → Agricultural districts.
|
||||
SettingType::Agricultural => SettingType::Agricultural,
|
||||
// For all other planet classes, city districts are Urban.
|
||||
// Exception: extraction role on wilderness worlds → Specialized.
|
||||
SettingType::Wilderness { biome } => {
|
||||
if economic_role == "extraction" {
|
||||
SettingType::Specialized {
|
||||
function: format!("extraction-{biome}"),
|
||||
}
|
||||
} else {
|
||||
SettingType::Urban
|
||||
}
|
||||
}
|
||||
// Transit nodes get Transitional setting.
|
||||
SettingType::Transitional => SettingType::Transitional,
|
||||
// Water bodies → Water districts don't host cities; treat as Specialized.
|
||||
SettingType::Water { .. } => SettingType::Specialized {
|
||||
function: "waterfront".into(),
|
||||
},
|
||||
// Generic Specialized pass-through.
|
||||
SettingType::Specialized { function } => SettingType::Specialized {
|
||||
function: function.clone(),
|
||||
},
|
||||
// Default for Urban and any unknown variant: Urban.
|
||||
SettingType::Urban => SettingType::Urban,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ComplexityTier derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive ComplexityTier from WorldTier + population tier (D-194, D-218).
|
||||
///
|
||||
/// Backwater is "NOT budget-capped" per D-218 — it joins Epicenter/Regional
|
||||
/// at Full complexity rather than being capped at Moderate like Passage.
|
||||
///
|
||||
/// | WorldTier | pop_tier ≥ 1 | pop_tier = 0 |
|
||||
/// |-----------------|---------------|----------------------|
|
||||
/// | Epicenter | Full | Moderate |
|
||||
/// | Regional | Full | Moderate |
|
||||
/// | Backwater | Full | Moderate |
|
||||
/// | Passage | Moderate | Minimal |
|
||||
/// | Waypoint | Minimal | Minimal (→ Empty <5K)|
|
||||
fn derive_complexity(world_tier: &WorldTier, pop_tier: u8, population: i64) -> ComplexityTier {
|
||||
// Ghost stub threshold: pop < 5000 on Waypoint → Empty.
|
||||
if population < 5_000 && matches!(world_tier, WorldTier::Waypoint) {
|
||||
return ComplexityTier::Empty;
|
||||
}
|
||||
|
||||
match world_tier {
|
||||
WorldTier::Epicenter | WorldTier::Regional | WorldTier::Backwater => {
|
||||
if pop_tier >= 1 {
|
||||
ComplexityTier::Full
|
||||
} else {
|
||||
ComplexityTier::Moderate
|
||||
}
|
||||
}
|
||||
WorldTier::Passage => {
|
||||
if pop_tier >= 1 {
|
||||
ComplexityTier::Moderate
|
||||
} else {
|
||||
ComplexityTier::Minimal
|
||||
}
|
||||
}
|
||||
WorldTier::Waypoint => ComplexityTier::Minimal,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DistrictLayoutMode derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive DistrictLayoutMode from PoliticalArchetype + block irregularity (D-213, D-214).
|
||||
///
|
||||
/// Commission / Military / Corporate / Academic → Grid (planned geometry).
|
||||
/// Pioneer / Industrial → Organic (organic growth with per-block offsets).
|
||||
fn derive_layout_mode(
|
||||
archetype: &PoliticalArchetype,
|
||||
irregularity: f32,
|
||||
seed: u64,
|
||||
) -> DistrictLayoutMode {
|
||||
match archetype {
|
||||
PoliticalArchetype::Commission
|
||||
| PoliticalArchetype::Military
|
||||
| PoliticalArchetype::Corporate
|
||||
| PoliticalArchetype::Academic => DistrictLayoutMode::Grid,
|
||||
|
||||
PoliticalArchetype::Pioneer | PoliticalArchetype::Industrial => {
|
||||
// Organic: generate per-block offsets and rotations seeded from district seed.
|
||||
let placements = organic_placements(irregularity, seed);
|
||||
DistrictLayoutMode::Organic { placements }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate 4×4 organic block placements seeded deterministically (D-010).
|
||||
///
|
||||
/// Uses a seeded LCG; offset range controlled by `irregularity` (0.05–1.0)
|
||||
/// scaled to the ±16 sim tile maximum from `block_irregularity::max_offset_sim_tiles`.
|
||||
fn organic_placements(irregularity: f32, seed: u64) -> [[BlockPlacement; 4]; 4] {
|
||||
let max_offset = (irregularity * 16.0) as i16;
|
||||
let mut lcg = AtlasRng::new(seed.wrapping_add(0x9e37_79b9_7f4a_7c15));
|
||||
|
||||
// Build the 2D array using a flat closure to keep things readable.
|
||||
let mut flat: [BlockPlacement; 16] = core::array::from_fn(|_| BlockPlacement {
|
||||
offset: (0, 0),
|
||||
rotation_steps: 0,
|
||||
street_width_bps: 10_000,
|
||||
});
|
||||
|
||||
for item in flat.iter_mut() {
|
||||
let raw_x = (lcg.next_u32() % (2 * max_offset as u32 + 1)) as i16 - max_offset;
|
||||
let raw_y = (lcg.next_u32() % (2 * max_offset as u32 + 1)) as i16 - max_offset;
|
||||
let rot = (lcg.next_u32() % 4) as u8; // 0–3 (15° increments, max 45°)
|
||||
// Street width 7500–20000 bps proportional to irregularity.
|
||||
let width_range = 12_500u32; // 20000 - 7500
|
||||
let width = 7_500u32 + (lcg.next_u32() % (width_range + 1));
|
||||
*item = BlockPlacement {
|
||||
offset: (raw_x, raw_y),
|
||||
rotation_steps: rot,
|
||||
street_width_bps: width as u16,
|
||||
};
|
||||
}
|
||||
|
||||
core::array::from_fn(|row| core::array::from_fn(|col| flat[row * 4 + col].clone()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Block grid construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Map a DistrictType to its primary ZoningType (D-194).
|
||||
fn zoning_for_district(dt: &DistrictType) -> ZoningType {
|
||||
match dt {
|
||||
DistrictType::LogisticsHub => ZoningType::Industrial,
|
||||
DistrictType::Residential => ZoningType::Residential,
|
||||
DistrictType::Commercial => ZoningType::Commercial,
|
||||
DistrictType::Industrial => ZoningType::Industrial,
|
||||
DistrictType::Administrative => ZoningType::Administrative,
|
||||
DistrictType::Entertainment => ZoningType::Commercial,
|
||||
DistrictType::MixedUse => ZoningType::Mixed,
|
||||
DistrictType::Transit => ZoningType::Transit,
|
||||
DistrictType::Specialized => ZoningType::Restricted,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the primary district type to the DistrictType field on DistrictSkeleton.
|
||||
fn district_type_from_mix(primary: &DistrictType) -> DistrictType {
|
||||
primary.clone()
|
||||
}
|
||||
|
||||
/// Build the 4×4 BlockSkeleton grid from the district mix list and reservation map.
|
||||
///
|
||||
/// Blocks are assigned row-major (index = row * 4 + col).
|
||||
/// Reserved blocks retain their zoning from the district mix but link to the reservation.
|
||||
fn build_block_grid(
|
||||
districts: &[DistrictType],
|
||||
block_reservation: &[[Option<ReservationId>; 4]; 4],
|
||||
primary: &DistrictType,
|
||||
) -> [[BlockSkeleton; 4]; 4] {
|
||||
// Pad or truncate district list to exactly 16.
|
||||
let district_iter: Vec<&DistrictType> = (0..16)
|
||||
.map(|i| districts.get(i).unwrap_or(primary))
|
||||
.collect();
|
||||
|
||||
core::array::from_fn(|row| {
|
||||
core::array::from_fn(|col| {
|
||||
let idx = row * 4 + col;
|
||||
let dt = district_iter[idx];
|
||||
let zoning = zoning_for_district(dt);
|
||||
let reservation = block_reservation[row][col];
|
||||
|
||||
let density = density_for_zoning(&zoning);
|
||||
BlockSkeleton {
|
||||
position: (row as u8, col as u8),
|
||||
zoning,
|
||||
reservation,
|
||||
chunk_layout: String::new(), // stub
|
||||
hosted_sites: Vec::new(),
|
||||
era: String::new(), // stub
|
||||
era_modifications: Vec::new(),
|
||||
era_cause: None,
|
||||
density_pct: density,
|
||||
landmark: None,
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Default build density percentage for a zoning type.
|
||||
fn density_for_zoning(zoning: &ZoningType) -> u8 {
|
||||
match zoning {
|
||||
ZoningType::Residential => 60,
|
||||
ZoningType::Commercial => 80,
|
||||
ZoningType::Industrial => 70,
|
||||
ZoningType::Administrative => 75,
|
||||
ZoningType::Transit => 50,
|
||||
ZoningType::Recreational => 30,
|
||||
ZoningType::Restricted => 85,
|
||||
ZoningType::Mixed => 65,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-block reservations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive Phase 1 multi-block reservations for a city (D-211, D-194).
|
||||
///
|
||||
/// Reservation rules:
|
||||
/// - Pop tier ≥ 2 → one 2×2 park reservation at the center-right (blocks (1,2),(1,3),(2,2),(2,3)).
|
||||
/// - Transit_hub role OR pop tier ≥ 3 → one 1×2 transit terminal at row 0 cols 0–1.
|
||||
///
|
||||
/// Phase 1 produces skeleton-only reservations — floor_zones and vertical_corridors
|
||||
/// are deferred to Phase 2.
|
||||
fn derive_reservations(
|
||||
pop_tier: u8,
|
||||
economic_role: &str,
|
||||
_seed: u64,
|
||||
) -> Vec<MultiBlockReservation> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
// Park: large cities need open space.
|
||||
if pop_tier >= 2 {
|
||||
out.push(MultiBlockReservation {
|
||||
blocks: vec![(1, 2), (1, 3), (2, 2), (2, 3)],
|
||||
template_tag: "park-central".into(),
|
||||
function: ReservationFunction::Park,
|
||||
z_levels: 1,
|
||||
base_z: 0,
|
||||
floor_zones: Vec::new(),
|
||||
z_band_count: 1,
|
||||
z_band_zones: Vec::new(),
|
||||
vertical_corridors: Vec::new(),
|
||||
hosted_sites: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Transit terminal: transit-hub economies and major cities.
|
||||
if economic_role == "transit_hub" || pop_tier >= 3 {
|
||||
out.push(MultiBlockReservation {
|
||||
blocks: vec![(0, 0), (0, 1)],
|
||||
template_tag: "transit-terminal".into(),
|
||||
function: ReservationFunction::Terminal,
|
||||
z_levels: 2,
|
||||
base_z: -1, // one level of underground rail
|
||||
floor_zones: Vec::new(),
|
||||
z_band_count: 2,
|
||||
z_band_zones: Vec::new(),
|
||||
vertical_corridors: Vec::new(),
|
||||
hosted_sites: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::simulation::generator::{
|
||||
CityGenerationContext, FoundingOrientation, PoliticalArchetype, SettingType, WorldTier,
|
||||
};
|
||||
|
||||
fn make_context(archetype: PoliticalArchetype, world_tier: WorldTier) -> CityGenerationContext {
|
||||
CityGenerationContext {
|
||||
city_id: 1,
|
||||
political_archetype: archetype,
|
||||
prosperity_baseline: 0.7,
|
||||
surrounding_biome: SettingType::Urban,
|
||||
road_entry_directions: vec![0, 4],
|
||||
footprint_radius_km: 10.0,
|
||||
founding_orientation: FoundingOrientation::Cardinal,
|
||||
world_tier,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_station_passthrough() {
|
||||
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
ctx.surrounding_biome = SettingType::Station;
|
||||
let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 200, 42);
|
||||
assert!(matches!(sk.setting, SettingType::Station));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_urban_for_city_on_planet() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, 42);
|
||||
assert!(matches!(sk.setting, SettingType::Urban));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complexity_epicenter_high_pop_is_full() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
// 10M pop → pop_tier = 1 → Full on Epicenter
|
||||
let sk = generate_skeleton(&ctx, 10_000_000, "financial", 1, 200, 42);
|
||||
assert_eq!(sk.complexity, ComplexityTier::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complexity_waypoint_tiny_pop_is_empty() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Waypoint);
|
||||
let sk = generate_skeleton(&ctx, 1_000, "residential", 1, 50, 42);
|
||||
assert_eq!(sk.complexity, ComplexityTier::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complexity_backwater_low_pop_is_moderate() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
|
||||
// 50_000 pop → pop_tier = 0 → Moderate on Backwater (D-218: not budget-capped)
|
||||
let sk = generate_skeleton(&ctx, 50_000, "residential", 1, 50, 42);
|
||||
assert_eq!(sk.complexity, ComplexityTier::Moderate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complexity_backwater_high_pop_is_full() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
|
||||
// 10M pop → pop_tier = 1 → Full on Backwater (D-218: not budget-capped)
|
||||
let sk = generate_skeleton(&ctx, 10_000_000, "residential", 1, 50, 42);
|
||||
assert_eq!(sk.complexity, ComplexityTier::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complexity_passage_low_pop_is_minimal() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Passage);
|
||||
// 50_000 pop → pop_tier = 0 → Minimal on Passage (transit stop, budget-capped)
|
||||
let sk = generate_skeleton(&ctx, 50_000, "transit_hub", 1, 100, 42);
|
||||
assert_eq!(sk.complexity, ComplexityTier::Minimal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_commission_is_grid() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 100, 99);
|
||||
assert!(matches!(sk.layout_mode, DistrictLayoutMode::Grid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_pioneer_is_organic() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "residential", 1, 400, 99);
|
||||
assert!(matches!(sk.layout_mode, DistrictLayoutMode::Organic { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_grid_is_fully_populated() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, 42);
|
||||
// All 16 blocks must have valid positions.
|
||||
for row in 0..4 {
|
||||
for col in 0..4 {
|
||||
let b = &sk.blocks[row][col];
|
||||
assert_eq!(b.position, (row as u8, col as u8));
|
||||
assert!(b.density_pct <= 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_reservations_for_small_city() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
|
||||
// pop_tier 0, not transit_hub → no reservations.
|
||||
let sk = generate_skeleton(&ctx, 80_000, "residential", 1, 50, 42);
|
||||
assert!(sk.reservations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn park_reservation_for_large_city() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
// 100M pop → pop_tier 2 → park reservation.
|
||||
let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, 42);
|
||||
let has_park = sk
|
||||
.reservations
|
||||
.iter()
|
||||
.any(|r| matches!(r.function, ReservationFunction::Park));
|
||||
assert!(has_park);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transit_terminal_for_transit_hub_role() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
// pop_tier 0 but transit_hub → terminal reservation.
|
||||
let sk = generate_skeleton(&ctx, 80_000, "transit_hub", 1, 200, 42);
|
||||
let has_terminal = sk
|
||||
.reservations
|
||||
.iter()
|
||||
.any(|r| matches!(r.function, ReservationFunction::Terminal));
|
||||
assert!(has_terminal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_blocks_linked_in_grid() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
// 100M pop → park at (1,2),(1,3),(2,2),(2,3) with reservation id 1.
|
||||
let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, 42);
|
||||
// All park blocks must reference the park reservation (id=1).
|
||||
for &(row, col) in &[(1u8, 2u8), (1, 3), (2, 2), (2, 3)] {
|
||||
let b = &sk.blocks[row as usize][col as usize];
|
||||
assert!(
|
||||
b.reservation.is_some(),
|
||||
"block ({row},{col}) should be reserved"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determinism_same_seed_same_output() {
|
||||
let ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional);
|
||||
let sk1 = generate_skeleton(&ctx, 2_000_000, "manufacturing", 77, 250, 12345);
|
||||
let sk2 = generate_skeleton(&ctx, 2_000_000, "manufacturing", 77, 250, 12345);
|
||||
// Compare block grid zoning and positions.
|
||||
for row in 0..4 {
|
||||
for col in 0..4 {
|
||||
assert_eq!(sk1.blocks[row][col].zoning, sk2.blocks[row][col].zoning);
|
||||
assert_eq!(sk1.blocks[row][col].position, sk2.blocks[row][col].position);
|
||||
assert_eq!(
|
||||
sk1.blocks[row][col].density_pct,
|
||||
sk2.blocks[row][col].density_pct
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(sk1.reservations.len(), sk2.reservations.len());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Tile condition thresholds and derivation (D-217).
|
||||
//!
|
||||
//! A tile's visual condition is derived from the district's `prosperity_score`
|
||||
//! (0.0–1.0) using four threshold bands. The block's `EraCause` applies a
|
||||
//! minimum condition floor that prevents high-prosperity scores from masking
|
||||
//! historical decay.
|
||||
//!
|
||||
//! **Threshold bands (D-217):**
|
||||
//! | Band | Condition | prosperity_score |
|
||||
//! |------|-----------|-----------------|
|
||||
//! | 1 | Intact | > 0.63 |
|
||||
//! | 2 | Worn | 0.43 – 0.63 |
|
||||
//! | 3 | Cracked | 0.23 – 0.43 |
|
||||
//! | 4 | Broken | < 0.23 |
|
||||
//!
|
||||
//! **Era-based floor (D-217):**
|
||||
//! - `EconomicDisruption` (Decay-era): minimum Cracked.
|
||||
//! - `EmergencyExtension`: minimum Worn.
|
||||
//! - All other eras: no floor — condition follows prosperity_score freely.
|
||||
//!
|
||||
//! **Threshold crossing invalidation:** A tile's condition only changes when
|
||||
//! `prosperity_score` crosses a band boundary. Checked once per game-minute.
|
||||
//!
|
||||
//! Threshold values are authored constants (D-217): 0.63, 0.43, 0.23.
|
||||
|
||||
use crate::simulation::generator::EraCause;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TileCondition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Visual condition band for a tile, derived from prosperity_score (D-217).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum TileCondition {
|
||||
/// prosperity_score > 0.63. Clean, undamaged, well-maintained.
|
||||
Intact,
|
||||
/// prosperity_score 0.43–0.63. Scuff marks, minor discoloration, partial repairs.
|
||||
Worn,
|
||||
/// prosperity_score 0.23–0.43. Visible damage, incomplete repair, graffiti.
|
||||
Cracked,
|
||||
/// prosperity_score < 0.23. Structural damage, debris, derelict appearance.
|
||||
Broken,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Threshold constants (D-217 authored — do not compute at runtime)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const THRESHOLD_INTACT: f32 = 0.63;
|
||||
pub const THRESHOLD_WORN: f32 = 0.43;
|
||||
pub const THRESHOLD_CRACKED: f32 = 0.23;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive `TileCondition` from `prosperity_score` alone (no era floor).
|
||||
pub fn condition_from_score(prosperity_score: f32) -> TileCondition {
|
||||
if prosperity_score > THRESHOLD_INTACT {
|
||||
TileCondition::Intact
|
||||
} else if prosperity_score > THRESHOLD_WORN {
|
||||
TileCondition::Worn
|
||||
} else if prosperity_score > THRESHOLD_CRACKED {
|
||||
TileCondition::Cracked
|
||||
} else {
|
||||
TileCondition::Broken
|
||||
}
|
||||
}
|
||||
|
||||
/// Era-based minimum condition floor (D-217).
|
||||
///
|
||||
/// Returns the minimum `TileCondition` for a block with the given `EraCause`.
|
||||
/// `None` means no floor — condition follows prosperity_score freely.
|
||||
pub fn era_condition_floor(era_cause: Option<&EraCause>) -> Option<TileCondition> {
|
||||
match era_cause {
|
||||
Some(EraCause::EconomicDisruption) => Some(TileCondition::Cracked),
|
||||
Some(EraCause::EmergencyExtension) => Some(TileCondition::Worn),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive `TileCondition` with era-based floor applied.
|
||||
///
|
||||
/// If the era floor is stricter (lower condition) than the score-derived
|
||||
/// condition, the floor wins.
|
||||
pub fn tile_condition(prosperity_score: f32, era_cause: Option<&EraCause>) -> TileCondition {
|
||||
let from_score = condition_from_score(prosperity_score);
|
||||
match era_condition_floor(era_cause) {
|
||||
Some(floor) => {
|
||||
// Lower enum discriminant = better condition (Intact < Worn < Cracked < Broken).
|
||||
// Floor is a *minimum degradation* — we want the worse of the two.
|
||||
if floor > from_score {
|
||||
floor
|
||||
} else {
|
||||
from_score
|
||||
}
|
||||
}
|
||||
None => from_score,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a threshold crossing occurred between two prosperity scores.
|
||||
///
|
||||
/// Returns `true` if the tile's condition band changed between `old_score` and
|
||||
/// `new_score`. Used by the game-minute update loop to decide whether to
|
||||
/// apply a `ChunkMutation.tile_override`.
|
||||
pub fn threshold_crossed(old_score: f32, new_score: f32) -> bool {
|
||||
condition_from_score(old_score) != condition_from_score(new_score)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn intact_above_0_63() {
|
||||
assert_eq!(condition_from_score(0.64), TileCondition::Intact);
|
||||
assert_eq!(condition_from_score(1.0), TileCondition::Intact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worn_between_0_43_and_0_63() {
|
||||
assert_eq!(condition_from_score(0.63), TileCondition::Worn);
|
||||
assert_eq!(condition_from_score(0.50), TileCondition::Worn);
|
||||
assert_eq!(condition_from_score(0.44), TileCondition::Worn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cracked_between_0_23_and_0_43() {
|
||||
assert_eq!(condition_from_score(0.43), TileCondition::Cracked);
|
||||
assert_eq!(condition_from_score(0.30), TileCondition::Cracked);
|
||||
assert_eq!(condition_from_score(0.24), TileCondition::Cracked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_below_0_23() {
|
||||
assert_eq!(condition_from_score(0.23), TileCondition::Broken);
|
||||
assert_eq!(condition_from_score(0.10), TileCondition::Broken);
|
||||
assert_eq!(condition_from_score(0.0), TileCondition::Broken);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn era_floor_decay_enforces_cracked_minimum() {
|
||||
// Prosperous district in an EconomicDisruption-era block — still Cracked.
|
||||
let cond = tile_condition(0.90, Some(&EraCause::EconomicDisruption));
|
||||
assert_eq!(
|
||||
cond,
|
||||
TileCondition::Cracked,
|
||||
"EconomicDisruption floor must prevent Intact/Worn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn era_floor_emergency_extension_enforces_worn_minimum() {
|
||||
// High prosperity EmergencyExtension block should never be Intact.
|
||||
let cond = tile_condition(0.80, Some(&EraCause::EmergencyExtension));
|
||||
assert_eq!(cond, TileCondition::Worn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn era_floor_does_not_improve_condition() {
|
||||
// EconomicDisruption floor = Cracked; Broken score stays Broken.
|
||||
let cond = tile_condition(0.10, Some(&EraCause::EconomicDisruption));
|
||||
assert_eq!(
|
||||
cond,
|
||||
TileCondition::Broken,
|
||||
"Era floor must not improve condition below score-derived value"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_era_cause_follows_score() {
|
||||
let cond = tile_condition(0.90, None);
|
||||
assert_eq!(cond, TileCondition::Intact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_crossed_detects_band_change() {
|
||||
// 0.7 → 0.5 crosses the 0.63 boundary.
|
||||
assert!(threshold_crossed(0.70, 0.50));
|
||||
// 0.55 → 0.48 stays in Worn band.
|
||||
assert!(!threshold_crossed(0.55, 0.48));
|
||||
// 0.40 → 0.20 crosses 0.23 boundary.
|
||||
assert!(threshold_crossed(0.40, 0.20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn condition_ordering_intact_is_best() {
|
||||
assert!(TileCondition::Intact < TileCondition::Worn);
|
||||
assert!(TileCondition::Worn < TileCondition::Cracked);
|
||||
assert!(TileCondition::Cracked < TileCondition::Broken);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// The Settled Reach - Simulation Server
|
||||
// Rust/bevy_ecs simulation server for D-010 client-server architecture
|
||||
|
||||
pub mod atlas;
|
||||
pub mod bookmark;
|
||||
pub mod bridge;
|
||||
pub mod cause_chain;
|
||||
|
||||
@@ -299,13 +299,19 @@ pub struct EconQueryBuffer {
|
||||
pub fn serve_econ_state_query(
|
||||
mut query_buf: ResMut<EconQueryBuffer>,
|
||||
econ_state: Option<Res<EconStateResource>>,
|
||||
mut snapshot_buf: ResMut<SnapshotBuffer>,
|
||||
snapshot_buf: Option<ResMut<SnapshotBuffer>>,
|
||||
) {
|
||||
let system_id = match query_buf.pending.take() {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// SnapshotBuffer only exists when BridgePlugin is loaded (not in standalone tests).
|
||||
let mut snapshot_buf = match snapshot_buf {
|
||||
Some(b) => b,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let econ_state = match econ_state {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
|
||||
@@ -100,15 +100,19 @@ pub type PlacedObject = String;
|
||||
/// Network importance of a world in the galaxy.
|
||||
/// Determines simulation fidelity budget and NPC complexity ceiling.
|
||||
///
|
||||
/// Source: tyre-round4.md §2.1, workshop-outcomes.md
|
||||
/// Source: D-218, workshop-outcomes.md
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum WorldTier {
|
||||
/// Background system — minimal simulation, sparse NPCs. Pure environmental.
|
||||
Peripheral,
|
||||
/// Standard Settled Reach system — full simulation, complex social sites.
|
||||
Connected,
|
||||
/// Major hub — maximum fidelity, multi-faction politics, all triangle types.
|
||||
Core,
|
||||
/// Hub system. Full simulation, high faction pressure.
|
||||
Epicenter,
|
||||
/// Regional system. 1–4 districts, partial full-budget simulation.
|
||||
Regional,
|
||||
/// Small community. 1 district. Network-insignificant, NOT budget-capped.
|
||||
Backwater,
|
||||
/// Transit stop. Pass-through node. Moderate complexity ceiling.
|
||||
Passage,
|
||||
/// Not simulated until player approaches. Minimal complexity ceiling.
|
||||
Waypoint,
|
||||
}
|
||||
|
||||
/// Generator content budget for a district.
|
||||
@@ -288,6 +292,147 @@ pub enum EraCause {
|
||||
CulturalShift,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settlement classification enums (D-196, D-212, D-213, D-214, D-215)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// How a settlement enters and exits active simulation.
|
||||
/// Controls whether generation runs, and at what complexity level.
|
||||
/// Source: D-196
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SettlementClass {
|
||||
/// Named in wiki; always active regardless of population threshold.
|
||||
NameLocked,
|
||||
/// Active if pop ≥ 50_000; ghost stub if pop < 5_000.
|
||||
PopulationBudget,
|
||||
/// Active only while the triggering economic condition holds.
|
||||
EconomicTriggered,
|
||||
/// Emergent settlement not in atlas at generation time; written during simulation.
|
||||
OrganicGrowth,
|
||||
}
|
||||
|
||||
/// Dominant power structure of a settlement and its physical spatial expression.
|
||||
/// Derived from TerritorialStatus + economic_role at generation time.
|
||||
/// Source: D-214
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PoliticalArchetype {
|
||||
/// Top-down Commission planning; rectilinear, institutional core, radial-core arrangement.
|
||||
Commission,
|
||||
/// Corp-dominated; commercial density, restricted campus blocks, restricted-perimeter adjacent.
|
||||
Corporate,
|
||||
/// Self-organized; organic growth, mixed use, ribbon arrangement.
|
||||
Pioneer,
|
||||
/// Garrison or fortification origin; defensible geometry, fortified-perimeter arrangement.
|
||||
Military,
|
||||
/// University or research origin; campus-quad structure, green space, radial-core arrangement.
|
||||
Academic,
|
||||
/// Factory-first; large-footprint industrial blocks, worker residential rings, ribbon arrangement.
|
||||
Industrial,
|
||||
}
|
||||
|
||||
/// Primary spatial axis of a city's original street grid.
|
||||
/// Derived from the matched attractor type (D-211). Controls district grid rotation.
|
||||
/// Source: D-213
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub enum FoundingOrientation {
|
||||
/// Street grid perpendicular to coastline. `facing_degrees`: compass bearing toward water (0–359).
|
||||
Coastal { facing_degrees: u16 },
|
||||
/// Street grid parallel to founding river. `bearing_degrees`: river flow direction (0–359).
|
||||
RiverAligned { bearing_degrees: u16 },
|
||||
/// Grid rotated to follow local contours (valley floor settlements).
|
||||
TerrainFollowing,
|
||||
/// Grid aligned to cardinal N/S/E/W (Commission-planned settlements on flat terrain).
|
||||
Cardinal,
|
||||
/// Arbitrary bearing (pioneer settlements on open terrain). `bearing_degrees`: 0–359.
|
||||
Free { bearing_degrees: u16 },
|
||||
}
|
||||
|
||||
/// Territory control status for a province (drainage basin). Priority-ordered derivation.
|
||||
/// Source: D-212
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TerritorialStatus {
|
||||
/// Commission faction_influence ≥ 0.6 in this province.
|
||||
CommissionControlled,
|
||||
/// Single corporation faction_influence ≥ 0.5.
|
||||
CorpTerritory,
|
||||
/// Two or more factions each ≥ 0.3; no dominant faction.
|
||||
ContestedZone,
|
||||
/// No faction with influence ≥ 0.2.
|
||||
FrontierUnclaimed,
|
||||
/// Cultural corridor has indigenous autonomy flag.
|
||||
IndigenousHeld,
|
||||
/// Population density < 0.01 AND no faction ≥ 0.1.
|
||||
Derelict,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attractor types for settlement placement (D-195, D-209, D-211)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The type of terrain feature that attracts settlement placement.
|
||||
/// Source: D-195, D-209
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AttractorType {
|
||||
/// Where a river meets sea level or coastline. Historically high-value.
|
||||
RiverMouth,
|
||||
/// Proximity to coast without a river mouth. Port access.
|
||||
CoastalAccess,
|
||||
/// Where a river crosses a topographic saddle or confluence point.
|
||||
RiverCrossing,
|
||||
/// Local elevation minimum; flat, arable, sheltered.
|
||||
ValleyFloor,
|
||||
/// Saddle point between adjacent drainage basins; controls a mountain pass.
|
||||
PassEntrance,
|
||||
/// Adjacent to a lake polygon.
|
||||
LakeShore,
|
||||
/// Flat terrain away from all other attractors; fallback for plains settlements.
|
||||
PlainCenter,
|
||||
}
|
||||
|
||||
/// A terrain feature at a specific map position that influences city placement scoring.
|
||||
/// Source: D-195, D-209
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct GeographicAttractor {
|
||||
/// Pixel position in heightmap space [row, col].
|
||||
pub position: (u16, u16),
|
||||
pub attractor_type: AttractorType,
|
||||
/// Normalized strength 0.0–1.0. Derived from flow accumulation or habitability score.
|
||||
pub strength: f32,
|
||||
}
|
||||
|
||||
/// Compatibility weights between economic roles and attractor types.
|
||||
/// A 10×7 matrix (10 economic_role values × 7 AttractorType variants).
|
||||
/// Each cell is a weight multiplier 0.0–3.0 applied during attractor-matching scoring.
|
||||
/// Source: D-195
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct CompatibilityMatrix {
|
||||
/// Row order: manufacturing, financial, agricultural, extraction, service_mixed,
|
||||
/// institutional, transit_hub, research, military, residential.
|
||||
/// Column order: RiverMouth, CoastalAccess, RiverCrossing, ValleyFloor,
|
||||
/// PassEntrance, LakeShore, PlainCenter.
|
||||
pub weights: [[f32; 7]; 10],
|
||||
}
|
||||
|
||||
/// Data contract between build-time (systems.db) and the runtime-background
|
||||
/// generation tier. Populated from atlas_city_names + bodies at generation
|
||||
/// dispatch time. All 8 fields are required before a generation task may run.
|
||||
/// Source: D-200, D-199
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct CityGenerationContext {
|
||||
/// Foreign key into atlas_city_names.id
|
||||
pub city_id: u64,
|
||||
pub political_archetype: PoliticalArchetype,
|
||||
/// Starting economic health seed (0.0–1.0). Derived per D-197.
|
||||
pub prosperity_baseline: f32,
|
||||
pub surrounding_biome: SettingType,
|
||||
/// Compass octants (0=N, 1=NE … 7=NW) where roads enter the city footprint.
|
||||
pub road_entry_directions: Vec<u8>,
|
||||
/// City footprint radius in km. Derived from body_radius_km (D-204) + population.
|
||||
pub footprint_radius_km: f32,
|
||||
pub founding_orientation: FoundingOrientation,
|
||||
pub world_tier: WorldTier,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supporting structs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod modification;
|
||||
pub mod monologue;
|
||||
pub mod movement;
|
||||
pub mod movement_plugin;
|
||||
pub mod name_index;
|
||||
pub mod npc_components;
|
||||
pub mod npc_knowledge_transfer;
|
||||
pub mod path_follow;
|
||||
@@ -59,6 +60,10 @@ pub struct SimulationPlugin {
|
||||
|
||||
impl Plugin for SimulationPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// Phase ordering must be configured before any system is registered.
|
||||
// TickPhase::configure is idempotent — safe if main.rs calls it again.
|
||||
crate::tick_phases::TickPhase::configure(app);
|
||||
|
||||
// Tier marker components (D-026) — must register before behavior systems
|
||||
app.add_plugins(tier::TierPlugin);
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
//! SystemNameIndex — Aho-Corasick automaton for event-driven pre-generation (D-206).
|
||||
//!
|
||||
//! Loaded once at startup from `systems.db`. Scans NPC dialogue and news ticker
|
||||
//! text; any match names a body_id to enqueue for background generation at Low
|
||||
//! priority (D-206 §event-driven pre-generation).
|
||||
//!
|
||||
//! The automaton is case-insensitive and matches overlapping patterns so that
|
||||
//! "New Chengdu" and "Chengdu" both fire independently when present.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind};
|
||||
use bevy_ecs::prelude::*;
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single match returned by [`SystemNameIndex::scan`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NameMatch {
|
||||
/// The `body_id` (or `system_id`) that was matched.
|
||||
pub id: String,
|
||||
/// The matched text span (byte offsets into the input string).
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Aho-Corasick automaton over all body/system/station proper names in systems.db.
|
||||
///
|
||||
/// Built once from the DB at startup; immutable thereafter.
|
||||
/// All queries are `O(n)` in the length of the scanned text regardless of
|
||||
/// how many names the automaton holds.
|
||||
///
|
||||
/// Returned IDs are body_ids for bodies/stations, or system_ids for star systems
|
||||
/// that have no body entries. The caller (background generation queue, D-206)
|
||||
/// decides which IDs are actionable.
|
||||
#[derive(Resource)]
|
||||
pub struct SystemNameIndex {
|
||||
automaton: AhoCorasick,
|
||||
/// Maps automaton pattern index → the body_id / system_id it represents.
|
||||
ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl SystemNameIndex {
|
||||
/// Build the index from `systems.db` at `path`.
|
||||
///
|
||||
/// Loads proper names from `bodies`, `stations`, and `star_systems`.
|
||||
/// Returns `None` on DB open failure (logged at warn level; the game
|
||||
/// runs without the index, just without event-driven pre-generation).
|
||||
pub fn load(path: &Path) -> Option<Self> {
|
||||
let conn = match Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %path.display(), error = %e, "SystemNameIndex: failed to open systems.db");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let entries = match collect_names(&conn) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "SystemNameIndex: failed to collect names");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if entries.is_empty() {
|
||||
tracing::warn!("SystemNameIndex: no names found in systems.db — index empty");
|
||||
}
|
||||
|
||||
let (patterns, ids): (Vec<String>, Vec<String>) = entries.into_iter().unzip();
|
||||
|
||||
let automaton = match AhoCorasickBuilder::new()
|
||||
.ascii_case_insensitive(true)
|
||||
.match_kind(MatchKind::LeftmostFirst)
|
||||
.build(&patterns)
|
||||
{
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "SystemNameIndex: automaton build failed — index unavailable");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(pattern_count = ids.len(), "SystemNameIndex built");
|
||||
Some(Self { automaton, ids })
|
||||
}
|
||||
|
||||
/// Scan `text` and return all name matches.
|
||||
///
|
||||
/// Each match carries the body_id / system_id and the byte span.
|
||||
/// Overlapping matches are not reported (leftmost-first wins per AhoCorasick
|
||||
/// `MatchKind::LeftmostFirst`).
|
||||
pub fn scan(&self, text: &str) -> Vec<NameMatch> {
|
||||
self.automaton
|
||||
.find_iter(text)
|
||||
.map(|m| NameMatch {
|
||||
id: self.ids[m.pattern().as_usize()].clone(),
|
||||
start: m.start(),
|
||||
end: m.end(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How many patterns the automaton holds (for diagnostics).
|
||||
pub fn pattern_count(&self) -> usize {
|
||||
self.ids.len()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn collect_names(conn: &Connection) -> rusqlite::Result<Vec<(String, String)>> {
|
||||
let mut entries: Vec<(String, String)> = Vec::new();
|
||||
|
||||
// Bodies — use proper_name only (body_id like "GJ-15Ab" is not natural language)
|
||||
{
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT body_id, proper_name FROM bodies WHERE proper_name IS NOT NULL AND proper_name != ''",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(1)?, row.get::<_, String>(0)?))
|
||||
})?;
|
||||
for row in rows {
|
||||
entries.push(row?);
|
||||
}
|
||||
}
|
||||
|
||||
// Stations
|
||||
{
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT station_id, proper_name FROM stations WHERE proper_name IS NOT NULL AND proper_name != ''",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(1)?, row.get::<_, String>(0)?))
|
||||
})?;
|
||||
for row in rows {
|
||||
entries.push(row?);
|
||||
}
|
||||
}
|
||||
|
||||
// Star systems — include both system_name and proper_name as separate patterns
|
||||
// so "Van Maanen's Star" and "GJ 35" both trigger if used in dialogue.
|
||||
{
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT system_id, system_name, proper_name FROM star_systems")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (system_id, system_name, proper_name) = row?;
|
||||
if let Some(name) = system_name {
|
||||
if !name.is_empty() {
|
||||
entries.push((name, system_id.clone()));
|
||||
}
|
||||
}
|
||||
if let Some(name) = proper_name {
|
||||
if !name.is_empty() {
|
||||
entries.push((name, system_id.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind};
|
||||
|
||||
/// Build a minimal index directly (no DB) for unit testing.
|
||||
fn make_index(pairs: &[(&str, &str)]) -> SystemNameIndex {
|
||||
let (patterns, ids): (Vec<&str>, Vec<String>) =
|
||||
pairs.iter().map(|(p, id)| (*p, id.to_string())).unzip();
|
||||
let automaton = AhoCorasickBuilder::new()
|
||||
.ascii_case_insensitive(true)
|
||||
.match_kind(MatchKind::LeftmostFirst)
|
||||
.build(&patterns)
|
||||
.unwrap();
|
||||
SystemNameIndex { automaton, ids }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_exact_match() {
|
||||
let idx = make_index(&[("Xin Chengdu", "GJ-380c")]);
|
||||
let matches = idx.scan("The freighter docked at Xin Chengdu yesterday.");
|
||||
assert_eq!(matches.len(), 1);
|
||||
assert_eq!(matches[0].id, "GJ-380c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_is_case_insensitive() {
|
||||
let idx = make_index(&[("Horizon Station", "GJ-380-oort-S1")]);
|
||||
let matches = idx.scan("HORIZON STATION cargo rates up 12%.");
|
||||
assert_eq!(matches.len(), 1);
|
||||
assert_eq!(matches[0].id, "GJ-380-oort-S1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_returns_empty_on_no_match() {
|
||||
let idx = make_index(&[("Xin Chengdu", "GJ-380c")]);
|
||||
let matches = idx.scan("Nothing here matches.");
|
||||
assert!(matches.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_returns_multiple_distinct_matches() {
|
||||
let idx = make_index(&[
|
||||
("Xin Chengdu", "GJ-380c"),
|
||||
("Horizon Station", "GJ-380-oort-S1"),
|
||||
]);
|
||||
let text = "Xin Chengdu imports from Horizon Station.";
|
||||
let matches = idx.scan(text);
|
||||
assert_eq!(matches.len(), 2);
|
||||
let ids: Vec<&str> = matches.iter().map(|m| m.id.as_str()).collect();
|
||||
assert!(ids.contains(&"GJ-380c"));
|
||||
assert!(ids.contains(&"GJ-380-oort-S1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_span_is_correct() {
|
||||
let idx = make_index(&[("Chengdu", "GJ-380c")]);
|
||||
let text = "0123456Chengdu rest";
|
||||
let matches = idx.scan(text);
|
||||
assert_eq!(matches.len(), 1);
|
||||
assert_eq!(matches[0].start, 7);
|
||||
assert_eq!(matches[0].end, 14);
|
||||
assert_eq!(&text[matches[0].start..matches[0].end], "Chengdu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_index_scans_without_panic() {
|
||||
let idx = make_index(&[]);
|
||||
let matches = idx.scan("Any text at all.");
|
||||
assert!(matches.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_count_matches_entries() {
|
||||
let idx = make_index(&[("Alpha", "sys-1"), ("Beta", "sys-2"), ("Gamma", "sys-3")]);
|
||||
assert_eq!(idx.pattern_count(), 3);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,6 @@
|
||||
"state_hash": 14452262397297540338,
|
||||
"tick": 8,
|
||||
"triangle_crisis_events": [],
|
||||
"version": 19,
|
||||
"visible_tiles": [
|
||||
{
|
||||
"tile_kind": "Floor",
|
||||
|
||||
@@ -20,10 +20,14 @@ Decision refs: #855 (generator versioning), #857 (pre-push hook)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# semver pattern: MAJOR.MINOR.PATCH (no pre-release or build metadata)
|
||||
_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
|
||||
@@ -41,9 +45,16 @@ GENERATOR_SOURCES: dict[str, list[Path]] = {
|
||||
REPO_ROOT / "server" / "src" / "bin" / "generate_brands" / "main.rs",
|
||||
REPO_ROOT / "server" / "src" / "bin" / "generate_brands" / "names.rs",
|
||||
REPO_ROOT / "tooling" / "generate-brands",
|
||||
REPO_ROOT / "tooling" / "schema_version.py",
|
||||
],
|
||||
"generate_atlas": [
|
||||
REPO_ROOT / "tooling" / "planet-gen" / "generate_atlas.py",
|
||||
REPO_ROOT / "tooling" / "planet-gen" / "gemma_naming.py",
|
||||
REPO_ROOT / "tooling" / "planet-gen" / "naming_core.py",
|
||||
REPO_ROOT / "tooling" / "planet-gen" / "import_city_names.py",
|
||||
REPO_ROOT / "tooling" / "planet-gen" / "import_heightmaps.py",
|
||||
REPO_ROOT / "tooling" / "planet-gen" / "import_province_boundaries.py",
|
||||
REPO_ROOT / "tooling" / "schema_version.py",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -73,7 +84,7 @@ def check(verbose: bool = False) -> int:
|
||||
try:
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
rows = conn.execute(
|
||||
"SELECT generator_name, generator_sha FROM meta"
|
||||
"SELECT generator_name, schema_version, generator_sha FROM meta"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
except sqlite3.OperationalError:
|
||||
@@ -91,7 +102,19 @@ def check(verbose: bool = False) -> int:
|
||||
|
||||
stale: list[str] = []
|
||||
unknown: list[str] = []
|
||||
for generator_name, stored_sha in rows:
|
||||
bad_version: list[str] = []
|
||||
seen_versions: dict[str, str] = {} # generator_name -> schema_version
|
||||
for generator_name, schema_version, stored_sha in rows:
|
||||
seen_versions[generator_name] = schema_version
|
||||
# Validate schema_version is a semver string (#888).
|
||||
# Old DBs may still carry a SHA-1 hex (40-char) — flag them as stale
|
||||
# so the user knows to run make regen-db rather than getting a silent pass.
|
||||
if not _SEMVER_RE.match(schema_version or ""):
|
||||
bad_version.append(
|
||||
f"{generator_name}: schema_version='{schema_version}' "
|
||||
f"(expected semver like '1.0.0' — run make regen-db)"
|
||||
)
|
||||
|
||||
sources = GENERATOR_SOURCES.get(generator_name)
|
||||
if sources is None:
|
||||
# Unknown generator — fail closed (T6). A future branch adding a
|
||||
@@ -118,6 +141,24 @@ def check(verbose: bool = False) -> int:
|
||||
f"\n current: {current_sha}"
|
||||
)
|
||||
|
||||
if bad_version:
|
||||
for msg in bad_version:
|
||||
print(f"check-systems-db-stamp: BAD schema_version — {msg}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# All generators must agree on the same schema_version (#888 defense-in-depth).
|
||||
# If they differ, the DB was partially regenerated with different source trees.
|
||||
unique_versions = set(seen_versions.values())
|
||||
if len(unique_versions) > 1:
|
||||
print(
|
||||
"check-systems-db-stamp: CONFLICT — generators disagree on schema_version:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for gen, ver in sorted(seen_versions.items()):
|
||||
print(f" {gen}: {ver}", file=sys.stderr)
|
||||
print(" Run: make regen-db", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if unknown:
|
||||
print(
|
||||
"check-systems-db-stamp: UNKNOWN generator(s) in meta table: "
|
||||
|
||||
+6
-4
@@ -1,8 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Decision ID management — claim, query, and validate decision IDs.
|
||||
# Usage:
|
||||
# decision next [D|Q|R] Show next available ID
|
||||
# decision claim <D|Q|R> <domain> [title] Claim next ID (reserves in DB)
|
||||
# decision check-dupes Check for duplicate IDs in markdown
|
||||
# decision sync Sync markdown -> DB
|
||||
# decision sync Sync decisions/*.md into SQLite
|
||||
# decision show <D-NNN> Show a decision with linked tickets + refs
|
||||
# decision next [D|Q|R] Show next available ID
|
||||
# decision claim <D|Q|R> <domain> [title] Claim next ID (reserves in DB)
|
||||
# decision check-dupes Check for duplicate IDs in markdown
|
||||
# decision orphan-tickets List tickets with invalid/missing decision_ref
|
||||
exec python3 "$(dirname "$0")/decisions_sync.py" "$@"
|
||||
|
||||
@@ -310,16 +310,14 @@ def sync(cfg):
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO decision_refs
|
||||
(source_id, target_id, ref_type, note)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(d["id"], target_id, ref_type, note),
|
||||
)
|
||||
cur = conn.execute(
|
||||
"""INSERT OR IGNORE INTO decision_refs
|
||||
(source_id, target_id, ref_type, note)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(d["id"], target_id, ref_type, note),
|
||||
)
|
||||
if cur.rowcount > 0:
|
||||
refs_created += 1
|
||||
except sqlite3.IntegrityError:
|
||||
pass # duplicate ref, skip
|
||||
|
||||
conn.commit()
|
||||
|
||||
@@ -457,6 +455,35 @@ def show_decision(cfg, decision_id):
|
||||
conn.close()
|
||||
|
||||
|
||||
def orphan_tickets(cfg):
|
||||
"""List tickets whose decision_ref is set but does not match any decision in the DB."""
|
||||
conn = get_connection(cfg)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""SELECT t.id, t.title, t.decision_ref, t.status, t.team
|
||||
FROM tickets t
|
||||
WHERE t.decision_ref IS NOT NULL
|
||||
AND t.decision_ref != ''
|
||||
AND t.decision_ref NOT IN (SELECT id FROM decisions)
|
||||
ORDER BY t.decision_ref, t.id""",
|
||||
).fetchall()
|
||||
|
||||
orphans = [dict(r) for r in rows]
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"count": len(orphans),
|
||||
"orphans": orphans,
|
||||
"summary": (
|
||||
f"{len(orphans)} orphan ticket(s) found"
|
||||
if orphans
|
||||
else "No orphan tickets — all decision_ref values are valid"
|
||||
),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def check_dupes(cfg):
|
||||
"""Check for duplicate decision IDs across all markdown files."""
|
||||
# Pre-existing collisions too deeply embedded to renumber (139+ references).
|
||||
@@ -508,6 +535,7 @@ Usage:
|
||||
decisions_sync.py next [D|Q|R] Show next available ID (all prefixes or one)
|
||||
decisions_sync.py claim <D|Q|R> <domain> [title] Claim next ID and insert placeholder
|
||||
decisions_sync.py check-dupes Check for duplicate IDs across markdown files
|
||||
decisions_sync.py orphan-tickets List tickets with invalid/missing decision_ref
|
||||
decisions_sync.py --help Show this help message
|
||||
|
||||
ID claiming workflow:
|
||||
@@ -554,6 +582,8 @@ def main():
|
||||
result = claim_id(cfg, prefix, domain, title)
|
||||
elif cmd == "check-dupes":
|
||||
result = check_dupes(cfg)
|
||||
elif cmd == "orphan-tickets":
|
||||
result = orphan_tickets(cfg)
|
||||
else:
|
||||
result = {"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ Usage:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
@@ -34,12 +35,17 @@ from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
# Import shared schema version constant (#888) — single source of truth in tooling/schema_version.py
|
||||
sys.path.insert(0, str(REPO_ROOT / "tooling"))
|
||||
from schema_version import SCHEMA_VERSION # noqa: E402
|
||||
|
||||
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
|
||||
STAR_MAP = REPO_ROOT / "docs" / "design" / "star-map.json"
|
||||
COMMODITIES_TOML = REPO_ROOT / "wiki" / "economics" / "commodities.toml"
|
||||
CHAINS_TOML = REPO_ROOT / "wiki" / "economics" / "production_chains.toml"
|
||||
SCHEMA_SQL = REPO_ROOT / "server" / "data" / "systems-schema.sql"
|
||||
CORPORATIONS_DIR = REPO_ROOT / "wiki" / "corporations"
|
||||
WIKI_STAR_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
|
||||
BRANDS_TOML = REPO_ROOT / "wiki" / "economics" / "corporations" / "brands.toml"
|
||||
GENERATED_BRANDS_TOML = REPO_ROOT / "wiki" / "economics" / "corporations" / "generated_brands.toml"
|
||||
# Rust sources for the generate_brands subroutine. import_economics shells out to
|
||||
@@ -76,6 +82,7 @@ IMPORT_ECONOMICS_SOURCES: tuple[Path, ...] = (
|
||||
GENERATE_BRANDS_RS,
|
||||
GENERATE_BRANDS_NAMES_RS,
|
||||
GENERATE_BRANDS_WRAPPER,
|
||||
REPO_ROOT / "tooling" / "schema_version.py",
|
||||
)
|
||||
|
||||
|
||||
@@ -97,9 +104,10 @@ def _write_stamp(conn: sqlite3.Connection, generator_name: str, *source_files: P
|
||||
schema_sha = _file_sha1(SCHEMA_SQL)
|
||||
generator_sha = _file_sha1(*source_files)
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO meta (generator_name, schema_version, generator_sha, generated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))""",
|
||||
(generator_name, schema_sha, generator_sha),
|
||||
"""INSERT OR REPLACE INTO meta
|
||||
(generator_name, schema_version, schema_sha, generator_sha, generated_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))""",
|
||||
(generator_name, SCHEMA_VERSION, schema_sha, generator_sha),
|
||||
)
|
||||
|
||||
|
||||
@@ -281,6 +289,74 @@ CREATE TABLE IF NOT EXISTS meta (
|
||||
-- own meta row. This DELETE makes the check-systems-db-stamp "unknown generator"
|
||||
-- path (fail-closed per T6) compatible with older DBs that still have the row.
|
||||
DELETE FROM meta WHERE generator_name = 'generate_brands';
|
||||
|
||||
-- Heightmap BLOB storage (D-202, #901)
|
||||
CREATE TABLE IF NOT EXISTS atlas_body_heightmaps (
|
||||
body_id TEXT PRIMARY KEY REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
width INTEGER NOT NULL DEFAULT 512,
|
||||
height INTEGER NOT NULL DEFAULT 256,
|
||||
data BLOB NOT NULL,
|
||||
sea_level REAL NOT NULL DEFAULT 0.0,
|
||||
imported_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_body_heightmaps_body ON atlas_body_heightmaps(body_id);
|
||||
|
||||
-- City name reservations (D-207, #902)
|
||||
CREATE TABLE IF NOT EXISTS atlas_city_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'city',
|
||||
economic_role TEXT NOT NULL,
|
||||
population INTEGER NOT NULL,
|
||||
settlement_class TEXT,
|
||||
corp_id TEXT REFERENCES corporations(corp_id),
|
||||
reserved INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_body ON atlas_city_names(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_kind ON atlas_city_names(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_corp ON atlas_city_names(corp_id);
|
||||
|
||||
-- Geographic feature name reservations (#903)
|
||||
CREATE TABLE IF NOT EXISTS atlas_feature_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
feature_type TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_feature_names_body ON atlas_feature_names(body_id);
|
||||
|
||||
-- Province boundaries (D-205, #904)
|
||||
CREATE TABLE IF NOT EXISTS atlas_province_boundaries (
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
basin_id INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
area_pct REAL NOT NULL,
|
||||
PRIMARY KEY (body_id, basin_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_province_boundaries_body ON atlas_province_boundaries(body_id);
|
||||
|
||||
-- City positions — attractor-matched placement output (D-211, #34)
|
||||
CREATE TABLE IF NOT EXISTS atlas_city_positions (
|
||||
city_names_id INTEGER PRIMARY KEY REFERENCES atlas_city_names(id) ON DELETE CASCADE,
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
row INTEGER NOT NULL,
|
||||
col INTEGER NOT NULL,
|
||||
attractor_type TEXT NOT NULL,
|
||||
score REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_positions_body ON atlas_city_positions(body_id);
|
||||
|
||||
-- Normalize bodies.economic_role to the D-194 canonical 10-value set (#911).
|
||||
-- Idempotent: each UPDATE is a no-op if the old value is already gone.
|
||||
UPDATE bodies SET economic_role = 'agricultural' WHERE economic_role IN ('agriculture', 'mixed-agriculture');
|
||||
UPDATE bodies SET economic_role = 'extraction' WHERE economic_role IN ('mining', 'resource_extraction', 'energy');
|
||||
UPDATE bodies SET economic_role = 'transit_hub' WHERE economic_role = 'transit';
|
||||
UPDATE bodies SET economic_role = 'service_mixed' WHERE economic_role IN ('commercial', 'coordination');
|
||||
UPDATE bodies SET economic_role = 'residential' WHERE economic_role = 'frontier';
|
||||
"""
|
||||
|
||||
# Columns to add to existing tables (ALTER TABLE is idempotent via try/except)
|
||||
@@ -291,6 +367,9 @@ COLUMN_MIGRATIONS = [
|
||||
("corporations", "supply_chain_role", "TEXT"),
|
||||
("corporations", "shadow_economy_access", "INTEGER DEFAULT 0"),
|
||||
("brand_products", "price_tier", "TEXT"),
|
||||
("bodies", "body_radius_km", "REAL"), # D-204 — physical radius in km, nullable
|
||||
("meta", "schema_sha", "TEXT"),
|
||||
("atlas_city_names", "settlement_class", "TEXT"), # D-196 — NULL until placement (#37)
|
||||
]
|
||||
|
||||
|
||||
@@ -776,6 +855,29 @@ def validate(conn: sqlite3.Connection) -> list[str]:
|
||||
for chain_id, cid in orphan_outputs:
|
||||
errors.append(f"production_chains: chain '{chain_id}' outputs unknown commodity '{cid}'")
|
||||
|
||||
# economic_role must be one of the D-194 canonical 10 values
|
||||
valid_roles = {
|
||||
'manufacturing', 'financial', 'agricultural', 'extraction',
|
||||
'service_mixed', 'institutional', 'transit_hub', 'research',
|
||||
'military', 'residential',
|
||||
}
|
||||
bad_roles = conn.execute("""
|
||||
SELECT DISTINCT economic_role, COUNT(*) as cnt
|
||||
FROM bodies
|
||||
WHERE economic_role IS NOT NULL
|
||||
AND economic_role NOT IN (
|
||||
'manufacturing', 'financial', 'agricultural', 'extraction',
|
||||
'service_mixed', 'institutional', 'transit_hub', 'research',
|
||||
'military', 'residential'
|
||||
)
|
||||
GROUP BY economic_role
|
||||
""").fetchall()
|
||||
for role, cnt in bad_roles:
|
||||
errors.append(
|
||||
f"bodies.economic_role: non-canonical value '{role}' on {cnt} row(s) — "
|
||||
f"valid values: {sorted(valid_roles)}"
|
||||
)
|
||||
|
||||
# Chain completeness: every intermediate commodity must have at least one producer
|
||||
missing_chains = conn.execute("""
|
||||
SELECT c.commodity_id, c.name
|
||||
@@ -980,6 +1082,228 @@ def import_system_fiscal(conn: sqlite3.Connection, dry_run: bool) -> int:
|
||||
return len(rows)
|
||||
|
||||
|
||||
def populate_body_radius_km(conn: sqlite3.Connection, dry_run: bool) -> int:
|
||||
"""Populate body_radius_km column from planet_class fallback (D-204, #910).
|
||||
|
||||
Applies the fallback lookup table to rows where body_radius_km IS NULL.
|
||||
Does not overwrite rows where body_radius_km is already set (authoritative data).
|
||||
|
||||
Fallback values (km):
|
||||
super_earth -> 8000
|
||||
earth_like -> 6371
|
||||
earth -> 6371 (alternate spelling)
|
||||
sub_earth -> 4500
|
||||
ocean_world -> 6500
|
||||
arid -> 5800
|
||||
frozen -> 4500
|
||||
ice_world -> 3000
|
||||
barren -> 4500
|
||||
volcanic -> 5500
|
||||
gas_giant -> 0 (no settlements, skip)
|
||||
moon -> 1737
|
||||
other/unknown -> 6371 (Earth default)
|
||||
"""
|
||||
PLANET_CLASS_RADIUS = {
|
||||
"super_earth": 8000.0,
|
||||
"earth_like": 6371.0,
|
||||
"earth": 6371.0,
|
||||
"sub_earth": 4500.0,
|
||||
"ocean_world": 6500.0,
|
||||
"arid": 5800.0,
|
||||
"frozen": 4500.0,
|
||||
"ice_world": 3000.0,
|
||||
"barren": 4500.0,
|
||||
"volcanic": 5500.0,
|
||||
"temperate": 6371.0,
|
||||
"moon": 1737.0,
|
||||
}
|
||||
DEFAULT_RADIUS = 6371.0
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT body_id, planet_class FROM bodies WHERE body_radius_km IS NULL"
|
||||
).fetchall()
|
||||
|
||||
updates = []
|
||||
for body_id, planet_class in rows:
|
||||
if planet_class and planet_class.lower() == "gas_giant":
|
||||
continue # gas giants have no settlements; leave NULL
|
||||
radius = PLANET_CLASS_RADIUS.get(
|
||||
(planet_class or "").lower(), DEFAULT_RADIUS
|
||||
)
|
||||
updates.append((radius, body_id))
|
||||
|
||||
if not dry_run and updates:
|
||||
conn.executemany(
|
||||
"UPDATE bodies SET body_radius_km = ? WHERE body_id = ?", updates
|
||||
)
|
||||
|
||||
return len(updates)
|
||||
|
||||
|
||||
def populate_atlas_city_names(conn: sqlite3.Connection, dry_run: bool) -> int:
|
||||
"""Populate atlas_city_names from wiki markers.json city entries (D-207, #908).
|
||||
|
||||
Scans wiki/star-systems/*/bodies/*/markers.json for 'cities' arrays.
|
||||
Each entry yields one atlas_city_names row:
|
||||
- body_id : directory name (e.g. GJ0e)
|
||||
- name : city name from markers.json
|
||||
- kind : 'capital' or 'city' (default 'city')
|
||||
- economic_role : inherited from bodies.economic_role; fallback 'mixed'
|
||||
- population : from markers.json (integer)
|
||||
- corp_id : NULL — populated by populate_atlas_city_names_corps (#909)
|
||||
- reserved : 0
|
||||
|
||||
Uses INSERT OR REPLACE so re-runs are idempotent per (body_id, name).
|
||||
Skips body directories not found in the bodies table (missing FK).
|
||||
"""
|
||||
# Build body_id -> economic_role map
|
||||
body_roles: dict[str, str] = {}
|
||||
for body_id, role in conn.execute(
|
||||
"SELECT body_id, economic_role FROM bodies"
|
||||
).fetchall():
|
||||
body_roles[body_id] = role or "mixed"
|
||||
|
||||
valid_body_ids: set[str] = set(body_roles.keys())
|
||||
|
||||
rows: list[tuple] = []
|
||||
skipped_bodies: list[str] = []
|
||||
|
||||
pattern = str(WIKI_STAR_SYSTEMS / "*" / "bodies" / "*" / "markers.json")
|
||||
for markers_path in sorted(glob.glob(pattern)):
|
||||
body_id = markers_path.split("/bodies/")[1].split("/")[0]
|
||||
if body_id not in valid_body_ids:
|
||||
skipped_bodies.append(body_id)
|
||||
continue
|
||||
|
||||
with open(markers_path) as fh:
|
||||
data = json.load(fh)
|
||||
|
||||
for city in data.get("cities", []):
|
||||
name = city.get("name", "").strip()
|
||||
if not name:
|
||||
continue
|
||||
kind = city.get("kind", "city")
|
||||
population = int(city.get("population", 0))
|
||||
economic_role = body_roles[body_id]
|
||||
rows.append((body_id, name, kind, economic_role, population))
|
||||
|
||||
if skipped_bodies:
|
||||
unique = sorted(set(skipped_bodies))
|
||||
print(f" warning: {len(unique)} body dirs not in DB — skipped: {unique[:5]}")
|
||||
|
||||
if not dry_run and rows:
|
||||
conn.executemany(
|
||||
"""INSERT OR REPLACE INTO atlas_city_names
|
||||
(body_id, name, kind, economic_role, population)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
rows,
|
||||
)
|
||||
|
||||
return len(rows)
|
||||
|
||||
|
||||
def populate_atlas_city_names_corps(conn: sqlite3.Connection, dry_run: bool) -> tuple[int, int]:
|
||||
"""Cross-reference corp HQ city names into atlas_city_names (D-207, #909).
|
||||
|
||||
For each corporation with a parseable headquarters field ("City (SYSTEM_ID)"):
|
||||
- If atlas_city_names already has a row with matching name on a body in that
|
||||
system: UPDATE the row to set corp_id.
|
||||
- Otherwise: INSERT a reserved row (reserved=1) so the name is protected.
|
||||
Attaches to the most-populated body in the system (fallback: any body).
|
||||
|
||||
Returns (n_updated, n_inserted).
|
||||
"""
|
||||
# Build system_id -> sorted bodies (by population desc, then body_id)
|
||||
sys_bodies: dict[str, list[tuple[int, str, str]]] = {}
|
||||
for body_id, sys_id, pop, role in conn.execute(
|
||||
"SELECT body_id, system_id, COALESCE(population, 0), COALESCE(economic_role, 'mixed') FROM bodies"
|
||||
).fetchall():
|
||||
sys_bodies.setdefault(sys_id, []).append((pop, body_id, role))
|
||||
for v in sys_bodies.values():
|
||||
v.sort(key=lambda x: (-x[0], x[1]))
|
||||
|
||||
# Build (body_id, name_lower) -> id index for existing atlas_city_names rows
|
||||
existing: dict[tuple[str, str], int] = {}
|
||||
body_to_sys: dict[str, str] = {
|
||||
r[0]: r[1]
|
||||
for r in conn.execute("SELECT body_id, system_id FROM bodies").fetchall()
|
||||
}
|
||||
for row_id, body_id, name in conn.execute(
|
||||
"SELECT id, body_id, name FROM atlas_city_names"
|
||||
).fetchall():
|
||||
existing[(body_id, name.lower())] = row_id
|
||||
|
||||
# Build system_id -> set of body_ids for quick lookup
|
||||
sys_body_ids: dict[str, set[str]] = {}
|
||||
for body_id, sys_id in body_to_sys.items():
|
||||
sys_body_ids.setdefault(sys_id, set()).add(body_id)
|
||||
|
||||
updated: list[tuple[str, int]] = [] # (corp_id, atlas_row_id)
|
||||
inserted: list[tuple] = [] # insert rows
|
||||
|
||||
for corp_id, headquarters_system in conn.execute(
|
||||
"SELECT corp_id, headquarters_system FROM corporations WHERE headquarters_system IS NOT NULL"
|
||||
).fetchall():
|
||||
# Retrieve original headquarters string from wiki to get city name
|
||||
md_file = CORPORATIONS_DIR / f"{corp_id}.md"
|
||||
if not md_file.exists():
|
||||
continue
|
||||
hq_raw = ""
|
||||
with open(md_file) as f:
|
||||
in_fm = False
|
||||
for line in f:
|
||||
if line.strip() == "---":
|
||||
if not in_fm:
|
||||
in_fm = True
|
||||
continue
|
||||
else:
|
||||
break
|
||||
if in_fm and line.startswith("headquarters:"):
|
||||
hq_raw = line.split(":", 1)[1].strip().strip('"')
|
||||
break
|
||||
if not hq_raw:
|
||||
continue
|
||||
m = re.search(r"\(([^)]+)\)", hq_raw)
|
||||
city_name = hq_raw[: m.start()].strip() if m else hq_raw.strip()
|
||||
if not city_name:
|
||||
continue
|
||||
|
||||
# Try to find a matching atlas_city_names row in the same system
|
||||
body_ids_in_sys = sys_body_ids.get(headquarters_system, set())
|
||||
match_id: int | None = None
|
||||
for body_id in body_ids_in_sys:
|
||||
key = (body_id, city_name.lower())
|
||||
if key in existing:
|
||||
match_id = existing[key]
|
||||
break
|
||||
|
||||
if match_id is not None:
|
||||
updated.append((corp_id, match_id))
|
||||
else:
|
||||
# Insert a reserved row on the most-populated body in the system
|
||||
candidates = sys_bodies.get(headquarters_system, [])
|
||||
if not candidates:
|
||||
continue
|
||||
_, target_body_id, body_role = candidates[0]
|
||||
inserted.append((target_body_id, city_name, "city", body_role, 0, corp_id, 1))
|
||||
|
||||
if not dry_run:
|
||||
for corp_id, row_id in updated:
|
||||
conn.execute(
|
||||
"UPDATE atlas_city_names SET corp_id = ? WHERE id = ?",
|
||||
(corp_id, row_id),
|
||||
)
|
||||
if inserted:
|
||||
conn.executemany(
|
||||
"""INSERT OR IGNORE INTO atlas_city_names
|
||||
(body_id, name, kind, economic_role, population, corp_id, reserved)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
inserted,
|
||||
)
|
||||
|
||||
return len(updated), len(inserted)
|
||||
|
||||
|
||||
def validate_brands(conn: sqlite3.Connection) -> list[str]:
|
||||
"""Brand layer structural validation rules V-B01 through V-B06.
|
||||
|
||||
@@ -1220,10 +1544,25 @@ def main():
|
||||
print(f" {n_brands} brand_products, {n_brand_inputs} brand_inputs")
|
||||
|
||||
# 10. System fiscal parameters (D-189 section 6)
|
||||
print(" [10/10] Populating system_fiscal...")
|
||||
print(" [10/13] Populating system_fiscal...")
|
||||
n_fiscal = import_system_fiscal(conn, args.dry_run)
|
||||
print(f" {n_fiscal} system_fiscal rows")
|
||||
|
||||
# 11. body_radius_km fallback from planet_class (D-204, #910)
|
||||
print(" [11/13] Populating body_radius_km fallback...")
|
||||
n_radius = populate_body_radius_km(conn, args.dry_run)
|
||||
print(f" {n_radius} bodies updated")
|
||||
|
||||
# 12. atlas_city_names from wiki markers.json (D-207, #908)
|
||||
print(" [12/13] Populating atlas_city_names from wiki content...")
|
||||
n_cities = populate_atlas_city_names(conn, args.dry_run)
|
||||
print(f" {n_cities} city name rows")
|
||||
|
||||
# 13. atlas_city_names corp HQ cross-reference (D-207, #909)
|
||||
print(" [13/13] Cross-referencing corp HQ cities into atlas_city_names...")
|
||||
n_updated, n_inserted = populate_atlas_city_names_corps(conn, args.dry_run)
|
||||
print(f" {n_updated} rows updated, {n_inserted} reserved rows inserted")
|
||||
|
||||
# Validate structural integrity (FK, chain refs, chain completeness).
|
||||
# These errors indicate broken imported data — do NOT commit.
|
||||
print("\n Validating structural integrity...")
|
||||
|
||||
@@ -1842,6 +1842,19 @@ def process_body(
|
||||
substyles = CORRIDOR_SUBSTYLES.get(corridor, DEFAULT_SUBSTYLES)
|
||||
mood = mood_for_body(body_id, world_seed)
|
||||
|
||||
# Build cultural-history context for the prompt (#886 §6):
|
||||
# Collect the inflection descriptions of all *secondary* registers in
|
||||
# this corridor so the model sees the full settlement layering — e.g.
|
||||
# "Scottish Highland" as primary, but also the Irish and Australian
|
||||
# substyles that represent earlier or interleaved waves of settlers.
|
||||
# Limited to 3 secondary styles to keep the prompt concise.
|
||||
_secondary_inflections = [
|
||||
s["inflection"] for s in substyles if s["inflection"] != inflection
|
||||
][:3]
|
||||
cultural_history: str | None = (
|
||||
"; ".join(_secondary_inflections) if _secondary_inflections else None
|
||||
)
|
||||
|
||||
# Helper: batch-name blank features in a marker section
|
||||
def _batch_fill(
|
||||
section_key: str,
|
||||
@@ -1895,6 +1908,7 @@ def process_body(
|
||||
mood=mood,
|
||||
body_id=body_id,
|
||||
world_seed=world_seed,
|
||||
cultural_history=cultural_history,
|
||||
ctx_size=voice.ctx_size,
|
||||
)
|
||||
|
||||
@@ -1926,11 +1940,11 @@ def process_body(
|
||||
_batch_fill("mountain_ranges", lambda f: "mountain_range", "mountain_ranges")
|
||||
_batch_fill("pois", _feature_type_for_poi, "pois")
|
||||
|
||||
# Mountain suffix monotony check (#853 §3):
|
||||
# Mountain suffix monotony check + auto-fix (#853 §3, #886 §3):
|
||||
# If >40% of mountain names on a single body share a trailing word,
|
||||
# flag it. We don't re-query in the batch pipeline (no voice access here)
|
||||
# but record a warning so the batch runner can surface bodies that need
|
||||
# a targeted re-run.
|
||||
# re-query with the offending names added to `taken` so the model is
|
||||
# forced to diversify. One retry per body; if the retry still clusters
|
||||
# (rare), record a warning for post-run inspection.
|
||||
mountain_names = [
|
||||
f.get("name", "") for f in (markers.get("mountain_ranges") or [])
|
||||
if f.get("name")
|
||||
@@ -1944,11 +1958,64 @@ def process_body(
|
||||
dominant = max(suffix_counts, key=lambda k: suffix_counts[k])
|
||||
dominant_frac = suffix_counts[dominant] / len(mountain_names)
|
||||
if dominant_frac > 0.40:
|
||||
counts["suffix_monotony_warning"] = (
|
||||
f"mountain suffix '{dominant}' on "
|
||||
f"{suffix_counts[dominant]}/{len(mountain_names)} "
|
||||
f"features ({dominant_frac:.0%}) — re-run targeting this body"
|
||||
# Targeted retry: identify features with the dominant suffix,
|
||||
# re-request names for them with the monotonous names as `taken`.
|
||||
offending_features = [
|
||||
f for f in (markers.get("mountain_ranges") or [])
|
||||
if f.get("name") and f["name"].split()[-1].lower() == dominant
|
||||
]
|
||||
retry_taken = (
|
||||
list(body_used)
|
||||
+ list(corpus.get((corridor, "mountain_range"), set()))
|
||||
)
|
||||
retry_names = name_features_batch(
|
||||
voice=voice,
|
||||
feature_type="mountain_range",
|
||||
count=len(offending_features),
|
||||
inflection=inflection,
|
||||
corridor=corridor,
|
||||
corridor_substyles=substyles,
|
||||
taken=retry_taken,
|
||||
prompt_config=_PROMPT_CONFIG,
|
||||
system_name=ctx.get("system_proper_name"),
|
||||
body_name=ctx.get("body_proper_name"),
|
||||
system_hook=system_hook,
|
||||
mood=mood,
|
||||
body_id=body_id,
|
||||
world_seed=world_seed + 1, # bump seed to force different output
|
||||
cultural_history=cultural_history,
|
||||
ctx_size=voice.ctx_size,
|
||||
)
|
||||
for i, feat in enumerate(offending_features):
|
||||
if i < len(retry_names):
|
||||
old_name = feat["name"]
|
||||
feat["name"] = retry_names[i]
|
||||
body_used.discard(old_name)
|
||||
body_used.add(retry_names[i])
|
||||
corpus.setdefault((corridor, "mountain_range"), set()).discard(old_name)
|
||||
corpus.setdefault((corridor, "mountain_range"), set()).add(retry_names[i])
|
||||
changed = True
|
||||
# Re-check after retry; record warning if still clustered
|
||||
mountain_names_after = [
|
||||
f.get("name", "") for f in (markers.get("mountain_ranges") or [])
|
||||
if f.get("name")
|
||||
]
|
||||
suffix_counts_after: dict[str, int] = {}
|
||||
for mn in mountain_names_after:
|
||||
words = mn.split()
|
||||
if words:
|
||||
suffix_counts_after[words[-1].lower()] = (
|
||||
suffix_counts_after.get(words[-1].lower(), 0) + 1
|
||||
)
|
||||
if suffix_counts_after:
|
||||
dominant_after = max(suffix_counts_after, key=lambda k: suffix_counts_after[k])
|
||||
dominant_frac_after = suffix_counts_after[dominant_after] / len(mountain_names_after)
|
||||
if dominant_frac_after > 0.40:
|
||||
counts["suffix_monotony_warning"] = (
|
||||
f"mountain suffix '{dominant_after}' still on "
|
||||
f"{suffix_counts_after[dominant_after]}/{len(mountain_names_after)} "
|
||||
f"features ({dominant_frac_after:.0%}) after retry"
|
||||
)
|
||||
|
||||
# Infrastructure naming (#853 §7):
|
||||
# Assign deterministic city-pair names to unnamed roads and railroads.
|
||||
|
||||
@@ -49,6 +49,10 @@ import yaml
|
||||
|
||||
from planet_simulation import simulate
|
||||
|
||||
# Import shared schema version constant (#888) — single source of truth in tooling/schema_version.py
|
||||
sys.path.insert(0, str(REPO_ROOT / "tooling"))
|
||||
from schema_version import SCHEMA_VERSION # noqa: E402
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -121,12 +125,21 @@ def _write_stamp(conn: sqlite3.Connection) -> None:
|
||||
a double-commit with the atlas data write that precedes it.
|
||||
"""
|
||||
schema_sha = _file_sha1(SYSTEMS_SCHEMA_PATH)
|
||||
generator_sha = _file_sha1(Path(__file__))
|
||||
_atlas_dir = Path(__file__).parent
|
||||
generator_sha = _file_sha1(
|
||||
Path(__file__),
|
||||
_atlas_dir / "gemma_naming.py",
|
||||
_atlas_dir / "naming_core.py",
|
||||
_atlas_dir / "import_city_names.py",
|
||||
_atlas_dir / "import_heightmaps.py",
|
||||
_atlas_dir / "import_province_boundaries.py",
|
||||
REPO_ROOT / "tooling" / "schema_version.py",
|
||||
)
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO meta
|
||||
(generator_name, schema_version, generator_sha, generated_at)
|
||||
VALUES ('generate_atlas', ?, ?, datetime('now'))""",
|
||||
(schema_sha, generator_sha),
|
||||
(generator_name, schema_version, schema_sha, generator_sha, generated_at)
|
||||
VALUES ('generate_atlas', ?, ?, ?, datetime('now'))""",
|
||||
(SCHEMA_VERSION, schema_sha, generator_sha),
|
||||
)
|
||||
|
||||
|
||||
@@ -172,11 +185,18 @@ def ensure_atlas_schema(conn: sqlite3.Connection) -> None:
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
generator_name TEXT PRIMARY KEY,
|
||||
schema_version TEXT NOT NULL,
|
||||
schema_sha TEXT,
|
||||
generator_sha TEXT NOT NULL,
|
||||
generated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
"""
|
||||
)
|
||||
# Add schema_sha column to existing DBs that pre-date #888 (#888 migration).
|
||||
try:
|
||||
conn.execute("ALTER TABLE meta ADD COLUMN schema_sha TEXT")
|
||||
except sqlite3.OperationalError as e:
|
||||
if "duplicate column" not in str(e).lower():
|
||||
raise
|
||||
|
||||
|
||||
def _first_int(values, default: int = 0) -> int:
|
||||
@@ -1142,6 +1162,7 @@ def load_markers(body_dir: Path) -> dict:
|
||||
"cities": [],
|
||||
"railroads": [],
|
||||
"pois": [],
|
||||
"provinces": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -1290,6 +1311,38 @@ def process_body(
|
||||
return {"status": "generated", "markers": markers}
|
||||
|
||||
|
||||
def query_province_boundaries(conn: sqlite3.Connection, body_id: str) -> list[dict]:
|
||||
"""Read pre-computed province boundaries for a body from atlas_province_boundaries."""
|
||||
rows = conn.execute(
|
||||
"SELECT basin_id, path, area_pct FROM atlas_province_boundaries WHERE body_id = ? ORDER BY basin_id",
|
||||
(body_id,),
|
||||
).fetchall()
|
||||
provinces = []
|
||||
for row in rows:
|
||||
provinces.append({
|
||||
"basin_id": row[0],
|
||||
"path": json.loads(row[1]),
|
||||
"area_pct": row[2],
|
||||
})
|
||||
return provinces
|
||||
|
||||
|
||||
def inject_provinces_into_markers(conn: sqlite3.Connection, body_id: str, body_dir: Path) -> bool:
|
||||
"""Add provinces array to an existing markers.json. Returns True if written."""
|
||||
markers_path = body_dir / "markers.json"
|
||||
if not markers_path.exists():
|
||||
return False
|
||||
provinces = query_province_boundaries(conn, body_id)
|
||||
if not provinces:
|
||||
return False
|
||||
with open(markers_path) as f:
|
||||
markers = json.load(f)
|
||||
markers["provinces"] = provinces
|
||||
with open(markers_path, "w") as f:
|
||||
json.dump(markers, f, indent=2)
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Atlas generation — terrain-aware city placement and infrastructure (#832)"
|
||||
@@ -1386,6 +1439,18 @@ def main():
|
||||
n_errors += 1
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}")
|
||||
|
||||
# Inject province boundaries into markers.json for bodies that have them
|
||||
if not args.dry_run:
|
||||
n_provinces = 0
|
||||
for body_info in bodies:
|
||||
body_id = body_info["body_id"]
|
||||
terrain_ref = body_info["terrain_reference"]
|
||||
body_dir = REPO_ROOT / Path(terrain_ref).parent
|
||||
if inject_provinces_into_markers(conn, body_id, body_dir):
|
||||
n_provinces += 1
|
||||
if n_provinces > 0:
|
||||
print(f"\n Province boundaries injected into {n_provinces} markers.json files.")
|
||||
|
||||
if not args.dry_run:
|
||||
# Stamp generator metadata (#855, #856) together with the atlas data
|
||||
# in a single commit — atlas data + stamp land atomically, and the
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
import_city_names.py — Populate atlas_city_names from wiki markers.json content.
|
||||
|
||||
For each inhabited body, reads city records from markers.json and inserts rows
|
||||
into atlas_city_names with:
|
||||
- name, kind, population from markers.json
|
||||
- economic_role from bodies table
|
||||
- corp_id from corporations.headquarters_body cross-reference (#909)
|
||||
|
||||
Incremental: clears and reimports all rows for each body on every run (the
|
||||
table has no stable local IDs — city identity is name × body_id). Use --body
|
||||
to restrict to a single body.
|
||||
|
||||
Usage:
|
||||
tooling/planet-gen/import_city_names.py
|
||||
tooling/planet-gen/import_city_names.py --body GJ380c
|
||||
tooling/planet-gen/import_city_names.py --dry-run
|
||||
|
||||
Exit codes:
|
||||
0 completed
|
||||
1 fatal error (missing DB, schema error)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
TOOLING_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
||||
|
||||
_venv_python = REPO_ROOT / ".venv" / "bin" / "python"
|
||||
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
|
||||
import os
|
||||
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
|
||||
|
||||
import sqlite3
|
||||
|
||||
from generate_atlas import (
|
||||
DB_PATH,
|
||||
ensure_atlas_schema,
|
||||
query_inhabited_bodies,
|
||||
)
|
||||
|
||||
|
||||
def _build_hq_index(conn: sqlite3.Connection) -> dict[str, str]:
|
||||
"""Build a mapping of body_id → corp_id for all corp HQ locations."""
|
||||
rows = conn.execute(
|
||||
"SELECT headquarters_body, corp_id FROM corporations "
|
||||
"WHERE headquarters_body IS NOT NULL"
|
||||
).fetchall()
|
||||
index: dict[str, str] = {}
|
||||
for body_id, corp_id in rows:
|
||||
# If multiple corps have the same HQ body, take the first (alphabetical
|
||||
# corp_id for determinism). This is unlikely but safe.
|
||||
if body_id not in index:
|
||||
index[body_id] = corp_id
|
||||
return index
|
||||
|
||||
|
||||
def _load_city_records(body_dir: Path) -> list[dict]:
|
||||
"""Load named city records from markers.json. Returns empty list if none."""
|
||||
markers_path = body_dir / "markers.json"
|
||||
if not markers_path.exists():
|
||||
return []
|
||||
try:
|
||||
markers = json.loads(markers_path.read_text())
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return [
|
||||
c for c in (markers.get("cities") or [])
|
||||
if c.get("name") and isinstance(c["name"], str) and c["name"].strip()
|
||||
]
|
||||
|
||||
|
||||
def import_body_cities(
|
||||
body_info: dict,
|
||||
conn: sqlite3.Connection,
|
||||
hq_index: dict[str, str],
|
||||
dry_run: bool,
|
||||
verbose: bool,
|
||||
) -> dict:
|
||||
"""Import atlas_city_names rows for one body.
|
||||
|
||||
Returns a dict with:
|
||||
status: 'imported' | 'no_cities' | 'error'
|
||||
imported: count of rows written
|
||||
message: detail (on error)
|
||||
"""
|
||||
body_id = body_info["body_id"]
|
||||
terrain_ref = body_info["terrain_reference"]
|
||||
economic_role = body_info.get("economic_role") or "unknown"
|
||||
corp_id = hq_index.get(body_id)
|
||||
|
||||
body_dir = REPO_ROOT / Path(terrain_ref).parent
|
||||
cities = _load_city_records(body_dir)
|
||||
|
||||
if not cities:
|
||||
return {"status": "no_cities", "imported": 0}
|
||||
|
||||
if verbose:
|
||||
print(f" {body_id}: {len(cities)} cities, economic_role={economic_role}"
|
||||
+ (f", corp_hq={corp_id}" if corp_id else ""))
|
||||
|
||||
if not dry_run:
|
||||
# Full rebuild for this body: delete existing rows, re-insert.
|
||||
conn.execute("DELETE FROM atlas_city_names WHERE body_id = ?", (body_id,))
|
||||
|
||||
for city in cities:
|
||||
name = city["name"].strip()
|
||||
kind = city.get("kind") or "city"
|
||||
population = int(city.get("population") or 0)
|
||||
# Only set corp_id on the capital city of a corp HQ body.
|
||||
city_corp_id = corp_id if (kind == "capital" and corp_id) else None
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO atlas_city_names
|
||||
(body_id, name, kind, economic_role, population, corp_id,
|
||||
reserved, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, datetime('now'))""",
|
||||
(body_id, name, kind, economic_role, population, city_corp_id),
|
||||
)
|
||||
|
||||
return {"status": "imported", "imported": len(cities)}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Populate atlas_city_names from wiki markers.json (#908, #909)"
|
||||
)
|
||||
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
|
||||
parser.add_argument("--body", help="Process only this body_id")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Read and validate without writing to DB")
|
||||
parser.add_argument("--verbose", action="store_true",
|
||||
help="Print per-body detail")
|
||||
args = parser.parse_args()
|
||||
|
||||
db_path = Path(args.db)
|
||||
if not db_path.exists():
|
||||
print(f"error: {db_path} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n City Names Import (#908 + #909)")
|
||||
print(f" DB: {db_path}")
|
||||
if args.dry_run:
|
||||
print(f" Mode: DRY RUN (no DB writes)")
|
||||
print()
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
ensure_atlas_schema(conn)
|
||||
|
||||
hq_index = _build_hq_index(conn)
|
||||
|
||||
bodies = query_inhabited_bodies(conn)
|
||||
if args.body:
|
||||
bodies = [b for b in bodies if b["body_id"] == args.body]
|
||||
if not bodies:
|
||||
print(f"error: body '{args.body}' not found or has no terrain_reference",
|
||||
file=sys.stderr)
|
||||
conn.close()
|
||||
sys.exit(1)
|
||||
|
||||
print(f" {len(bodies)} inhabited bodies with terrain_reference")
|
||||
print(f" {len(hq_index)} corp HQ body mappings\n")
|
||||
|
||||
t_total = time.time()
|
||||
n_imported = 0
|
||||
n_no_cities = 0
|
||||
n_errors = 0
|
||||
total_rows = 0
|
||||
|
||||
for i, body_info in enumerate(bodies):
|
||||
body_id = body_info["body_id"]
|
||||
|
||||
result = import_body_cities(body_info, conn, hq_index, args.dry_run, args.verbose)
|
||||
status = result["status"]
|
||||
|
||||
if status == "imported":
|
||||
n_imported += 1
|
||||
total_rows += result["imported"]
|
||||
if args.verbose:
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} {result['imported']} cities")
|
||||
elif status == "no_cities":
|
||||
n_no_cities += 1
|
||||
elif status == "error":
|
||||
n_errors += 1
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}")
|
||||
|
||||
if not args.dry_run:
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
|
||||
elapsed = time.time() - t_total
|
||||
print(f"\n Done in {elapsed:.1f}s")
|
||||
print(f" bodies_with_cities={n_imported} no_cities={n_no_cities} "
|
||||
f"errors={n_errors} total_rows={total_rows}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
import_heightmaps.py — Import terrain elevation grids into atlas_body_heightmaps.
|
||||
|
||||
For each inhabited body with a terrain_reference, simulates the terrain via
|
||||
planet_simulation.simulate() and stores the float32 LE elevation BLOB plus
|
||||
sea_level metadata in atlas_body_heightmaps (#906, D-202).
|
||||
|
||||
The BLOB format matches the Rust loader spec (D-202):
|
||||
- float32 little-endian, row-major
|
||||
- width × height values, each in [0.0, 1.0]
|
||||
- width = GRID_W (512), height = GRID_H (256)
|
||||
|
||||
Incremental: bodies that already have a row in atlas_body_heightmaps are
|
||||
skipped unless --force is passed.
|
||||
|
||||
Usage:
|
||||
tooling/planet-gen/import_heightmaps.py
|
||||
tooling/planet-gen/import_heightmaps.py --body GJ380c
|
||||
tooling/planet-gen/import_heightmaps.py --force
|
||||
tooling/planet-gen/import_heightmaps.py --dry-run
|
||||
|
||||
Exit codes:
|
||||
0 completed (possibly with skipped or errored bodies)
|
||||
1 fatal error (missing DB, schema error)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
TOOLING_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
||||
|
||||
_venv_python = REPO_ROOT / ".venv" / "bin" / "python"
|
||||
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
|
||||
import os
|
||||
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
|
||||
|
||||
import numpy as np
|
||||
import sqlite3
|
||||
|
||||
from generate_atlas import (
|
||||
GRID_W,
|
||||
GRID_H,
|
||||
DB_PATH,
|
||||
ensure_atlas_schema,
|
||||
load_body_def,
|
||||
query_inhabited_bodies,
|
||||
)
|
||||
from planet_simulation import simulate
|
||||
|
||||
|
||||
def _elevation_to_blob(elevation: np.ndarray) -> bytes:
|
||||
"""Convert a float32 elevation grid to a little-endian BLOB."""
|
||||
arr = elevation.astype("<f4") # float32 LE, explicit
|
||||
assert arr.shape == (GRID_H, GRID_W), (
|
||||
f"elevation shape {arr.shape} does not match expected ({GRID_H}, {GRID_W})"
|
||||
)
|
||||
return arr.tobytes()
|
||||
|
||||
|
||||
def import_body(
|
||||
body_info: dict,
|
||||
conn: sqlite3.Connection,
|
||||
force: bool,
|
||||
dry_run: bool,
|
||||
verbose: bool,
|
||||
) -> dict:
|
||||
"""Import heightmap BLOB for one body.
|
||||
|
||||
Returns a dict with:
|
||||
status: 'imported' | 'skipped' | 'gas_giant' | 'error'
|
||||
message: detail (on error or skip)
|
||||
"""
|
||||
body_id = body_info["body_id"]
|
||||
terrain_ref = body_info["terrain_reference"]
|
||||
|
||||
# Incremental check — skip if already imported
|
||||
if not force:
|
||||
existing = conn.execute(
|
||||
"SELECT 1 FROM atlas_body_heightmaps WHERE body_id = ?", (body_id,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
return {"status": "skipped", "message": "already imported"}
|
||||
|
||||
body_dir = REPO_ROOT / Path(terrain_ref).parent
|
||||
if not body_dir.exists():
|
||||
return {"status": "error", "message": f"body_dir not found: {body_dir}"}
|
||||
|
||||
bd = load_body_def(body_dir)
|
||||
if not bd:
|
||||
return {"status": "error", "message": f"no body definition found in {body_dir}"}
|
||||
|
||||
try:
|
||||
terrain = simulate(bd)
|
||||
except Exception as exc:
|
||||
return {"status": "error", "message": f"simulate() failed: {exc}"}
|
||||
|
||||
if not terrain:
|
||||
return {"status": "gas_giant"}
|
||||
|
||||
elevation = terrain.get("elevation")
|
||||
if elevation is None:
|
||||
return {"status": "error", "message": "terrain dict missing 'elevation' key"}
|
||||
|
||||
sea_level = float(terrain.get("sea_level", 0.0))
|
||||
blob = _elevation_to_blob(elevation)
|
||||
|
||||
if verbose:
|
||||
land_pct = float(np.mean(elevation >= sea_level)) * 100
|
||||
print(f" {body_id}: {GRID_W}x{GRID_H} grid, sea_level={sea_level:.3f}, "
|
||||
f"land={land_pct:.1f}%, blob={len(blob)} bytes")
|
||||
|
||||
if not dry_run:
|
||||
conn.execute(
|
||||
"""INSERT INTO atlas_body_heightmaps
|
||||
(body_id, width, height, data, sea_level, imported_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(body_id) DO UPDATE SET
|
||||
width = excluded.width,
|
||||
height = excluded.height,
|
||||
data = excluded.data,
|
||||
sea_level = excluded.sea_level,
|
||||
imported_at = excluded.imported_at""",
|
||||
(body_id, GRID_W, GRID_H, blob, sea_level),
|
||||
)
|
||||
|
||||
return {"status": "imported"}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Import terrain elevation BLOBs into atlas_body_heightmaps (#906)"
|
||||
)
|
||||
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
|
||||
parser.add_argument("--body", help="Process only this body_id")
|
||||
parser.add_argument("--force", action="store_true",
|
||||
help="Re-import even if a row already exists")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Simulate without writing to DB")
|
||||
parser.add_argument("--verbose", action="store_true",
|
||||
help="Print per-body detail")
|
||||
args = parser.parse_args()
|
||||
|
||||
db_path = Path(args.db)
|
||||
if not db_path.exists():
|
||||
print(f"error: {db_path} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n Heightmap BLOB Import (#906)")
|
||||
print(f" DB: {db_path}")
|
||||
if args.dry_run:
|
||||
print(f" Mode: DRY RUN (no DB writes)")
|
||||
if args.force:
|
||||
print(f" Force: enabled (will overwrite existing rows)")
|
||||
print()
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
ensure_atlas_schema(conn)
|
||||
|
||||
bodies = query_inhabited_bodies(conn)
|
||||
if args.body:
|
||||
bodies = [b for b in bodies if b["body_id"] == args.body]
|
||||
if not bodies:
|
||||
print(f"error: body '{args.body}' not found or has no terrain_reference",
|
||||
file=sys.stderr)
|
||||
conn.close()
|
||||
sys.exit(1)
|
||||
|
||||
print(f" {len(bodies)} inhabited bodies with terrain_reference\n")
|
||||
|
||||
t_total = time.time()
|
||||
n_imported = 0
|
||||
n_skipped = 0
|
||||
n_gas = 0
|
||||
n_errors = 0
|
||||
|
||||
for i, body_info in enumerate(bodies):
|
||||
body_id = body_info["body_id"]
|
||||
t0 = time.time()
|
||||
|
||||
result = import_body(body_info, conn, args.force, args.dry_run, args.verbose)
|
||||
elapsed = time.time() - t0
|
||||
status = result["status"]
|
||||
|
||||
if status == "imported":
|
||||
n_imported += 1
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} imported ({elapsed:.1f}s)")
|
||||
elif status == "skipped":
|
||||
n_skipped += 1
|
||||
if args.verbose:
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} skipped (already imported)")
|
||||
elif status == "gas_giant":
|
||||
n_gas += 1
|
||||
if args.verbose:
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} gas giant — no surface")
|
||||
elif status == "error":
|
||||
n_errors += 1
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}")
|
||||
|
||||
if not args.dry_run:
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
|
||||
elapsed_total = time.time() - t_total
|
||||
print(f"\n Done in {elapsed_total:.1f}s")
|
||||
print(f" imported={n_imported} skipped={n_skipped} "
|
||||
f"gas_giant={n_gas} errors={n_errors}")
|
||||
|
||||
if n_errors > 0:
|
||||
print(f"\n {n_errors} error(s) — check output above", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,546 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
import_province_boundaries.py — Pre-compute province boundaries from watershed analysis.
|
||||
|
||||
For each inhabited body with a heightmap row in atlas_body_heightmaps, runs D8
|
||||
drainage analysis to derive drainage basin boundaries and stores them as pixel-space
|
||||
polylines in atlas_province_boundaries (D-205, D-208, #907).
|
||||
|
||||
Algorithm:
|
||||
1. Load float32 elevation BLOB from atlas_body_heightmaps.
|
||||
2. Depression-fill: raise sinks to the lowest-outlet neighbor (iterative).
|
||||
3. D8 flow direction: assign each cell to its steepest-descent neighbor.
|
||||
4. Flow accumulation: upstream cell count per cell (topological sort).
|
||||
5. Basin labeling: seed a basin per pour-point (flow-accumulation > threshold);
|
||||
flood-fill remaining cells following flow direction.
|
||||
6. Merge small basins (< 2% area) into the largest adjacent basin.
|
||||
7. Clamp basin count to [4, 12] by iterative merging of smallest basins.
|
||||
8. Trace boundary polylines between adjacent basins.
|
||||
9. Upsert rows into atlas_province_boundaries.
|
||||
|
||||
Province count target: 4–12 per body (D-205). Bodies with low relief get fewer,
|
||||
larger provinces; high-relief worlds get more.
|
||||
|
||||
Performance: ~3–5s per body on a single CPU core at canonical 512×256 resolution.
|
||||
The bottleneck is the pure-Python depression-fill + flow-direction scan (O(H×W) each,
|
||||
~131k cells). For a full run of ~270 inhabited bodies expect ~15–20 minutes.
|
||||
Hot loops (_depression_fill, _flow_direction) are candidates for NumPy vectorization
|
||||
if build time becomes a bottleneck; the current scalar implementation is correct
|
||||
and deterministic, which takes priority at this stage.
|
||||
|
||||
Incremental: bodies that already have rows in atlas_province_boundaries are skipped
|
||||
unless --force is passed.
|
||||
|
||||
Usage:
|
||||
tooling/planet-gen/import_province_boundaries.py
|
||||
tooling/planet-gen/import_province_boundaries.py --body GJ380c
|
||||
tooling/planet-gen/import_province_boundaries.py --force
|
||||
tooling/planet-gen/import_province_boundaries.py --dry-run
|
||||
|
||||
Exit codes:
|
||||
0 completed
|
||||
1 fatal error (missing DB, schema error)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
TOOLING_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
||||
|
||||
_venv_python = REPO_ROOT / ".venv" / "bin" / "python"
|
||||
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
|
||||
import os
|
||||
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
|
||||
|
||||
import numpy as np
|
||||
import sqlite3
|
||||
|
||||
from generate_atlas import (
|
||||
DB_PATH,
|
||||
ensure_atlas_schema,
|
||||
query_inhabited_bodies,
|
||||
)
|
||||
|
||||
# D8 neighbor offsets: (dr, dc)
|
||||
_D8 = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
|
||||
|
||||
# River threshold from D-208: cells with flow_accumulation > 200 are river cells.
|
||||
# Province seeds are local flow-accumulation maxima (watershed pour points).
|
||||
_FLOW_THRESHOLD = 200
|
||||
|
||||
# Minimum basin area as fraction of total cells before merging into neighbor.
|
||||
_MIN_BASIN_FRAC = 0.02
|
||||
|
||||
_PROVINCE_MIN = 4
|
||||
_PROVINCE_MAX = 12
|
||||
|
||||
|
||||
def _load_elevation(body_id: str, conn: sqlite3.Connection) -> np.ndarray | None:
|
||||
"""Load float32 LE elevation BLOB from atlas_body_heightmaps."""
|
||||
row = conn.execute(
|
||||
"SELECT data, width, height FROM atlas_body_heightmaps WHERE body_id = ?",
|
||||
(body_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
data, width, height = row
|
||||
arr = np.frombuffer(data, dtype="<f4").reshape((height, width))
|
||||
return arr.astype(np.float32)
|
||||
|
||||
|
||||
def _depression_fill(elev: np.ndarray) -> np.ndarray:
|
||||
"""Simple iterative depression fill: raise sinks to their lowest outlet.
|
||||
|
||||
Uses a shallow iterative pass — good enough for province-scale basins on
|
||||
512×256 grids. Not full priority-flood (which is O(N log N)); this O(N·k)
|
||||
approach converges in ≤10 passes on real heightmaps.
|
||||
"""
|
||||
H, W = elev.shape
|
||||
filled = elev.copy()
|
||||
for _ in range(10):
|
||||
changed = False
|
||||
for r in range(1, H - 1):
|
||||
for c in range(W):
|
||||
nbr_min = float("inf")
|
||||
for dr, dc in _D8:
|
||||
nr = r + dr
|
||||
nc = (c + dc) % W
|
||||
if 0 <= nr < H:
|
||||
nbr_min = min(nbr_min, filled[nr, nc])
|
||||
if filled[r, c] < nbr_min:
|
||||
filled[r, c] = nbr_min + 1e-6
|
||||
changed = True
|
||||
if not changed:
|
||||
break
|
||||
return filled
|
||||
|
||||
|
||||
def _flow_direction(filled: np.ndarray) -> np.ndarray:
|
||||
"""D8 flow direction: index into _D8 (0–7), or -1 for no outflow (edge/flat)."""
|
||||
H, W = filled.shape
|
||||
fdir = np.full((H, W), -1, dtype=np.int8)
|
||||
for r in range(H):
|
||||
for c in range(W):
|
||||
best_drop = 0.0
|
||||
best_k = -1
|
||||
for k, (dr, dc) in enumerate(_D8):
|
||||
nr = r + dr
|
||||
nc = (c + dc) % W
|
||||
if nr < 0 or nr >= H:
|
||||
continue
|
||||
drop = filled[r, c] - filled[nr, nc]
|
||||
if drop > best_drop:
|
||||
best_drop = drop
|
||||
best_k = k
|
||||
fdir[r, c] = best_k
|
||||
return fdir
|
||||
|
||||
|
||||
def _flow_accumulation(fdir: np.ndarray) -> np.ndarray:
|
||||
"""Flow accumulation via topological sort of the D8 DAG."""
|
||||
H, W = fdir.shape
|
||||
in_degree = np.zeros((H, W), dtype=np.int32)
|
||||
|
||||
for r in range(H):
|
||||
for c in range(W):
|
||||
k = int(fdir[r, c])
|
||||
if k < 0:
|
||||
continue
|
||||
dr, dc = _D8[k]
|
||||
nr = r + dr
|
||||
nc = (c + dc) % W
|
||||
if 0 <= nr < H:
|
||||
in_degree[nr, nc] += 1
|
||||
|
||||
from collections import deque
|
||||
queue = deque()
|
||||
for r in range(H):
|
||||
for c in range(W):
|
||||
if in_degree[r, c] == 0:
|
||||
queue.append((r, c))
|
||||
|
||||
accum = np.ones((H, W), dtype=np.int32)
|
||||
while queue:
|
||||
r, c = queue.popleft()
|
||||
k = int(fdir[r, c])
|
||||
if k < 0:
|
||||
continue
|
||||
dr, dc = _D8[k]
|
||||
nr = r + dr
|
||||
nc = (c + dc) % W
|
||||
if 0 <= nr < H:
|
||||
accum[nr, nc] += accum[r, c]
|
||||
in_degree[nr, nc] -= 1
|
||||
if in_degree[nr, nc] == 0:
|
||||
queue.append((nr, nc))
|
||||
|
||||
return accum
|
||||
|
||||
|
||||
def _label_basins(fdir: np.ndarray, accum: np.ndarray) -> np.ndarray:
|
||||
"""Label each cell with a basin ID via pour-point flood fill.
|
||||
|
||||
Pour points are local flow-accumulation maxima above the river threshold.
|
||||
Each pour point seeds a basin; remaining cells are labeled by tracing
|
||||
flow direction back to their pour-point seed.
|
||||
"""
|
||||
H, W = fdir.shape
|
||||
labels = np.full((H, W), -1, dtype=np.int32)
|
||||
|
||||
# Seed one label per local accum maximum above threshold.
|
||||
# Use a simple scan: a cell is a local maximum if no neighbor has higher accum.
|
||||
pour_pts: list[tuple[int, int]] = []
|
||||
for r in range(H):
|
||||
for c in range(W):
|
||||
if accum[r, c] <= _FLOW_THRESHOLD:
|
||||
continue
|
||||
is_max = True
|
||||
for dr, dc in _D8:
|
||||
nr = r + dr
|
||||
nc = (c + dc) % W
|
||||
if 0 <= nr < H and accum[nr, nc] > accum[r, c]:
|
||||
is_max = False
|
||||
break
|
||||
if is_max:
|
||||
pour_pts.append((r, c))
|
||||
|
||||
# If no pour points (e.g. flat/ocean world), create a single basin.
|
||||
if not pour_pts:
|
||||
labels[:] = 0
|
||||
return labels
|
||||
|
||||
for basin_id, (r, c) in enumerate(pour_pts):
|
||||
labels[r, c] = basin_id
|
||||
|
||||
# BFS flood: for each unlabeled cell, follow flow direction until a labeled
|
||||
# cell is reached; assign that label back along the path.
|
||||
|
||||
def _trace(r0: int, c0: int) -> int:
|
||||
path: list[tuple[int, int]] = []
|
||||
r, c = r0, c0
|
||||
for _ in range(H * W):
|
||||
if labels[r, c] >= 0:
|
||||
lbl = labels[r, c]
|
||||
for pr, pc in path:
|
||||
labels[pr, pc] = lbl
|
||||
return lbl
|
||||
path.append((r, c))
|
||||
k = int(fdir[r, c])
|
||||
if k < 0:
|
||||
# No outflow — assign basin 0
|
||||
lbl = 0
|
||||
for pr, pc in path:
|
||||
labels[pr, pc] = lbl
|
||||
return lbl
|
||||
dr, dc = _D8[k]
|
||||
nr = r + dr
|
||||
nc = (c + dc) % W
|
||||
if nr < 0 or nr >= H:
|
||||
lbl = 0
|
||||
for pr, pc in path:
|
||||
labels[pr, pc] = lbl
|
||||
return lbl
|
||||
r, c = nr, nc
|
||||
# Cycle guard
|
||||
lbl = 0
|
||||
for pr, pc in path:
|
||||
labels[pr, pc] = lbl
|
||||
return lbl
|
||||
|
||||
for r in range(H):
|
||||
for c in range(W):
|
||||
if labels[r, c] < 0:
|
||||
_trace(r, c)
|
||||
|
||||
return labels
|
||||
|
||||
|
||||
def _merge_small_basins(
|
||||
labels: np.ndarray, target_min: int, target_max: int
|
||||
) -> np.ndarray:
|
||||
"""Merge tiny basins into their largest neighbor until count is in [target_min, target_max]."""
|
||||
H, W = labels.shape
|
||||
labels = labels.copy()
|
||||
|
||||
def _basin_sizes() -> dict[int, int]:
|
||||
ids, counts = np.unique(labels, return_counts=True)
|
||||
return dict(zip(ids.tolist(), counts.tolist()))
|
||||
|
||||
def _neighbors(basin_id: int) -> set[int]:
|
||||
mask = labels == basin_id
|
||||
# Dilate mask by 1 pixel in each direction, find adjacent basin IDs.
|
||||
nbrs: set[int] = set()
|
||||
rs, cs = np.where(mask)
|
||||
for r, c in zip(rs.tolist(), cs.tolist()):
|
||||
for dr, dc in _D8:
|
||||
nr = r + dr
|
||||
nc = (c + dc) % W
|
||||
if 0 <= nr < H:
|
||||
nbr_id = int(labels[nr, nc])
|
||||
if nbr_id != basin_id:
|
||||
nbrs.add(nbr_id)
|
||||
return nbrs
|
||||
|
||||
total = H * W
|
||||
for _ in range(200):
|
||||
sizes = _basin_sizes()
|
||||
n_basins = len(sizes)
|
||||
if n_basins <= target_max and all(
|
||||
v / total >= _MIN_BASIN_FRAC for v in sizes.values()
|
||||
):
|
||||
break
|
||||
if n_basins <= target_min:
|
||||
break
|
||||
|
||||
# Find the smallest basin
|
||||
smallest_id = min(sizes, key=lambda b: sizes[b])
|
||||
smallest_frac = sizes[smallest_id] / total
|
||||
|
||||
if n_basins <= target_max and smallest_frac >= _MIN_BASIN_FRAC:
|
||||
break
|
||||
|
||||
# Merge into its largest neighbor
|
||||
nbrs = _neighbors(smallest_id)
|
||||
if not nbrs:
|
||||
break
|
||||
merge_into = max(nbrs, key=lambda b: sizes.get(b, 0))
|
||||
labels[labels == smallest_id] = merge_into
|
||||
|
||||
# Re-number contiguously from 0
|
||||
unique_ids = sorted(np.unique(labels).tolist())
|
||||
remap = {old: new for new, old in enumerate(unique_ids)}
|
||||
new_labels = np.zeros_like(labels)
|
||||
for old, new in remap.items():
|
||||
new_labels[labels == old] = new
|
||||
return new_labels
|
||||
|
||||
|
||||
def _trace_boundary(labels: np.ndarray, basin_id: int) -> list[list[int]]:
|
||||
"""Trace the outer boundary of a basin as a pixel-space polyline.
|
||||
|
||||
Returns a list of [row, col] points forming the boundary polygon.
|
||||
Uses a simple contour walk: find all boundary cells (cells adjacent to a
|
||||
different basin), then sort them by angle from centroid to approximate a
|
||||
closed polygon.
|
||||
"""
|
||||
H, W = labels.shape
|
||||
mask = labels == basin_id
|
||||
|
||||
# Boundary cells: in this basin AND adjacent to a different basin
|
||||
boundary: list[tuple[int, int]] = []
|
||||
rs, cs = np.where(mask)
|
||||
for r, c in zip(rs.tolist(), cs.tolist()):
|
||||
on_boundary = False
|
||||
for dr, dc in _D8:
|
||||
nr = r + dr
|
||||
nc = (c + dc) % W
|
||||
if nr < 0 or nr >= H:
|
||||
on_boundary = True
|
||||
break
|
||||
if labels[nr, nc] != basin_id:
|
||||
on_boundary = True
|
||||
break
|
||||
if on_boundary:
|
||||
boundary.append((r, c))
|
||||
|
||||
if not boundary:
|
||||
return []
|
||||
|
||||
# Sort by angle from centroid — produces a rough polygon outline.
|
||||
arr = np.array(boundary, dtype=np.float32)
|
||||
centroid_r = float(np.mean(arr[:, 0]))
|
||||
centroid_c = float(np.mean(arr[:, 1]))
|
||||
angles = np.arctan2(arr[:, 0] - centroid_r, arr[:, 1] - centroid_c)
|
||||
order = np.argsort(angles)
|
||||
|
||||
# Subsample if very large — keep at most 500 points for storage efficiency.
|
||||
pts = [boundary[i] for i in order.tolist()]
|
||||
if len(pts) > 500:
|
||||
step = len(pts) // 500
|
||||
pts = pts[::step]
|
||||
|
||||
return [[r, c] for r, c in pts]
|
||||
|
||||
|
||||
def compute_province_boundaries(
|
||||
body_id: str, elevation: np.ndarray
|
||||
) -> list[dict]:
|
||||
"""Run full watershed analysis; return list of basin dicts.
|
||||
|
||||
Each dict:
|
||||
basin_id: int
|
||||
path: JSON-serialisable [[row, col], ...]
|
||||
area_pct: float
|
||||
"""
|
||||
H, W = elevation.shape
|
||||
total_cells = H * W
|
||||
|
||||
filled = _depression_fill(elevation)
|
||||
fdir = _flow_direction(filled)
|
||||
accum = _flow_accumulation(fdir)
|
||||
labels = _label_basins(fdir, accum)
|
||||
labels = _merge_small_basins(labels, _PROVINCE_MIN, _PROVINCE_MAX)
|
||||
|
||||
unique_ids = sorted(np.unique(labels).tolist())
|
||||
basins = []
|
||||
for basin_id in unique_ids:
|
||||
count = int(np.sum(labels == basin_id))
|
||||
area_pct = count / total_cells
|
||||
path = _trace_boundary(labels, basin_id)
|
||||
if not path:
|
||||
continue
|
||||
basins.append({
|
||||
"basin_id": basin_id,
|
||||
"path": path,
|
||||
"area_pct": area_pct,
|
||||
})
|
||||
|
||||
return basins
|
||||
|
||||
|
||||
def import_body_provinces(
|
||||
body_id: str,
|
||||
conn: sqlite3.Connection,
|
||||
force: bool,
|
||||
dry_run: bool,
|
||||
verbose: bool,
|
||||
) -> dict:
|
||||
"""Import province boundary rows for one body.
|
||||
|
||||
Returns dict:
|
||||
status: 'imported' | 'skipped' | 'no_heightmap' | 'error'
|
||||
imported: count of basins written
|
||||
message: detail on error/skip
|
||||
"""
|
||||
if not force:
|
||||
existing = conn.execute(
|
||||
"SELECT COUNT(*) FROM atlas_province_boundaries WHERE body_id = ?",
|
||||
(body_id,),
|
||||
).fetchone()[0]
|
||||
if existing > 0:
|
||||
return {"status": "skipped", "imported": 0,
|
||||
"message": f"already has {existing} rows"}
|
||||
|
||||
elevation = _load_elevation(body_id, conn)
|
||||
if elevation is None:
|
||||
return {"status": "no_heightmap", "imported": 0,
|
||||
"message": "no row in atlas_body_heightmaps"}
|
||||
|
||||
try:
|
||||
basins = compute_province_boundaries(body_id, elevation)
|
||||
except Exception as exc:
|
||||
return {"status": "error", "imported": 0, "message": str(exc)}
|
||||
|
||||
if not basins:
|
||||
return {"status": "error", "imported": 0,
|
||||
"message": "no basins produced from watershed analysis"}
|
||||
|
||||
if verbose:
|
||||
areas = [f"{b['basin_id']}:{b['area_pct']:.1%}" for b in basins]
|
||||
print(f" {body_id}: {len(basins)} basins — {', '.join(areas)}")
|
||||
|
||||
if not dry_run:
|
||||
with conn: # per-body transaction (BEGIN/COMMIT): rolls back this body on exception, keeps prior commits
|
||||
conn.execute(
|
||||
"DELETE FROM atlas_province_boundaries WHERE body_id = ?",
|
||||
(body_id,),
|
||||
)
|
||||
for b in basins:
|
||||
conn.execute(
|
||||
"""INSERT INTO atlas_province_boundaries
|
||||
(body_id, basin_id, path, area_pct)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(body_id, b["basin_id"], json.dumps(b["path"]), b["area_pct"]),
|
||||
)
|
||||
|
||||
return {"status": "imported", "imported": len(basins)}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Pre-compute province boundaries from watershed analysis (D-205, #907)"
|
||||
)
|
||||
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
|
||||
parser.add_argument("--body", help="Process only this body_id")
|
||||
parser.add_argument("--force", action="store_true",
|
||||
help="Re-import even if rows already exist")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Analyse without writing to DB")
|
||||
parser.add_argument("--verbose", action="store_true",
|
||||
help="Print per-body detail")
|
||||
args = parser.parse_args()
|
||||
|
||||
db_path = Path(args.db)
|
||||
if not db_path.exists():
|
||||
print(f"error: {db_path} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n Province Boundary Import (#907)")
|
||||
print(f" DB: {db_path}")
|
||||
if args.dry_run:
|
||||
print(f" Mode: DRY RUN (no DB writes)")
|
||||
if args.force:
|
||||
print(f" Force: enabled (will overwrite existing rows)")
|
||||
print()
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
ensure_atlas_schema(conn)
|
||||
|
||||
bodies = query_inhabited_bodies(conn)
|
||||
if args.body:
|
||||
bodies = [b for b in bodies if b["body_id"] == args.body]
|
||||
if not bodies:
|
||||
print(f"error: body '{args.body}' not found or has no terrain_reference",
|
||||
file=sys.stderr)
|
||||
conn.close()
|
||||
sys.exit(1)
|
||||
|
||||
print(f" {len(bodies)} inhabited bodies with terrain_reference\n")
|
||||
|
||||
t_total = time.time()
|
||||
n_imported = 0
|
||||
n_skipped = 0
|
||||
n_no_hmap = 0
|
||||
n_errors = 0
|
||||
|
||||
for i, body_info in enumerate(bodies):
|
||||
body_id = body_info["body_id"]
|
||||
t0 = time.time()
|
||||
|
||||
result = import_body_provinces(body_id, conn, args.force, args.dry_run, args.verbose)
|
||||
elapsed = time.time() - t0
|
||||
status = result["status"]
|
||||
|
||||
if status == "imported":
|
||||
n_imported += 1
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} {result['imported']} basins ({elapsed:.1f}s)")
|
||||
elif status == "skipped":
|
||||
n_skipped += 1
|
||||
if args.verbose:
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} skipped ({result['message']})")
|
||||
elif status == "no_heightmap":
|
||||
n_no_hmap += 1
|
||||
if args.verbose:
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} no heightmap — skipping")
|
||||
elif status == "error":
|
||||
n_errors += 1
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}")
|
||||
|
||||
conn.close()
|
||||
|
||||
elapsed_total = time.time() - t_total
|
||||
print(f"\n Done in {elapsed_total:.1f}s")
|
||||
print(f" imported={n_imported} skipped={n_skipped} "
|
||||
f"no_heightmap={n_no_hmap} errors={n_errors}")
|
||||
|
||||
if n_errors > 0:
|
||||
print(f"\n {n_errors} error(s) — check output above", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -230,6 +230,7 @@ def build_batch_prompt(
|
||||
body_name: str | None = None,
|
||||
system_hook: str | None = None,
|
||||
mood: str | None = None,
|
||||
cultural_history: str | None = None,
|
||||
ctx_size: int = 1024,
|
||||
) -> str:
|
||||
"""Build a batch naming prompt asking for N names in one call.
|
||||
@@ -238,6 +239,10 @@ def build_batch_prompt(
|
||||
with few-shot examples showing comma-separated lists. The model
|
||||
pattern-completes the list.
|
||||
|
||||
`cultural_history` threads secondary cultural registers into the
|
||||
prompt so names reflect the layered settlement history of a corridor
|
||||
rather than only the primary inflection style (#886 §6).
|
||||
|
||||
The prompt is truncated to fit within ctx_size tokens (rough
|
||||
estimate: 1 token ≈ 4 chars).
|
||||
"""
|
||||
@@ -274,9 +279,11 @@ def build_batch_prompt(
|
||||
lines.append(". ".join(ident) + ".")
|
||||
if system_hook:
|
||||
lines.append(f"About the system: {system_hook}")
|
||||
if cultural_history:
|
||||
lines.append(f"Settlement history: {cultural_history}")
|
||||
if taken:
|
||||
lines.append(f"Already used (do NOT repeat): {', '.join(taken)}")
|
||||
if system_name or body_name or system_hook or taken:
|
||||
if system_name or body_name or system_hook or cultural_history or taken:
|
||||
lines.append("")
|
||||
|
||||
# Few-shot examples showing batch format
|
||||
@@ -292,7 +299,7 @@ def build_batch_prompt(
|
||||
max_chars = (ctx_size - 16) * 4 # 16 tokens headroom for output
|
||||
budget = max_chars - len(fixed) - len(tail) - 2 # 2 for newlines
|
||||
if budget < 0:
|
||||
# Trim the taken list to fit
|
||||
# Trim taken list first (preserving cultural_history context)
|
||||
while taken and budget < 0:
|
||||
taken = taken[:-1]
|
||||
lines_rebuild = [preamble, ""]
|
||||
@@ -305,6 +312,8 @@ def build_batch_prompt(
|
||||
lines_rebuild.append(". ".join(ident) + ".")
|
||||
if system_hook:
|
||||
lines_rebuild.append(f"About the system: {system_hook}")
|
||||
if cultural_history:
|
||||
lines_rebuild.append(f"Settlement history: {cultural_history}")
|
||||
if taken:
|
||||
lines_rebuild.append(f"Already used (do NOT repeat): {', '.join(taken)}")
|
||||
lines_rebuild.append("")
|
||||
@@ -336,6 +345,7 @@ def name_features_batch(
|
||||
mood: str | None,
|
||||
body_id: str,
|
||||
world_seed: int,
|
||||
cultural_history: str | None = None,
|
||||
ctx_size: int = 1024,
|
||||
) -> list[str]:
|
||||
"""Generate `count` names for a feature type using batch prompting.
|
||||
@@ -346,6 +356,9 @@ def name_features_batch(
|
||||
3. Rank by distinctiveness via Levenshtein, pick top `count`.
|
||||
4. If short, refill from the next adjacent register in the corridor.
|
||||
5. Return the final list of names.
|
||||
|
||||
`cultural_history` is forwarded to build_batch_prompt to enrich the
|
||||
prompt with secondary cultural context (#886 §6).
|
||||
"""
|
||||
prompt = build_batch_prompt(
|
||||
feature_type=feature_type,
|
||||
@@ -357,6 +370,7 @@ def name_features_batch(
|
||||
body_name=body_name,
|
||||
system_hook=system_hook,
|
||||
mood=mood,
|
||||
cultural_history=cultural_history,
|
||||
ctx_size=ctx_size,
|
||||
)
|
||||
|
||||
@@ -396,6 +410,7 @@ def name_features_batch(
|
||||
body_name=body_name,
|
||||
system_hook=system_hook,
|
||||
mood=mood,
|
||||
cultural_history=cultural_history,
|
||||
ctx_size=ctx_size,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Canonical systems.db schema version — shared by all generators (#888).
|
||||
|
||||
Bump SCHEMA_VERSION manually on any backwards-incompatible schema change
|
||||
(column removed, type changed, FK constraint added, table dropped).
|
||||
Additive changes (new nullable columns, new tables, new indexes) do not
|
||||
require a bump.
|
||||
|
||||
Imported by:
|
||||
tooling/economy-db/import_economics.py
|
||||
tooling/planet-gen/generate_atlas.py
|
||||
"""
|
||||
|
||||
SCHEMA_VERSION = "1.0.0"
|
||||
@@ -5,13 +5,13 @@ slug: arbour-aggregates
|
||||
category: corporation
|
||||
status: canonical
|
||||
created: 2026-04-21
|
||||
updated: 2026-04-21
|
||||
scope: GJ 338B local; north corridor secondary
|
||||
updated: 2026-05-02
|
||||
scope: GJ 338B local; core zone construction supply
|
||||
faction_type: economic
|
||||
headquarters: Arbour (GJ 338B)
|
||||
tags: [stone, timber, extraction, tractus]
|
||||
decision_refs: [D-175]
|
||||
cross_refs: []
|
||||
cross_refs: [earth-standard-group]
|
||||
---
|
||||
|
||||
# Arbour Aggregates
|
||||
@@ -19,7 +19,7 @@ cross_refs: []
|
||||
**Type:** Corporation — Stone and Timber Extraction Cooperative
|
||||
**Also Known As:** Arbour Agg, AAC
|
||||
**Status:** Canonical
|
||||
**Scope:** Arbour system primary; corridor construction supply secondary
|
||||
**Scope:** Arbour system primary; core zone construction supply secondary
|
||||
**Headquarters:** Arbour (GJ 338B) — surface operations, cooperative ownership
|
||||
**Classification:** Extraction cooperative; producer behavioral archetype
|
||||
|
||||
@@ -27,10 +27,36 @@ cross_refs: []
|
||||
|
||||
## Overview
|
||||
|
||||
Arbour was settled early and settled well. The planet's mixed biome — temperate forest belts alongside sedimentary stone formations — gave the founding cooperative two resource streams that corridor construction has needed ever since.
|
||||
Arbour was settled early and settled well. The planet's mixed biome — temperate forest belts alongside sedimentary stone formations — gave the founding cooperative two resource streams that corridor construction has needed ever since. A system that could produce both structural aggregate and certified timber without requiring either to be shipped in from the inner corridor had a material advantage during the settlement expansion period, and the cooperative built its commercial identity around that advantage.
|
||||
|
||||
Arbour Aggregates handles both. Stone quarrying supplies aggregate and cut stone to corridor station builders; managed timber harvest (certified regrowth cycles, 80-year rotation) supplies structural panel manufacturers who can't rely on synthetic composite alone.
|
||||
Arbour Aggregates handles both extraction operations. Stone quarrying supplies aggregate and cut stone to corridor station builders; managed timber harvest, run on certified regrowth cycles with an 80-year rotation, supplies structural panel manufacturers who cannot rely on synthetic composite alone. The two operations share logistics infrastructure and governance under a single cooperative structure, which has made both more efficient than they would have been under separate ownership.
|
||||
|
||||
**Primary operations:** Open-face stone quarrying at Arbour's central plateau, managed softwood and hardwood forest operations in the temperate belt.
|
||||
---
|
||||
|
||||
**Market position:** Reliable bulk supplier to north and core corridor. ESG dominates Sol-side stone; Arbour Aggregates holds the east-corridor share where transit distances from Sol make ESG supply expensive.
|
||||
## Operations
|
||||
|
||||
**Stone quarrying:** Open-face extraction at Arbour's central plateau, producing aggregate for bulk construction supply and cut stone in the grades that corridor station builders specify. The plateau formation is not the Reach's richest stone deposit, but its accessibility — surface extraction requiring minimal infrastructure relative to belt mining — keeps extraction costs low enough to compete at the transit distances involved.
|
||||
|
||||
**Timber operations:** Managed softwood and hardwood harvest from Arbour's temperate forest belt, certified under an 80-year rotation program. The certification covers both the harvest practices and the replanting schedule; buyers whose procurement specifications require documented sustainable sourcing use Arbour Aggregates' timber on the basis of this certification. The 80-year rotation is slower than the production timelines some buyers would prefer, which makes the timber operation a long-cycle asset rather than a short-cycle one.
|
||||
|
||||
**Logistics:** Freight from Arbour surface to transit moves through the system's own aperture facilities. The cooperative manages its own outbound logistics rather than contracting through an intermediary, which keeps margin on freight within the cooperative at the cost of maintaining logistics staff who are not otherwise needed for extraction.
|
||||
|
||||
---
|
||||
|
||||
## Market Position
|
||||
|
||||
Arbour Aggregates holds the core zone construction supply share where transit distances from Sol make Earth Standard Group supply expensive. GJ 338B sits at hop 3 from Gateway, connected to both Sirius and Renaissance — a core zone position that gives the cooperative competitive reach across the inner orbit's construction sector. The market position is geographic rather than technical — ESG's Sol-system throughput far exceeds anything Arbour can match — but geographic advantage in a freight-intensive sector is durable. Construction projects in the core and inner corridor systems buy from Arbour because the transit math works in the cooperative's favor at those distances.
|
||||
|
||||
The managed timber certification creates a secondary position in the premium structural timber market, which is smaller but commands better pricing than bulk aggregate. Buyers who specify certified sustainable sourcing pay a premium the aggregate business does not generate.
|
||||
|
||||
---
|
||||
|
||||
**Cross-References:**
|
||||
- [Arbour](../star-systems/GJ-338B/index.md) — Headquarters system; surface quarrying and forest operations
|
||||
- [Earth Standard Group](earth-standard-group.md) — Sol-system competitor; ESG dominates Sol-side stone supply
|
||||
|
||||
---
|
||||
|
||||
**Status:** Canonical
|
||||
**Created:** 2026-04-21
|
||||
**Updated:** 2026-05-02
|
||||
|
||||
@@ -20,7 +20,7 @@ cross_refs: [bavarian-craft, rheintal-systems, durban-engineering]
|
||||
**Also Known As:** Bergkraft, BKA
|
||||
**Status:** Canonical
|
||||
**Scope:** West reach primary; inward industrial procurement secondary
|
||||
**Headquarters:** Bergtor (GJ 505A) — west reach, 4 hops from Gateway
|
||||
**Headquarters:** Bergtor (GJ 505A) — west reach, 7 hops from Gateway
|
||||
**Classification:** Sub-Syndic precision engineering enterprise; industrial supply and transit systems
|
||||
|
||||
---
|
||||
|
||||
@@ -11,7 +11,7 @@ faction_type: economic
|
||||
headquarters: Nyrheim (GJ 3737)
|
||||
tags: [stone, quarrying, west_reach, mark]
|
||||
decision_refs: [D-175]
|
||||
cross_refs: []
|
||||
cross_refs: [gate-corporation, GJ-3737]
|
||||
---
|
||||
|
||||
# Bífröst Marmor
|
||||
@@ -63,8 +63,14 @@ The cooperative has responded by maintaining the manufacturing division at its c
|
||||
|
||||
---
|
||||
|
||||
## Silence
|
||||
## What They Don't Talk About
|
||||
|
||||
The geological survey that discovered the marble formation also mapped the full extent of the metamorphic layer. The formation runs deeper and wider than the current quarry face exploits. The cooperative's published reserve estimates describe "decades of extractable material at current production rates." The actual survey data, held in the practical council's sealed records, describes something significantly larger. The cooperative has not disclosed the full extent because doing so would invite exactly the kind of outside commercial interest that the founding charter was written to prevent.
|
||||
|
||||
There is also the question of the formation's origin. The specific pressure-temperature conditions that created the Kvitfjell veining pattern are consistent with the moon's current tidal relationship with the gas giant — but the geological survey noted that the formation appears older than the current orbital configuration should allow. The survey team flagged this as "requiring further investigation" and the cooperative filed the flag without commissioning the investigation.
|
||||
|
||||
---
|
||||
|
||||
**Cross-References:**
|
||||
- [Nyrheim](../star-systems/GJ-3737/index.md) — Headquarters system; Kvitfjell moon quarry operations
|
||||
- [Gate Corporation](gate-corporation.md) — Transit fee dispute over freight through Nyrheim's apertures
|
||||
|
||||
@@ -11,7 +11,7 @@ faction_type: economic
|
||||
headquarters: Earth (GJ 0)
|
||||
tags: [stone, advanced_alloys, industrial, sol_system]
|
||||
decision_refs: [D-175]
|
||||
cross_refs: []
|
||||
cross_refs: [gate-corporation, arbour-aggregates]
|
||||
---
|
||||
|
||||
# Earth Standard Group
|
||||
@@ -29,8 +29,43 @@ cross_refs: []
|
||||
|
||||
Three centuries of colonization outpaced the Reach's ability to supply itself. Earth Standard Group filled the gap that the colonization wave left behind — old-world industrial capacity on Sol-system scale, producing the stone aggregate and advanced alloys that new settlements needed before their own extraction infrastructure came online.
|
||||
|
||||
ESG is not glamorous. It is large, methodical, and has survived every economic cycle since the first gate opened by doing one thing well: producing reliable bulk materials at Sol-system throughput and shipping them corridor-wide.
|
||||
ESG is not glamorous. It is large, methodical, and has survived every economic cycle since the first gate opened by doing one thing well: producing reliable bulk materials at Sol-system throughput and shipping them corridor-wide. The company does not pursue margin by differentiating its products or cultivating institutional relationships. It pursues margin by producing at volumes that smaller regional suppliers cannot approach and by holding long-term supply contracts with the construction and infrastructure buyers who need guaranteed material availability over multi-year project timelines.
|
||||
|
||||
**Primary operations:** Stone quarrying and aggregate processing (Luna surface operations, Martian regolith processing), advanced alloy fabrication at orbital foundries.
|
||||
Three centuries of this approach have made ESG one of the largest material suppliers in the Reach without making it particularly visible. Bulk industrial suppliers do not attract the kind of attention that consumer brands or financial institutions do. They are present in everything and noticed by almost no one.
|
||||
|
||||
**Market position:** Dominant in corridor construction supply during early settlement phases; retains long-term supply contracts with the Gate Corporation and major station builders.
|
||||
---
|
||||
|
||||
## Origin
|
||||
|
||||
ESG's origins are pre-colonization — the company's core businesses were established on Earth and the Sol system before the first Founder Gates were activated. What changed when the gates opened was the market: suddenly an industrial conglomerate with Luna surface quarrying operations and Martian regolith processing could supply aggregate to systems that had none. The colonization wave created demand that Sol-system industry was positioned to meet before the new systems had developed their own extraction capacity.
|
||||
|
||||
The company's early corridor contracts were with settlement authorities purchasing aggregate for habitat construction and with the engineering concerns building the first span gate installations. Both client relationships have continued in various forms across three centuries.
|
||||
|
||||
---
|
||||
|
||||
## Operations
|
||||
|
||||
**Stone quarrying and aggregate processing:** Luna surface operations producing aggregate from the Moon's regolith formations, and Martian surface processing of regolith into construction-grade material. The Luna operations have been running continuously since the early settlement period. The Martian processing operation is larger by throughput and serves the heavy construction supply function for the inner corridor.
|
||||
|
||||
**Advanced alloy fabrication:** Orbital foundries in the Sol system producing advanced alloys for the corridor's manufacturing and construction sectors. The orbital foundries use the zero-gravity manufacturing environment for alloy compositions that benefit from it. This product line is technically distinct from the aggregate business but uses the same gate transit and freight infrastructure for corridor distribution.
|
||||
|
||||
**Gate Corporation supply contracts:** Long-term supply agreements with the Gate Corporation for materials used in span gate construction and maintenance. The Gate Corporation's manufacturing base at Renaissance (GJ 251) draws on ESG supply for specific alloy specifications that the Gate Corporation's own fabrication facilities do not produce. The relationship is commercially standard and operationally significant for both parties.
|
||||
|
||||
---
|
||||
|
||||
## Market Position
|
||||
|
||||
ESG is dominant in corridor construction supply for inner and core systems during settlement phases, and retains long-term supply contracts with the Gate Corporation and major station builders that make its revenue predictable across multi-decade project cycles. In systems where transit costs from Sol become prohibitive — the east corridor, the outer reach — regional suppliers hold the market share. ESG's geographic advantage is the core and inner corridor, where Sol proximity makes its throughput competitive with anything a regional supplier can produce.
|
||||
|
||||
---
|
||||
|
||||
**Cross-References:**
|
||||
- [Earth (Sol system)](../star-systems/GJ-0/index.md) — Headquarters; Luna and Martian operations
|
||||
- [Gate Corporation](gate-corporation.md) — Long-term supply client; span gate construction materials
|
||||
- [Arbour Aggregates](arbour-aggregates.md) — Regional competitor; east corridor stone supply
|
||||
|
||||
---
|
||||
|
||||
**Status:** Canonical
|
||||
**Created:** 2026-04-21
|
||||
**Updated:** 2026-04-21
|
||||
|
||||
@@ -9,7 +9,7 @@ updated: 2026-03-14
|
||||
scope: reach-wide
|
||||
faction_type: economic
|
||||
headquarters: Renaissance (GJ 251)
|
||||
tags: [fusion_fuel, gate_infrastructure, reach_wide, monopolist]
|
||||
tags: [gate_components, gate_infrastructure, reach_wide, monopolist]
|
||||
decision_refs: [D-095, D-175]
|
||||
cross_refs: [kaur-observatory-equipment]
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ updated: 2026-04-05
|
||||
scope: regional
|
||||
faction_type: economic
|
||||
headquarters: Changwon (GJ 860B)
|
||||
tags: [lattice_substrate, electronics, precision_instruments, east_reach, korean, assembly]
|
||||
tags: [electronics, lattice_substrate, precision_instruments, east_reach, korean, assembly]
|
||||
decision_refs: [D-175]
|
||||
cross_refs: [kaur-observatory-equipment]
|
||||
---
|
||||
|
||||
@@ -20,7 +20,7 @@ cross_refs: [kumho-navigation, namsan-collective, kyoei-design]
|
||||
**Also Known As:** Jeju, JL Components
|
||||
**Status:** Canonical
|
||||
**Scope:** East reach primary; corridor-wide institutional distribution secondary
|
||||
**Headquarters:** Yeongwol (GJ 268) — 2-aperture system, 4 hops from Gateway
|
||||
**Headquarters:** Yeongwol (GJ 268) — 2-aperture system, 6 hops from Gateway
|
||||
**Classification:** Sub-Syndic technology enterprise; certified industrial component supplier
|
||||
|
||||
---
|
||||
|
||||
@@ -55,6 +55,6 @@ The company's freight moves inward through Groenland and disperses into the west
|
||||
|
||||
---
|
||||
|
||||
## Silence
|
||||
## What They Don't Talk About
|
||||
|
||||
The condition of the old-growth zones beyond the current harvest boundary. The original concession surveys mapped significantly more old-growth area than the current regeneration cycle accounts for. What happened in those sections is not discussed.
|
||||
|
||||
@@ -20,7 +20,7 @@ cross_refs: [kellervolk, nordhavn-financial, lowlands-consumer]
|
||||
**Also Known As:** Norrland, Norrland House
|
||||
**Status:** Canonical
|
||||
**Scope:** West reach corridor primary; inward premium distribution secondary
|
||||
**Headquarters:** Nyrheim (GJ 3737) — 3-aperture hub, 6 hops from Gateway
|
||||
**Headquarters:** Nyrheim (GJ 3737) — 2-aperture loop member, 7 hops from Gateway
|
||||
**Classification:** Sub-Syndic artisan enterprise; certified sustainable woodcraft production
|
||||
|
||||
---
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
title: "Rush Mining"
|
||||
description: "Frontier extraction operation at Rush (GJ 725B) — metallic ore, rare minerals, and lattice-grade material from Struve's belt and surface deposits, supplying corridor processors who can't source from the established south_reach operations"
|
||||
description: "Multi-resource extraction operation at Rush (GJ 725B) — metallic ore, rare minerals, and lattice-grade material from Struve's belt and surface deposits, operating one hop from Gateway in a system that has been a junction for only forty years"
|
||||
slug: rush-mining
|
||||
category: corporation
|
||||
status: canonical
|
||||
created: 2026-04-21
|
||||
updated: 2026-04-21
|
||||
scope: GJ 725B local; outer corridor secondary
|
||||
updated: 2026-05-02
|
||||
scope: GJ 725B local; corridor-wide secondary
|
||||
faction_type: economic
|
||||
headquarters: Rush (GJ 725B)
|
||||
tags: [metallic_ore, rare_minerals, lattice_grade_material, mining, frontier, independent]
|
||||
decision_refs: [D-175]
|
||||
cross_refs: []
|
||||
cross_refs: [rare-vein-survey]
|
||||
---
|
||||
|
||||
# Rush Mining
|
||||
@@ -19,7 +19,7 @@ cross_refs: []
|
||||
**Type:** Corporation — Multi-Resource Extraction
|
||||
**Also Known As:** Rush, Rush Mining Co.
|
||||
**Status:** Canonical
|
||||
**Scope:** Struve system primary; outer corridor spot market secondary
|
||||
**Scope:** Struve system primary; corridor-wide secondary
|
||||
**Headquarters:** Rush (GJ 725B, Struve system) — surface and orbital operations
|
||||
**Classification:** Mining operation; producer behavioral archetype
|
||||
|
||||
@@ -27,10 +27,46 @@ cross_refs: []
|
||||
|
||||
## Overview
|
||||
|
||||
Struve's belt is productive but awkward — the system is off the main transit corridors, which keeps extraction costs high and competition low. Rush Mining has operated here for four generations, making a virtue of the location: without corridor competitors, they've developed deep extraction expertise across the belt's varied ore profile.
|
||||
Struve is one hop from Gateway and has been a junction for forty years. The second gate activation opened a three-aperture system in what had been a dead-end spur — geographically central, institutionally empty. Rush Mining was among the first extraction operations to establish on the habitable body, and the four decades since have given the company time to develop the belt's varied ore profile while the system's governance remained unsettled enough to keep the established inner-orbit combines from investing here directly. The combines require settled property tenure before committing extraction capital — they will not build infrastructure on claims that a future governance framework might redistribute. Rush Mining, founded under frontier conditions, holds its sites by continuous occupation rather than by institutional registration.
|
||||
|
||||
The company mines a wider commodity range than most single-system operations: metallic ore from the main belt deposits, rare mineral concentrates from the inner system's geologically active secondary bodies, and lattice-grade material from a fractured lunar body that proved unexpectedly rich. Each stream is sold independently into the corridor spot market when the gate schedule permits transit.
|
||||
The company mines a wider commodity range than most single-system operations: metallic ore from the main belt deposits, rare mineral concentrates from the inner system's geologically active secondary bodies, and lattice-grade material from a fractured lunar body that proved unexpectedly rich when surveyed in the second generation of operations. The diversity reflects the Struve belt's geological character rather than a deliberate portfolio strategy, but it has produced an operation that competes in three markets simultaneously — each with different buyer profiles, different pricing dynamics, and different competitive pressures. The one-hop transit to Gateway means every major processor in the corridor can reach Struve directly. Rush Mining has no logistics problem. What it has, and what defines its commercial position, is the gap between the system's excellent geography and its unresolved institutional conditions.
|
||||
|
||||
**Primary operations:** Belt extraction (metallic ore, rare minerals), surface and sub-surface mining of lattice-grade material on Struve's secondary moon.
|
||||
---
|
||||
|
||||
**Market position:** Frontier supplier with niche advantage on lattice-grade material quality — Rare Vein Survey (the primary reach-wide supplier) doesn't consistently reach Struve volumes. Rush Mining fills the gap for east and outer corridor buyers.
|
||||
## Origin
|
||||
|
||||
The founding generation arrived at Struve with the wave-five settlement push that followed the second aperture activation, carrying extraction equipment and limited capital. What distinguished the Struve situation was location and timing: a geologically productive system, one hop from the Reach's commercial center, with no established claims and no governance framework to adjudicate competing ones. The early settlers who moved fastest secured the most productive sites. Rush Mining's founders were among them.
|
||||
|
||||
The second generation committed resources to a proper survey of a fractured lunar body that initial prospecting had flagged as geologically active. The survey found lattice-grade material in concentrations that had not been expected. Lattice-grade material at this distance from Gateway is unusual; the deposits that produce it are more commonly found in systems further from the corridor's industrial center. The discovery gave Rush Mining a specialist position that its generic belt extraction did not.
|
||||
|
||||
The lattice-grade operation required capital. The second generation financed it through a long-term supply agreement with a core corridor buyer who provided upfront investment in exchange for preferential pricing during the agreement term. The buyer wanted reliable lattice-grade supply with minimal transit; Struve's one-hop position made Rush Mining the answer.
|
||||
|
||||
---
|
||||
|
||||
## Operations
|
||||
|
||||
**Belt extraction:** Metallic ore and rare mineral concentrates from the main Struve belt. Continuous operations with no gate-schedule dependency problem — Struve's three connections mean transit slots are not the constraint. The constraint is production capacity relative to the number of competing operators working the same buyer relationships.
|
||||
|
||||
**Lattice-grade operations:** Surface and sub-surface mining of the fractured lunar body, with on-site initial processing to the purity grade that lattice-grade specifications require. This is Rush Mining's differentiated product. Lattice-grade material from a hop-1 system commands a transit premium that the company converts into pricing discipline — buyers who need guaranteed delivery and quality pay for proximity, and Struve delivers both.
|
||||
|
||||
**Supply agreements:** The lattice-grade stream is under a long-term supply commitment with a core corridor buyer. The metallic ore and rare mineral streams move through competitive buyer relationships rather than fixed agreements — Struve's accessibility means buyers can and do compare Rush Mining's pricing against other operators in the system.
|
||||
|
||||
---
|
||||
|
||||
## Market Position
|
||||
|
||||
Rush Mining's position is defined by the contradiction between Struve's geography and its institutional maturity. The geography is excellent — one hop from Gateway, three-aperture junction, direct access to the inner corridor's commodity markets. The institutional conditions are frontier: contested governance, unsettled property claims, and the regulatory uncertainty that keeps the established extraction combines from committing capital here. Rush Mining operates in the gap between those two facts. The geography delivers the logistics. The institutional immaturity delivers the operating room.
|
||||
|
||||
The lattice-grade niche is the company's most durable advantage. A hop-1 deposit of this quality is unusual enough that the corridor's lattice-grade buyers pay attention to it. Rare Vein Survey, the primary reach-wide supplier, serves the corridor at greater transit distances; Rush Mining's supply reaches core corridor buyers faster and at lower freight cost. The bulk extraction business — metallic ore and rare minerals — competes on transit cost against larger operations at more established systems, a viable position for now. If Struve's property tenure resolves and the institutional environment stabilizes, the same logistics advantage that sustains Rush Mining will attract the competition that has so far stayed away.
|
||||
|
||||
---
|
||||
|
||||
**Cross-References:**
|
||||
- [Rush (Struve system)](../star-systems/GJ-725B/index.md) — Headquarters system; belt and surface operations
|
||||
- [Rare Vein Survey](rare-vein-survey.md) — Reach-wide lattice-grade competitor; serves at greater transit distances
|
||||
|
||||
---
|
||||
|
||||
**Status:** Canonical
|
||||
**Created:** 2026-04-21
|
||||
**Updated:** 2026-05-02
|
||||
|
||||
@@ -9,7 +9,7 @@ updated: 2026-04-05
|
||||
scope: reach-wide
|
||||
faction_type: economic
|
||||
headquarters: Matamba (GJ 884)
|
||||
tags: [chemical_feedstock, medical_goods, chemicals, south_reach, tractus, assembly, distributor]
|
||||
tags: [medical_goods, chemicals, chemical_feedstock, south_reach, tractus, assembly, distributor]
|
||||
decision_refs: [D-175]
|
||||
cross_refs: []
|
||||
---
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
title: "Scapa Flow Industries"
|
||||
description: "Station-based industrial manufacturer at Scapa Flow (GJ 570A) — fusion fuel bunkering and structural panel fabrication for the Bastion corridor, operating as the system's primary heavy industrial concern"
|
||||
description: "Core hub industrial operation at Quaterna (GJ 570A) — fusion fuel bunkering and structural panel fabrication at one of the Reach's most connected systems, where transit volume alone makes the bunkering business work at scale"
|
||||
slug: scapa-flow-industries
|
||||
category: corporation
|
||||
status: canonical
|
||||
created: 2026-04-21
|
||||
updated: 2026-04-21
|
||||
scope: GJ 570A local; Bastion corridor secondary
|
||||
updated: 2026-05-02
|
||||
scope: reach-wide
|
||||
faction_type: economic
|
||||
headquarters: Scapa Flow (GJ 570A)
|
||||
headquarters: Quaterna (GJ 570A)
|
||||
tags: [fusion_fuel, structural_panels, manufacturing, tractus]
|
||||
decision_refs: [D-175]
|
||||
cross_refs: []
|
||||
cross_refs: [GJ-570A]
|
||||
---
|
||||
|
||||
# Scapa Flow Industries
|
||||
@@ -19,18 +19,51 @@ cross_refs: []
|
||||
**Type:** Corporation — Industrial Manufacturing and Fuel Bunkering
|
||||
**Also Known As:** Scapa Flow, SFI
|
||||
**Status:** Canonical
|
||||
**Scope:** Bastion system primary; outer corridor secondary
|
||||
**Headquarters:** Scapa Flow station (GJ 570A, Bastion system) — orbital industrial platform
|
||||
**Scope:** Bastion system primary; reach-wide secondary
|
||||
**Headquarters:** Quaterna station (GJ 570A, Bastion system) — commercial orbital platform
|
||||
**Classification:** Industrial manufacturer; producer behavioral archetype
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Scapa Flow station was built as a fuel depot and grew into something larger. The outer corridor systems need bunkering infrastructure, and Scapa Flow's position in Bastion made it the logical hub — ships transiting the outer routes pass through here, and the infrastructure investment compounded over generations.
|
||||
Bastion is a five-aperture hub three hops from Gateway — one of the most connected points in the Reach, in the same tier of transit importance as Gateway itself. The system is Assembly-administered, its military installation at Scapa Flow serving as the fleet's permanent base. But the fleet is not the only thing at Bastion. Quaterna station, the system's commercial platform, houses 350 million people and the civilian economy that exists alongside — and partly because of — the military presence. Scapa Flow Industries operates from Quaterna's industrial levels, not from the restricted military station, and its business is civilian transit rather than fleet supply.
|
||||
|
||||
Scapa Flow Industries now runs two parallel operations from the station: fuel bunkering (buying fusion fuel from frontier suppliers and reselling at a Bastion-corridor price) and structural panel fabrication for station-scale construction projects in the outer systems. The panel operation started as a necessity — outer systems had long lead times on materials from the inner corridor — and became a competitive product in its own right.
|
||||
Fuel bunkering at a five-aperture hub is not a frontier service or a regional convenience — it is a high-volume industrial operation processing transit traffic at a scale that most bunkering stations in the Reach cannot approach. Every ship that converges on Bastion needs fuel, and the convergence is enormous. The structural panel fabrication operation that SFI runs alongside the bunkering business is an outward-facing enterprise: a core hub with five gate connections is optimally positioned to supply construction materials to every corridor direction at once, without the transit cost penalty that a single-corridor supplier carries.
|
||||
|
||||
**Primary operations:** Fusion fuel bunkering and resale, structural composite panel fabrication for station construction.
|
||||
---
|
||||
|
||||
**Market position:** Dominant in Bastion system; cost-competitive in outer corridor against inner-corridor suppliers due to reduced transit costs.
|
||||
## Origin
|
||||
|
||||
Quaterna's original bunkering infrastructure was built early, when Bastion's gate topology was already understood to be significant. The founding investors were not speculating on Bastion's future traffic — the hub position was established before the commercial station was built, and the bunkering operation was designed for the civilian traffic the hub would produce rather than the traffic it had at founding. The gap between projected and actual early-era volumes required patience; the patience was rewarded.
|
||||
|
||||
The panel fabrication operation came later, as a deliberate expansion rather than a response to a supply crisis. SFI's position at a five-aperture hub means that construction material produced at Quaterna can reach corridor destinations in any direction without the freight penalty that single-corridor producers pay when supplying the opposite side of the Reach. The fabrication investment was sized from the start to serve the hub's full gate-facing footprint, not any single corridor.
|
||||
|
||||
---
|
||||
|
||||
## Operations
|
||||
|
||||
**Fuel bunkering:** SFI sources fusion fuel from producers in the systems connected to Bastion's five apertures and resells at hub pricing to the transit operators who converge on Bastion. The volume is substantial — a five-aperture hub generates transit traffic that a single-corridor bunkering stop cannot match — and the operation requires storage infrastructure scaled accordingly. SFI maintains buffer stock sized for the hub's peak transit periods, which occur on the intersection of multiple corridor schedules simultaneously.
|
||||
|
||||
**Structural panel fabrication:** Composite structural panels for station and habitat construction, produced at SFI's fabrication level on Quaterna. The core hub position means the panels can be routed outward in five directions without asymmetric freight cost — a construction project at three hops in any direction from Bastion pays the same transit cost for SFI panels. This matters for the large station-building programs where procurement is centralized and suppliers are evaluated across the full project scope rather than corridor by corridor.
|
||||
|
||||
**Hub logistics:** SFI's two operations share the same freight infrastructure and the same relationships with the transit operators who move through Bastion. The fuel bunkering gives SFI ongoing commercial contact with the full range of operators transiting the hub; the panel fabrication gives those operators a reason to carry outbound freight from Bastion rather than deadheading. The logistics overlap between the two businesses is not incidental — it is a competitive advantage that a single-product operation at the same hub would not have.
|
||||
|
||||
---
|
||||
|
||||
## Market Position
|
||||
|
||||
In fuel bunkering, Bastion's hub position is SFI's market. Five-aperture transit convergence produces demand that the operation was built to serve and that no competitor can replicate without replicating the hub itself. SFI is not the only bunkering operation at Bastion — the traffic volume supports multiple suppliers — but its scale and its established relationships with the hub's regular transit operators give it the majority of the commercial traffic.
|
||||
|
||||
In panel fabrication, SFI competes across the full corridor reach from Bastion, including against inner-corridor specialists with greater production scale. The competitive argument is logistics: SFI's hub position eliminates the freight asymmetry that makes inner-corridor fabricators expensive for outward-reaching construction projects. For projects where the freight cost matters more than the production cost difference, SFI is the rational choice. For projects where scale and product specification depth matter more, the inner-corridor specialists hold the position.
|
||||
|
||||
---
|
||||
|
||||
**Cross-References:**
|
||||
- [Quaterna (Bastion system)](../star-systems/GJ-570A/index.md) — Headquarters; commercial station industrial operations
|
||||
|
||||
---
|
||||
|
||||
**Status:** Canonical
|
||||
**Created:** 2026-04-21
|
||||
**Updated:** 2026-05-02
|
||||
|
||||
@@ -5,13 +5,13 @@ slug: sede-chemical-works
|
||||
category: corporation
|
||||
status: canonical
|
||||
created: 2026-04-21
|
||||
updated: 2026-04-21
|
||||
scope: GJ 559B local; ACB corridor secondary
|
||||
updated: 2026-05-02
|
||||
scope: GJ 559B local; ACB (Alpha Centauri B) corridor secondary
|
||||
faction_type: economic
|
||||
headquarters: Sede (GJ 559B)
|
||||
tags: [chemical_feedstock, chemicals, manufacturing, tractus]
|
||||
decision_refs: [D-175]
|
||||
cross_refs: []
|
||||
cross_refs: [societe-chimique]
|
||||
---
|
||||
|
||||
# Sede Chemical Works
|
||||
@@ -19,7 +19,7 @@ cross_refs: []
|
||||
**Type:** Corporation — Chemical Feedstock Processing and Synthesis
|
||||
**Also Known As:** Sede Chemical, SCW
|
||||
**Status:** Canonical
|
||||
**Scope:** ACB corridor primary; reach-wide specialty supply secondary
|
||||
**Scope:** ACB (Alpha Centauri B, GJ 559B) corridor primary; reach-wide specialty supply secondary
|
||||
**Headquarters:** Sede (GJ 559B) — industrial processing campus
|
||||
**Classification:** Chemical manufacturer; producer behavioral archetype
|
||||
|
||||
@@ -27,10 +27,44 @@ cross_refs: []
|
||||
|
||||
## Overview
|
||||
|
||||
ACB system sits at a transit intersection that made it a logical location for chemical processing: raw feedstocks can arrive from multiple corridor directions, and finished chemical products distribute outward on the same gate network.
|
||||
ACB — Alpha Centauri B, one of the Reach's core hubs — sits at a transit intersection that made it a logical location for chemical processing: raw feedstocks can arrive from multiple corridor directions, and finished chemical products distribute outward on the same gate network. Sede Chemical Works (SCW) was built to service that intersection. The company processes raw chemical feedstock into pharmaceutical-grade and industrial-grade outputs, operating under Assembly environmental protocols and the Lattice Commission's chemical regulatory framework.
|
||||
|
||||
Sede Chemical Works was built to service that intersection. The company processes raw chemical feedstock into pharmaceutical-grade and industrial-grade outputs, operating under strict Assembly environmental protocols. Their location at Sede means most ACB corridor pharmaceutical producers carry SCW as a primary supplier.
|
||||
The company is not SCV. It does not have SCV's three centuries of certification history or SCV's reach-wide institutional relationships. What it has is position — transit geometry that makes it the lowest-cost supplier for ACB corridor buyers — and the Commission certification that allows it to operate in the same pharmaceutical-grade market segment that SCV anchors at the reach-wide level.
|
||||
|
||||
**Primary operations:** Chemical feedstock fractionation, industrial chemical synthesis, pharmaceutical precursor production.
|
||||
---
|
||||
|
||||
**Market position:** Dominant ACB corridor supplier; competes with Société Chimique on reach-wide accounts but holds the ACB corridor share due to lower transit costs.
|
||||
## Origin
|
||||
|
||||
Sede Chemical Works was established as the ACB corridor's chemical processing capacity grew beyond what inner-corridor suppliers could serve efficiently. The transit intersection's geometry was the founding argument: a processing facility at Sede could receive feedstock from three corridor directions and distribute finished product outward on the same gate network that brought the feedstock in. The capital investment made sense at the transit intersection in a way it would not have made sense at a dead-end system.
|
||||
|
||||
The pharmaceutical-grade processing capability came later, added when ACB corridor pharmaceutical producers identified the transit cost savings of sourcing precursors locally rather than from the inner corridor.
|
||||
|
||||
---
|
||||
|
||||
## Operations
|
||||
|
||||
**Feedstock fractionation:** Processing raw chemical feedstock into refined intermediates for industrial and pharmaceutical applications. The fractionation operation is the company's highest-throughput function and the one that benefits most directly from the transit intersection's feedstock supply.
|
||||
|
||||
**Industrial chemical synthesis:** Production of industrial-grade chemical outputs for the ACB corridor's manufacturing sector. Synthesized under Assembly environmental protocols; certified for industrial use under the relevant Lattice Commission standards.
|
||||
|
||||
**Pharmaceutical precursor production:** Processing to pharmaceutical-grade specifications for ACB corridor drug manufacturers. This product line requires higher Commission certification maintenance than the industrial line — periodic audits, documentation requirements, and quality control standards that the company maintains at higher cost in exchange for the margin that pharmaceutical-grade supply commands.
|
||||
|
||||
**Long-term supply agreements:** SCW holds multi-year supply agreements with its primary ACB corridor clients, which stabilizes revenue and allows capacity planning across the multi-year investment cycles that chemical processing infrastructure requires.
|
||||
|
||||
---
|
||||
|
||||
## Market Position
|
||||
|
||||
SCW is the dominant chemical supplier for the ACB corridor, where its transit cost advantage over reach-wide suppliers is the primary competitive differentiator. Société Chimique du Vide competes on reach-wide accounts — clients large enough or specialized enough that SCV's certification depth and institutional relationships outweigh the transit cost premium. For ACB corridor buyers below that threshold, SCW holds the market.
|
||||
|
||||
---
|
||||
|
||||
**Cross-References:**
|
||||
- [Sede (ACB system)](../star-systems/GJ-559B/index.md) — Headquarters; processing campus
|
||||
- [Société Chimique du Vide](societe-chimique.md) — Reach-wide competitor; competes on reach-wide pharmaceutical accounts
|
||||
|
||||
---
|
||||
|
||||
**Status:** Canonical
|
||||
**Created:** 2026-04-21
|
||||
**Updated:** 2026-04-21
|
||||
|
||||
@@ -19,7 +19,7 @@ cross_refs: [orkney-ceramics, highland-cooperative, thrds]
|
||||
**Type:** Corporation — Heritage Craft Manufacturer (Natural Fiber Textiles)
|
||||
**Status:** Canonical
|
||||
**Scope:** North reach primary; inward corridor premium textile market secondary
|
||||
**Headquarters:** Crown's Hollow (GJ 661A) — north reach corridor, 5 hops from Gateway
|
||||
**Headquarters:** Crown's Hollow (GJ 661A) — north reach corridor, 3 hops from Gateway
|
||||
**Classification:** Sub-Syndic artisan enterprise; heritage breed certification holder
|
||||
|
||||
---
|
||||
|
||||
@@ -20,7 +20,7 @@ cross_refs: []
|
||||
**Also Known As:** SCV, "the Society," "Vide Chemicals"
|
||||
**Status:** Canonical
|
||||
**Scope:** Reach-wide — Commission-certified industrial chemicals supply
|
||||
**Headquarters:** Confluent (GJ 395) — west_reach mid-corridor hub, French-heritage
|
||||
**Headquarters:** Confluent (GJ 395) — west_reach outer corridor, French-heritage
|
||||
**Classification:** Industrial chemicals producer; Assembly compliance-linked supply chain
|
||||
|
||||
---
|
||||
|
||||
@@ -19,7 +19,7 @@ cross_refs: [rheingold-distillers, schwarzwald-gin, mercado-travessia]
|
||||
**Type:** Corporation — Regional Specialty Producer (Mineral Water)
|
||||
**Status:** Canonical
|
||||
**Scope:** West reach primary; inward corridor premium beverage market secondary
|
||||
**Headquarters:** Nyrheim (GJ 3737) — west reach corridor, 5 hops from Gateway via Mark currency zone
|
||||
**Headquarters:** Nyrheim (GJ 3737) — west reach corridor, 7 hops from Gateway via Mark currency zone
|
||||
**Classification:** Sub-Syndic artisan enterprise; Lattice Commission geographic indication holder (mineral waters)
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "thrds"
|
||||
description: "North_reach cold-weather technical clothing cooperative based at Braemar (GJ 475) — brach fiber garments, local cooperative ownership, creative direction that stayed at origin"
|
||||
description: "North reach cold-weather technical clothing cooperative based at Braemar (GJ 475) — brach fiber garments, local cooperative ownership, creative direction that stayed at origin"
|
||||
slug: thrds
|
||||
category: corporation
|
||||
status: canonical
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
---
|
||||
title: "Threshold Fuel Syndicate"
|
||||
description: "Ice harvesting and fusion fuel production at Tau Ceti — the corridor's most reliable frontier fuel supplier, operating from Threshold's outer ice bodies where water supply is consistent and competition is thin"
|
||||
description: "Ice harvesting and fusion fuel production at Tau Ceti (GJ 71) — Gateway's only local fuel producer, covering a fraction of the system's enormous demand and reducing import dependency for the Reach's busiest transit hub"
|
||||
slug: threshold-fuel-syndicate
|
||||
category: corporation
|
||||
status: canonical
|
||||
created: 2026-04-21
|
||||
updated: 2026-04-21
|
||||
scope: tau_ceti local; east corridor secondary
|
||||
updated: 2026-05-02
|
||||
scope: tau_ceti local
|
||||
faction_type: economic
|
||||
headquarters: Threshold (GJ 71)
|
||||
tags: [fusion_fuel, water, ice_harvesting, frontier, independent]
|
||||
tags: [fusion_fuel, water, ice_harvesting, independent]
|
||||
decision_refs: [D-175]
|
||||
cross_refs: []
|
||||
---
|
||||
@@ -19,7 +19,7 @@ cross_refs: []
|
||||
**Type:** Corporation — Ice Harvesting and Fusion Fuel Refinery
|
||||
**Also Known As:** Threshold Fuel, TFS
|
||||
**Status:** Canonical
|
||||
**Scope:** Tau Ceti primary; corridor fuel supply secondary
|
||||
**Scope:** Tau Ceti local
|
||||
**Headquarters:** Threshold (GJ 71, Tau Ceti system) — outer system operations
|
||||
**Classification:** Resource extraction syndicate; monopolist behavioral archetype (local)
|
||||
|
||||
@@ -27,10 +27,43 @@ cross_refs: []
|
||||
|
||||
## Overview
|
||||
|
||||
Tau Ceti's outer ice bodies contain one of the most accessible water reserves in the east corridor. Threshold Fuel Syndicate was formed by a consortium of Threshold settlers who realized that controlling that water supply meant controlling fuel production for every ship passing through.
|
||||
Tau Ceti is the Reach's transit center. Five gate apertures, 1.2 billion people, and every ship that enters or leaves the Reach passes through Gateway. The system imports fusion fuel — enormously, continuously, from multiple corridor suppliers — because its own consumption outstrips anything local production could cover. Threshold Fuel Syndicate does not try to cover it. What TFS provides is the fraction of Gateway's fuel demand that can be sourced locally, from the system's own outer ice bodies, without depending on gate transit from external suppliers.
|
||||
|
||||
Three generations later, TFS operates a vertically integrated operation: ice extraction, water processing, and fusion fuel refinery all under one contract structure. Local competitors have tried and withdrawn; the capital cost of orbital ice-cracking infrastructure is a high barrier.
|
||||
The fraction is small relative to Gateway's total consumption. It is not small in absolute terms. Tau Ceti's outer system contains accessible ice reserves, and the Syndicate controls extraction rights across the relevant bodies. The fuel that TFS produces reaches Gateway's bunkering infrastructure without passing through a single gate — no transit cost, no gate-schedule dependency, no exposure to the supply disruptions that affect imported fuel when corridor traffic peaks or gate maintenance closes an aperture. For a system whose entire economy depends on transit reliability, a local fuel source that operates independently of the gate network has value beyond its volume.
|
||||
|
||||
**Primary operations:** Comet and ice-body water extraction, electrolytic processing, fusion fuel synthesis at Threshold orbital platform.
|
||||
---
|
||||
|
||||
**Market position:** Dominant fuel supplier for Tau Ceti system; significant spot-market presence in the east corridor where Lagrange Fuel Systems has thinner coverage.
|
||||
## Origin
|
||||
|
||||
The founding consortium was not a single company. It was a group of Threshold settlers in Tau Ceti's outer system who agreed to pool their extraction claims and equipment rather than compete for the same ice bodies with insufficient capital. The syndicate structure reflects the founding logic: individual operators with small operations could not finance orbital ice-cracking; the collective could, and the collective's combined claim coverage prevented any later entrant from establishing an independent water supply at the same system.
|
||||
|
||||
The fusion fuel refinery was a second-generation addition. The founding generation extracted water and sold it to Gateway's municipal and commercial buyers. The second generation built the refinery and captured the margin between raw water and processed fuel. The refinery investment was financed by forward contracts with Gateway-based transit operators who wanted a local fuel source that did not depend on imported supply arriving through the same gate network their ships used.
|
||||
|
||||
---
|
||||
|
||||
## Operations
|
||||
|
||||
**Ice extraction:** Comet and ice-body water extraction from Tau Ceti's outer system, conducted from the Threshold orbital platform using extraction vessels that operate on rotation schedules from the platform. The ice bodies are not depleting at current extraction rates; the system's outer region contains more than the operation can process at its current scale.
|
||||
|
||||
**Water processing:** Electrolytic processing of extracted ice to the purity grade that fusion fuel synthesis requires. The processing step runs continuously at the platform; the output feeds directly into the refinery.
|
||||
|
||||
**Fusion fuel refinery:** Synthesis of hydrogen fusion fuel at the orbital platform, refined to the grade specifications that commercial vessel operators require. The refinery operates at a scale calibrated to the portion of Gateway's fuel demand that local supply can realistically serve — a fraction of total consumption, but a fraction that TFS delivers without gate transit.
|
||||
|
||||
**Local extraction monopoly:** TFS holds all water extraction licenses in the Tau Ceti outer system. No competing local extraction operation exists. The monopoly is on local production, not on Gateway's fuel supply — the vast majority of Gateway's fuel arrives through the gates from corridor producers.
|
||||
|
||||
---
|
||||
|
||||
## Market Position
|
||||
|
||||
TFS is not Gateway's primary fuel supplier. That position belongs to the corridor's major fuel producers — Lagrange Fuel Systems and others — who ship through the gate network at volume. TFS is Gateway's only *local* fuel supplier, which gives it a strategic position disproportionate to its market share. When gate traffic peaks, when an aperture goes down for maintenance, when corridor supply chains are disrupted, TFS's fuel is the supply that continues arriving. The premium that transit operators pay for gate-independent supply — and the contracts that Gateway's logistics administrators maintain with TFS for strategic buffer stock — reflect this.
|
||||
|
||||
---
|
||||
|
||||
**Cross-References:**
|
||||
- [Tau Ceti (Gateway)](../star-systems/GJ-71/index.md) — Headquarters system; outer system extraction and orbital platform operations
|
||||
|
||||
---
|
||||
|
||||
**Status:** Canonical
|
||||
**Created:** 2026-04-21
|
||||
**Updated:** 2026-05-02
|
||||
|
||||
@@ -19,7 +19,7 @@ cross_refs: [kumho-navigation, higashiyama-vehicle, dalbit-systems]
|
||||
**Type:** Corporation — Precision Technology Manufacturer (Vessel Drive and Navigation Systems)
|
||||
**Status:** Canonical
|
||||
**Scope:** East reach primary; corridor-wide small commercial vessel supply secondary
|
||||
**Headquarters:** Miryang (GJ 754) — east reach corridor, 4 hops from Gateway
|
||||
**Headquarters:** Miryang (GJ 754) — east reach corridor, 5 hops from Gateway
|
||||
**Classification:** Sub-Syndic technical enterprise; drive systems and navigation hardware manufacturer
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user