Compare commits
@@ -115,8 +115,7 @@ Synthesize findings.
|
||||
|
||||
### Qatux (Documenter & Librarian)
|
||||
- Core team member — participates in discussion rounds as documenter
|
||||
- Manages document search via `/docs-search` skill
|
||||
- Maintains DECISIONS.md, DISCUSSION.md, briefings, and Qdrant search index
|
||||
- Maintains DECISIONS.md, DISCUSSION.md, and briefings
|
||||
- Answers "did we discuss this?" with citations
|
||||
|
||||
## Extending the team
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: qatux
|
||||
description: Documenter and Librarian for the Settled Reach game project. Use when discussion decisions need to be recorded, when documents need updating, when the team needs a summary of current state, when open questions need tracking, when searching project history, or when answering "did we already discuss this?". Maintains decisions/ domain files, DISCUSSION.md, briefings, and the Qdrant search index.
|
||||
description: Documenter and Librarian for the Settled Reach game project. Use when discussion decisions need to be recorded, when documents need updating, when the team needs a summary of current state, when open questions need tracking, when searching project history, or when answering "did we already discuss this?". Maintains decisions/ domain files, DISCUSSION.md, and briefings.
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash
|
||||
model: sonnet
|
||||
memory: project
|
||||
@@ -28,7 +28,6 @@ Named after Qatux, the Raiel with perfect memory who helped Paula Myo by recalli
|
||||
- Provide "state of the project" summaries when asked
|
||||
|
||||
### Knowledge management
|
||||
- Maintain the Qdrant document index via /docs-search skill
|
||||
- Update briefing files when decisions change
|
||||
- Answer retrieval questions: "did we discuss X?", "what did we decide about Y?"
|
||||
- Catch staleness in briefings and flag for update
|
||||
@@ -43,8 +42,7 @@ Named after Qatux, the Raiel with perfect memory who helped Paula Myo by recalli
|
||||
|
||||
- **Work in dedicated round files:** All new rounds happen in `docs/discussions/round-NN-topic.md` from the start. DISCUSSION.md is retired for new content.
|
||||
- **Update the discussion index ONLY when closing:** After a round is formally closed, update `docs/discussions/README.md` with the round entry (number, topic, decisions produced, file link).
|
||||
- **Update briefings:** After a round produces new decisions, update the relevant agent briefing files in `docs/briefings/`.
|
||||
- **Re-index documents:** After archiving or updating documents, re-index them in Qdrant via `tooling/db/qdrant-index <path>`.
|
||||
- **Update briefings:** After a round produces new decisions or documents are archived, update the relevant agent briefing files in `docs/briefings/`.
|
||||
|
||||
## Team workflow (mandatory)
|
||||
|
||||
|
||||
@@ -3,6 +3,3 @@
|
||||
Endpoints are also preconfigured in `tooling/db/config.json`.
|
||||
|
||||
- **Gitea:** `http://git.schweitz.internal` (login: `schweitz`)
|
||||
- **Qdrant:** `http://tower-of-joy:6333/`
|
||||
- **Ollama:** `http://tower-of-joy:11434/` (nomic-embed-text)
|
||||
- **Collection:** `commonwealth` (768 dimensions, cosine distance)
|
||||
|
||||
@@ -26,12 +26,11 @@ docs/
|
||||
db/
|
||||
schema.sql # Database schema
|
||||
tooling/
|
||||
db/ # Connector scripts for SQLite, Qdrant, and audio
|
||||
db/ # Connector scripts for SQLite and audio
|
||||
config.json # Endpoint configuration
|
||||
ticket # Ticket CLI
|
||||
sprint # Sprint lifecycle CLI
|
||||
sqlite_connector.py # SQLite mini MCP
|
||||
qdrant_connector.py # Qdrant + ollama mini MCP
|
||||
audio_connector.py # Stable Audio Open connector
|
||||
.claude/
|
||||
agents/ # Agent personality files
|
||||
|
||||
@@ -31,10 +31,6 @@
|
||||
"Bash(tooling/db/sprint *)",
|
||||
"Bash(tooling/db/sqlite-query *)",
|
||||
"Bash(tooling/db/sqlite-exec *)",
|
||||
"Bash(tooling/db/qdrant-search *)",
|
||||
"Bash(tooling/db/qdrant-index *)",
|
||||
"Bash(tooling/db/qdrant-health)",
|
||||
"Bash(tooling/db/qdrant-count)",
|
||||
"Bash(tooling/db/sqlite-init)",
|
||||
"Bash(tooling/db/decisions-sync)",
|
||||
"Bash(tooling/db/decision *)",
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
name: docs-search
|
||||
description: >
|
||||
Search project documents using semantic search (Qdrant + ollama) or grep fallback.
|
||||
Use when the user asks "did we discuss X?", "find references to Y", "search docs",
|
||||
or invokes /docs-search. Wraps the qdrant_connector.py for semantic document search.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Grep, Glob
|
||||
---
|
||||
|
||||
# Search Docs Skill
|
||||
|
||||
Semantic search across project documents. Endpoints are in
|
||||
`.claude/rules/local-services.md`. This skill covers advanced operations
|
||||
and workflows.
|
||||
|
||||
## Advanced Commands
|
||||
|
||||
### Index a single chunk
|
||||
|
||||
For precise indexing of specific content:
|
||||
```bash
|
||||
python3 tooling/db/qdrant_connector.py index "unique-id" "Text content to index" --metadata source=manual heading="Custom heading"
|
||||
```
|
||||
|
||||
### Create collection
|
||||
|
||||
Initialize the Qdrant collection (run once during setup):
|
||||
```bash
|
||||
python3 tooling/db/qdrant_connector.py create-collection
|
||||
```
|
||||
|
||||
## Bulk Indexing
|
||||
|
||||
Index all project documents at once:
|
||||
```bash
|
||||
for f in decisions/*.md DISCUSSION.md TEAM.md docs/discussions/*.md docs/briefings/*.md; do
|
||||
tooling/db/qdrant-index "$f"
|
||||
done
|
||||
```
|
||||
|
||||
## Fallback
|
||||
|
||||
If Qdrant or ollama is unreachable, fall back to grep-based search:
|
||||
```bash
|
||||
grep -r -i "search term" decisions/ DISCUSSION.md docs/ --include="*.md"
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Qatux (Librarian)** is the primary user of this skill
|
||||
2. After each discussion round, index the archived round file
|
||||
3. After briefing updates, re-index affected briefings
|
||||
4. After decision changes, re-index the relevant decisions/*.md domain files
|
||||
5. Use search to answer "did we discuss this?" questions with citations
|
||||
@@ -7,7 +7,11 @@ worktrees.** Sprint branches use `sprint-{N}/{team}` naming. Include
|
||||
the branch name and a list of changed files in every prompt. The
|
||||
default approach is `git show origin/<branch>:<path>`. If an active
|
||||
worktree exists under `.sprint/`, agents can also use the Read tool
|
||||
with the worktree path.
|
||||
with the worktree path. **Always prefer `git show` over worktree
|
||||
reads** — worktrees may use sparse checkouts that silently exclude
|
||||
files, causing reviewers to miss content and produce false findings
|
||||
(Sprint 33 lesson: Paula reported missing prose that was actually
|
||||
present, because the worktree excluded the wiki directory).
|
||||
|
||||
## Code reviews (`server`, `client`, `ci`)
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
server/settings.db
|
||||
server/settings.db-shm
|
||||
server/settings.db-wal
|
||||
server/data/systems.db-shm
|
||||
server/data/systems.db-wal
|
||||
|
||||
# Build and cache
|
||||
.cache/
|
||||
@@ -13,6 +15,7 @@ server/target/
|
||||
server/sr-voice/target/
|
||||
server/models/
|
||||
tooling/content-converter/target/
|
||||
tooling/econ-sim/target/
|
||||
tooling/line-previewer/target/
|
||||
tooling/test-client/target/
|
||||
content-ron/
|
||||
@@ -39,6 +42,8 @@ spikes/**/*.npz
|
||||
|
||||
# Planet generator intermediates
|
||||
tooling/planet-gen/__pycache__/
|
||||
tooling/planet-gen/sol_data/.cache/
|
||||
tooling/planet-gen/sol_data/__pycache__/
|
||||
*.tmp.npz
|
||||
|
||||
# Generated terrain grids (large, regenerated from pipeline)
|
||||
|
||||
@@ -6,6 +6,47 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.1.34] — 2026-04-10
|
||||
|
||||
### Added
|
||||
- Economics simulation integrated into server tick loop — econ-sim library crate, D-180 event port, D-181 7-signal vocabulary, IPC bridge (protocol v21), debug commands (#810, #821, #822, #823)
|
||||
- Economics Monitor implant panel — system selector, 6-commodity price table with trend arrows, GDP strip (#824)
|
||||
- Debug console `econ inject`, `econ param`, `econ inspect` commands for runtime economics manipulation (#825)
|
||||
- Star map info panel shows system population and GDP when data is available (#785)
|
||||
- Overheard conversations for all 31 zone types (was 5): 78 new ambient dialogue entries (94 total) with D-078 occlusion-resilient authoring, investigative knowledge payloads, and culture-neutral role-pair conversations (#695)
|
||||
- D-189 brand layer architecture — administered pricing, halo/volume tiers, 8 brand categories, corp tax/GDP
|
||||
- D-190 brand volume calibration — population-relative scale for ~80B Reach
|
||||
- D-191 Atlas of the Reach Phase 3 scope — sequential settlement growth, Gemma 2 naming pipeline, 9 MVP overlays
|
||||
|
||||
## [v0.1.33] — 2026-04-08
|
||||
|
||||
### Added
|
||||
- Economics simulation binary (`tooling/econ-sim/`): three-layer architecture — Layer 1 (Leontief production), Layer 2 (damped tâtonnement trade flows, α=0.03, β=0.4), Layer 3 (corporate behavioral archetypes)
|
||||
- D-179 stability tests: cold-start convergence (±5% at tick 100), long-run stability (±2% over 1000 ticks), no-explosion check, cross-zone FX balance
|
||||
- Tier-3 corporation generation pipeline (`server/src/bin/generate_corporations/`): seeded procedural naming, D-175 coverage rules (3+ corps per commodity, 1+ per system >100K pop)
|
||||
- Currency zone assignments (`wiki/economics/currency_zones.toml`): 32 MARK_PRIMARY + 14 MIXED systems authored by Miri (D-172)
|
||||
- Shadow economy intensity ranges (`wiki/economics/shadow_economy.toml`): per-system seeding with geographic bands, modifiers, and overrides (D-174)
|
||||
- 141 Tier-2 regional corporations across 6 corridors with backstories and behavioral archetypes
|
||||
- 36 commodity wiki pages with economic intelligence flavor text
|
||||
- Gate energy connectivity (D-186): MARK_PRIMARY zones default off-grid
|
||||
- `make econ-sim`, `make econ-sim-run`, `make econ-sim-stability` targets
|
||||
- Icon tint shader (`icon_tint.gdshader`) for runtime HUD icon recoloring
|
||||
- Sol system (GJ-0) handcrafted terrain pipeline (`tooling/planet-gen/sol_import.py`): imports real NASA/USGS data for Earth, Mars, Luna
|
||||
- Ferric biome classes (34–36) in `biomes.toml` for Mars iron oxide surface
|
||||
- Earth named features: 50 cities, 15 rivers, 5 oceans, 7 mountain ranges
|
||||
|
||||
### Fixed
|
||||
- Globe renderer east-west mirroring: `arctan2(hx, hz)` replaces `arctan2(hz, hx)` in planet_renderer.py
|
||||
- Determinism: HashMap → BTreeMap throughout econ-sim, ORDER BY RANDOM() replaced with seeded selection
|
||||
- Transport cost formula: multiplicative gate×zone instead of additive (trade.rs)
|
||||
- Corporation gap-fill off-by-one: now generates exactly 3 corps per uncovered commodity
|
||||
|
||||
### Changed
|
||||
- Economy-db pipeline extended with corporation sync, validation, currency zone import from TOML, and gate energy flags
|
||||
|
||||
### Removed
|
||||
- Qdrant semantic search infrastructure (#816): dropped commonwealth collection, removed qdrant_connector.py, wrapper scripts, /docs-search skill, and all active references
|
||||
|
||||
## [v0.1.32] — 2026-04-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -73,8 +73,6 @@ The ticketing database (`settledreach.db`) is accessed via `SR_DB_PATH` env var
|
||||
| SQL queries | `tooling/db/sqlite-query "SELECT ..."` | — |
|
||||
| SQL writes | `tooling/db/sqlite-exec "UPDATE ..."` | — |
|
||||
| Decisions | `tooling/db/decision next`, `claim`, `check-dupes` | — |
|
||||
| Doc search | `tooling/db/qdrant-search "query"` | `/docs-search` skill |
|
||||
| Doc index | `tooling/db/qdrant-index path/to/file.md` | `/docs-search` skill |
|
||||
|
||||
### Testing preferences
|
||||
|
||||
|
||||
@@ -327,6 +327,17 @@ db-install:
|
||||
economy-db: ## Import economics data (commodities, chains, gate links) into systems.db
|
||||
@python3 tooling/economy-db/import_economics.py
|
||||
|
||||
econ-sim: ## Build the economics simulation binary (Layer 1+2: Leontief + tâtonnement trade)
|
||||
@cargo build --manifest-path tooling/econ-sim/Cargo.toml --release
|
||||
@echo "Built: tooling/econ-sim/target/release/econ-sim"
|
||||
|
||||
econ-sim-run: ## Run a quick economics simulation (100 ticks, output to /tmp/econ-sim.csv)
|
||||
@tooling/econ-sim/target/release/econ-sim --ticks 100 --output /tmp/econ-sim.csv
|
||||
@echo "Output: /tmp/econ-sim.csv"
|
||||
|
||||
econ-sim-stability: ## Run D-179 stability checks (Tests 1 and 2)
|
||||
@tooling/econ-sim/target/release/econ-sim --stability-check
|
||||
|
||||
# --- Decisions ---
|
||||
|
||||
decisions-sync:
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| **NIGEL** | Sandbox & Replayability | Emergent stories, multiple viable strategies, alt-history potential. |
|
||||
| **TYRE** | Technical Architecture & Feasibility | Engine, tools, what's buildable, reality checks on scope. |
|
||||
| **BURNELLI-SHELDON** | Economist & Simulation Modeler | Market models, price formation, production functions, stability analysis. "Is this economically credible?" |
|
||||
| **QATUX** | Documenter & Librarian | Maintains decisions, discussions, briefings, Qdrant search index. Archives rounds, updates docs. |
|
||||
| **QATUX** | Documenter & Librarian | Maintains decisions, discussions, briefings. Archives rounds, updates docs. |
|
||||
|
||||
## Specialist Team (task-focused, not in regular discussions)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -116,6 +116,12 @@ var ai_enhanced_dialogue_enabled: bool = true
|
||||
# "full" response hydrates ai_enhanced_dialogue_enabled (server is authoritative for persisted state).
|
||||
var settings_response: Variant = null
|
||||
|
||||
# v21 fields (#824, D-181): Economy snapshot from server.
|
||||
# Dictionary keyed by system_id → { price_current, price_trend, trade_flow_volume,
|
||||
# corporate_presence, stockpile_weeks, production_vs_baseline, official_coverage_ratio }
|
||||
# Null when no economy data in the current snapshot.
|
||||
var economy_snapshot: Variant = null
|
||||
|
||||
# v7 fields (#431, D-059/D-060)
|
||||
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ extends Node
|
||||
## "implant/map/starchart" — star map navigator
|
||||
## "implant/wiki/gttr" — Drifter's Guide reader
|
||||
## "implant/journal" — knowledge journal
|
||||
## "implant/economics" — economics monitor (D-181, #824)
|
||||
##
|
||||
## Usage:
|
||||
## HudGroups.register(self, "implant/map/starchart")
|
||||
|
||||
+15
-1
@@ -9,7 +9,6 @@ var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs
|
||||
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
|
||||
var _consumers: SnapshotConsumers # #775: non-dialogue snapshot consumers
|
||||
var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal handlers
|
||||
|
||||
@onready var world_renderer = $World
|
||||
@onready var fog_entities = $World/FogEntities # D-059/D-060: cognitive delay fog visualization
|
||||
@onready var camera = $Camera2D
|
||||
@@ -34,6 +33,7 @@ var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal handlers
|
||||
@onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console
|
||||
@onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7)
|
||||
@onready var star_map = $InsertOverlay/HUD/StarMap # #674: star map insert module (hop-ring view)
|
||||
@onready var economics_panel = $InsertOverlay/HUD/EconomicsPanel # #824: economics monitor (D-170)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -92,6 +92,7 @@ func _ready() -> void:
|
||||
"interaction_prompt": interaction_prompt,
|
||||
"minimap": minimap,
|
||||
"star_map": star_map,
|
||||
"economics_panel": economics_panel,
|
||||
},
|
||||
_screen_flash
|
||||
)
|
||||
@@ -154,6 +155,7 @@ func _ready() -> void:
|
||||
_router.register("dialogue_response", _dialogue.consume_dialogue_response)
|
||||
_router.register("save_result", _consumers.consume_save_result)
|
||||
_router.register("debug_response", _consumers.consume_debug_response)
|
||||
_router.register("economy_snapshot", _consumers.consume_economy_snapshot)
|
||||
|
||||
# #581: Wire settings_dialog debug console toggle
|
||||
if settings_dialog and debug_console:
|
||||
@@ -170,6 +172,18 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.keycode == KEY_M:
|
||||
if star_map:
|
||||
star_map.toggle_visible()
|
||||
elif event is InputEventKey and event.keycode == KEY_N:
|
||||
# #824: N — toggle Economics Monitor implant panel (E is bound to interact)
|
||||
if economics_panel:
|
||||
economics_panel.toggle_visible()
|
||||
elif event is InputEventKey and event.keycode == KEY_BRACKETLEFT:
|
||||
# #824: [ — cycle economics panel system selector backward
|
||||
if economics_panel and HudGroups.is_app_active("implant/economics"):
|
||||
economics_panel.navigate(-1)
|
||||
elif event is InputEventKey and event.keycode == KEY_BRACKETRIGHT:
|
||||
# #824: ] — cycle economics panel system selector forward
|
||||
if economics_panel and HudGroups.is_app_active("implant/economics"):
|
||||
economics_panel.navigate(1)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
|
||||
@@ -17,6 +17,7 @@ var interaction_list: Node = null
|
||||
var interaction_prompt: Node = null
|
||||
var minimap: Node = null
|
||||
var star_map: Node = null
|
||||
var economics_panel: Node = null # #824: economics monitor (D-181)
|
||||
|
||||
var _screen_flash_fn: Callable # Callable(color: Color, duration: float)
|
||||
|
||||
@@ -37,6 +38,7 @@ func init(refs: Dictionary, screen_flash: Callable) -> SnapshotConsumers:
|
||||
interaction_prompt = refs.get("interaction_prompt")
|
||||
minimap = refs.get("minimap")
|
||||
star_map = refs.get("star_map")
|
||||
economics_panel = refs.get("economics_panel")
|
||||
_screen_flash_fn = screen_flash
|
||||
return self
|
||||
|
||||
@@ -54,6 +56,8 @@ func propagate_insert_state() -> void:
|
||||
minimap.set_insert_active(insert_state)
|
||||
if star_map:
|
||||
star_map.set_insert_active(insert_state)
|
||||
if economics_panel:
|
||||
economics_panel.set_insert_active(insert_state)
|
||||
|
||||
|
||||
# D-057: Update interaction list from game state.
|
||||
@@ -173,6 +177,15 @@ func consume_debug_response() -> void:
|
||||
GameState.debug_response = null
|
||||
|
||||
|
||||
# #824: Forward economy_snapshot from server to the economics panel (D-181).
|
||||
func consume_economy_snapshot() -> void:
|
||||
if GameState.economy_snapshot == null or not economics_panel:
|
||||
return
|
||||
if economics_panel.has_method("receive_economy_data"):
|
||||
economics_panel.receive_economy_data(GameState.economy_snapshot)
|
||||
GameState.economy_snapshot = null
|
||||
|
||||
|
||||
# #174: Consume examine result — show overlay when server sends character-filtered observation.
|
||||
func consume_examine_result() -> void:
|
||||
if GameState.current_examine_result == null or not examine_display:
|
||||
|
||||
@@ -213,6 +213,12 @@ static func apply(snapshot: Dictionary) -> void:
|
||||
else:
|
||||
GameState.settings_response = null
|
||||
|
||||
# v21: economy_snapshot (#824, D-181)
|
||||
if snapshot.has("economy_snapshot") and snapshot.economy_snapshot is Dictionary:
|
||||
GameState.economy_snapshot = snapshot.economy_snapshot
|
||||
else:
|
||||
GameState.economy_snapshot = null
|
||||
|
||||
# #718: character_visual_descriptor — restored from server snapshot on save/load.
|
||||
if (
|
||||
snapshot.has("character_visual_descriptor")
|
||||
|
||||
@@ -225,10 +225,178 @@ func _dispatch(line: String) -> void:
|
||||
_send_debug("ListPopulation")
|
||||
"status":
|
||||
_send_debug("GetContaminationStatus")
|
||||
"econ":
|
||||
_dispatch_econ(parts)
|
||||
_:
|
||||
_append_text("unknown command: '%s' (type 'help')" % cmd, ERROR_COLOR)
|
||||
|
||||
|
||||
# #825: Economics debug console commands (D-178, D-180, D-181).
|
||||
# Three subcommands: inject (fire EconEvent), param (tweak α/β/friction), inspect (read signals).
|
||||
# TODO: Server-side DebugCommandKind variants (InjectEconEvent, SetEconParam, GetEconState)
|
||||
# ship with #823. Until then, _send_debug will transmit the payload but the server will
|
||||
# respond with an "unknown command" error. Wire format is ready; server handler is not.
|
||||
func _dispatch_econ(parts: Array) -> void:
|
||||
if parts.size() < 2:
|
||||
_append_text(
|
||||
(
|
||||
"usage:\n"
|
||||
+ " econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]\n"
|
||||
+ " econ param <alpha|beta|friction> <value> [system_a] [system_b]\n"
|
||||
+ " econ inspect <system_id>"
|
||||
),
|
||||
ERROR_COLOR,
|
||||
)
|
||||
return
|
||||
var sub := parts[1].to_lower()
|
||||
match sub:
|
||||
"inject":
|
||||
_econ_inject(parts)
|
||||
"param":
|
||||
_econ_param(parts)
|
||||
"inspect":
|
||||
_econ_inspect(parts)
|
||||
_:
|
||||
_append_text("econ: unknown subcommand '%s'" % sub, ERROR_COLOR)
|
||||
|
||||
|
||||
# econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]
|
||||
# Minimal form: econ inject Sol shock 0.5
|
||||
# Full form: econ inject Sol fusion_fuel boost 1.2 100
|
||||
func _econ_inject(parts: Array) -> void:
|
||||
# parts[0]="econ", parts[1]="inject", rest is args
|
||||
var args := parts.slice(2)
|
||||
if args.size() < 3:
|
||||
_append_text(
|
||||
"usage: econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]",
|
||||
ERROR_COLOR,
|
||||
)
|
||||
return
|
||||
|
||||
# Parse: detect whether commodity_id is present by checking if args[1] is an effect keyword
|
||||
var system_id: String = args[0]
|
||||
var commodity_id: String = ""
|
||||
var effect: String = ""
|
||||
var magnitude_str: String = ""
|
||||
var duration_ticks: int = 0
|
||||
|
||||
var ticks_str := ""
|
||||
if args[1].to_lower() in ["shock", "boost"]:
|
||||
# No commodity_id: econ inject <system> <effect> <magnitude> [ticks]
|
||||
effect = args[1].to_lower()
|
||||
magnitude_str = args[2]
|
||||
if args.size() >= 4:
|
||||
ticks_str = args[3]
|
||||
elif args.size() == 3:
|
||||
# 3 args but args[1] isn't shock/boost — bad effect keyword, not a commodity
|
||||
_append_text(
|
||||
"econ inject: effect must be 'shock' or 'boost', got '%s'" % args[1], ERROR_COLOR
|
||||
)
|
||||
return
|
||||
else:
|
||||
# With commodity_id: econ inject <system> <commodity> <effect> <magnitude> [ticks]
|
||||
commodity_id = args[1]
|
||||
if args.size() < 4:
|
||||
_append_text(
|
||||
"usage: econ inject <system_id> <commodity_id> <shock|boost> <magnitude> [ticks]",
|
||||
ERROR_COLOR,
|
||||
)
|
||||
return
|
||||
effect = args[2].to_lower()
|
||||
if not effect in ["shock", "boost"]:
|
||||
_append_text(
|
||||
"econ inject: effect must be 'shock' or 'boost', got '%s'" % effect, ERROR_COLOR
|
||||
)
|
||||
return
|
||||
magnitude_str = args[3]
|
||||
if args.size() >= 5:
|
||||
ticks_str = args[4]
|
||||
|
||||
if not ticks_str.is_empty():
|
||||
if not ticks_str.is_valid_int():
|
||||
_append_text("econ inject: invalid ticks '%s'" % ticks_str, ERROR_COLOR)
|
||||
return
|
||||
duration_ticks = int(ticks_str)
|
||||
|
||||
if not magnitude_str.is_valid_float():
|
||||
_append_text("econ inject: invalid magnitude '%s'" % magnitude_str, ERROR_COLOR)
|
||||
return
|
||||
var magnitude: float = float(magnitude_str)
|
||||
|
||||
var payload := {
|
||||
"system_id": system_id,
|
||||
"effect": effect,
|
||||
"magnitude": magnitude,
|
||||
}
|
||||
if not commodity_id.is_empty():
|
||||
payload["commodity_id"] = commodity_id
|
||||
if duration_ticks > 0:
|
||||
payload["duration_ticks"] = duration_ticks
|
||||
|
||||
_append_text(
|
||||
(
|
||||
"injecting %s on %s%s (mag=%.2f, ticks=%d)"
|
||||
% [
|
||||
effect,
|
||||
system_id,
|
||||
" / " + commodity_id if not commodity_id.is_empty() else "",
|
||||
magnitude,
|
||||
duration_ticks
|
||||
]
|
||||
),
|
||||
TEXT_COLOR,
|
||||
)
|
||||
_send_debug({"InjectEconEvent": payload})
|
||||
|
||||
|
||||
# econ param <alpha|beta|friction> <value> [system_a] [system_b]
|
||||
func _econ_param(parts: Array) -> void:
|
||||
var args := parts.slice(2)
|
||||
if args.size() < 2:
|
||||
_append_text(
|
||||
"usage: econ param <alpha|beta|friction> <value> [system_a] [system_b]",
|
||||
ERROR_COLOR,
|
||||
)
|
||||
return
|
||||
|
||||
var param_name: String = args[0].to_lower()
|
||||
if not param_name in ["alpha", "beta", "friction"]:
|
||||
_append_text(
|
||||
"econ param: must be alpha, beta, or friction — got '%s'" % param_name, ERROR_COLOR
|
||||
)
|
||||
return
|
||||
|
||||
if not args[1].is_valid_float():
|
||||
_append_text("econ param: invalid value '%s'" % args[1], ERROR_COLOR)
|
||||
return
|
||||
var value: float = float(args[1])
|
||||
|
||||
var payload := {"param": param_name, "value": value}
|
||||
if args.size() >= 3:
|
||||
payload["system_a"] = args[2]
|
||||
if args.size() >= 4:
|
||||
payload["system_b"] = args[3]
|
||||
|
||||
var scope := "global"
|
||||
if payload.has("system_a") and payload.has("system_b"):
|
||||
scope = "%s ↔ %s" % [payload["system_a"], payload["system_b"]]
|
||||
elif payload.has("system_a"):
|
||||
scope = payload["system_a"]
|
||||
|
||||
_append_text("setting %s = %.4f (%s)" % [param_name, value, scope], TEXT_COLOR)
|
||||
_send_debug({"SetEconParam": payload})
|
||||
|
||||
|
||||
# econ inspect <system_id> — request all 7 D-181 signals for a system
|
||||
func _econ_inspect(parts: Array) -> void:
|
||||
if parts.size() < 3:
|
||||
_append_text("usage: econ inspect <system_id>", ERROR_COLOR)
|
||||
return
|
||||
var system_id: String = parts[2]
|
||||
_append_text("inspecting economy: %s" % system_id, TEXT_COLOR)
|
||||
_send_debug({"GetEconState": system_id})
|
||||
|
||||
|
||||
func _send_debug(kind: Variant) -> void:
|
||||
var err := (
|
||||
SimBridge
|
||||
@@ -284,6 +452,14 @@ func _print_help() -> void:
|
||||
+ " triangles — list all triangles\n"
|
||||
+ " pop — list active NPCs\n"
|
||||
+ " status — contamination status\n"
|
||||
+ "\n"
|
||||
+ "Economics (D-178/D-180/D-181):\n"
|
||||
+ " econ inject <sys> [commodity] <shock|boost> <mag> [ticks]\n"
|
||||
+ " — fire an EconEvent at a system\n"
|
||||
+ " econ param <alpha|beta|friction> <val> [sys_a] [sys_b]\n"
|
||||
+ " — set tâtonnement parameter\n"
|
||||
+ " econ inspect <sys> — show all 7 signals for a system\n"
|
||||
+ "\n"
|
||||
+ " help — this list"
|
||||
),
|
||||
TEXT_COLOR
|
||||
|
||||
+7
-1
@@ -1,7 +1,8 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://cq1y5w3hmxr8b"]
|
||||
[gd_scene load_steps=3 format=3 uid="uid://cq1y5w3hmxr8b"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/hud.gd" id="1_hud"]
|
||||
[ext_resource type="PackedScene" path="res://ui/star_map.tscn" id="2_starmap"]
|
||||
[ext_resource type="PackedScene" path="res://ui/implant/economics_panel.tscn" id="3_econ"]
|
||||
|
||||
[node name="HUD" type="Control"]
|
||||
layout_mode = 3
|
||||
@@ -19,3 +20,8 @@ script = ExtResource("1_hud")
|
||||
[node name="StarMap" parent="." instance=ExtResource("2_starmap")]
|
||||
visible = false
|
||||
|
||||
; #824: Economics Monitor — implant/economics INSERT panel. Toggled via E key from main.gd.
|
||||
; Composes ImplantPanel from the D-169 component library. Placeholder data until #822 ships.
|
||||
[node name="EconomicsPanel" parent="." instance=ExtResource("3_econ")]
|
||||
visible = false
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
class_name EconomicsPanel
|
||||
extends Control
|
||||
|
||||
## Economics Monitor — implant insert panel (#824, D-170, D-181).
|
||||
##
|
||||
## Displays price data and GDP for a selected system. Receives economy_snapshot
|
||||
## from the server via snapshot_handler → GameState → snapshot_consumers pipeline.
|
||||
##
|
||||
## Data architecture:
|
||||
## - Ring buffer: last 20 ticks of economy data per system (for trend display)
|
||||
## - 7 D-181 signals per system: price_current, price_trend, trade_flow_volume,
|
||||
## corporate_presence, stockpile_weeks, production_vs_baseline, official_coverage_ratio
|
||||
## - Signals 1-2 (price_current, price_trend) are Phase 2 deliverables
|
||||
## - Signals 3-7 are parsed and stored but not yet displayed (Phase 3)
|
||||
##
|
||||
## Visual layer: ImplantPanel composition built in _ready() from component library (D-169).
|
||||
## System selector uses LEFT/RIGHT arrow keys to cycle through all 301 systems.
|
||||
## Placeholder commodity prices shown until #822 ships.
|
||||
|
||||
## Emitted when new economy data arrives for the selected system.
|
||||
signal economy_data_updated(system_id: String, data: Dictionary)
|
||||
|
||||
const APP_PATH := "implant/economics"
|
||||
const RING_BUFFER_SIZE: int = 20
|
||||
const STAR_MAP_DATA := "res://data/star_map_data.json"
|
||||
const PANEL_WIDTH: float = 340.0
|
||||
const PANEL_MARGIN: float = 16.0
|
||||
|
||||
# Placeholder commodity rows shown until server ships EconomySnapshot (#822).
|
||||
# Commodity IDs match D-184 catalog.
|
||||
const PLACEHOLDER_COMMODITIES: Array[Dictionary] = [
|
||||
{"id": "fusion_fuel", "name": "FUSION FUEL", "price": 142, "trend": 1},
|
||||
{"id": "basic_goods", "name": "BASIC GOODS", "price": 58, "trend": 0},
|
||||
{"id": "machinery", "name": "MACHINERY", "price": 890, "trend": -1},
|
||||
{"id": "organics", "name": "ORGANICS", "price": 34, "trend": 1},
|
||||
{"id": "lattice_comp", "name": "LATTICE COMP.", "price": 2240, "trend": 0},
|
||||
{"id": "pharmaceuticals", "name": "PHARMA", "price": 312, "trend": -1},
|
||||
]
|
||||
|
||||
## Currently selected system for detailed display. Empty = no selection.
|
||||
var selected_system: String = ""
|
||||
|
||||
## Ring buffer: system_id → Array[Dictionary] (most recent last, max RING_BUFFER_SIZE).
|
||||
## Each entry is one tick's worth of D-181 signals for that system.
|
||||
var _history: Dictionary = {}
|
||||
|
||||
var _insert_active: bool = true
|
||||
|
||||
# Visual panel state (D-169 component library)
|
||||
var _panel: ImplantPanel = null # root container
|
||||
var _implant_theme: ImplantTheme = null
|
||||
var _header: ImplantHeader = null # kept for set_content() on system change
|
||||
var _nav_row: ImplantDataRow = null # system selector nav hint
|
||||
var _gdp_row: ImplantDataRow = null # GDP value row
|
||||
var _commodity_rows: Array = [] # ImplantDataRow × 6, updated without full rebuild
|
||||
var _placeholder_notice: ImplantTextBlock = null # hidden once live data arrives
|
||||
|
||||
# System list for the selector (populated from STAR_MAP_DATA)
|
||||
var _systems: Array = [] # Array[Dictionary], sorted by proper_name
|
||||
var _selected_idx: int = 0 # index into _systems
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||
grow_vertical = Control.GROW_DIRECTION_BOTH
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
|
||||
# D-170: Register with HUD layer groups
|
||||
HudGroups.register(self, APP_PATH)
|
||||
HudGroups.app_changed.connect(_on_app_changed)
|
||||
|
||||
_implant_theme = load("res://ui/implant/default_implant.tres") as ImplantTheme
|
||||
_load_system_list()
|
||||
_build_panel()
|
||||
economy_data_updated.connect(_on_economy_data_updated)
|
||||
|
||||
|
||||
## Called from SnapshotConsumers when economy_snapshot arrives in GameState.
|
||||
## data: Dictionary keyed by system_id → signal payload (D-181).
|
||||
func receive_economy_data(data: Dictionary) -> void:
|
||||
for system_id: String in data:
|
||||
var signals: Variant = data[system_id]
|
||||
if not signals is Dictionary:
|
||||
continue
|
||||
if not _history.has(system_id):
|
||||
_history[system_id] = []
|
||||
var buf: Array = _history[system_id]
|
||||
buf.append(signals)
|
||||
if buf.size() > RING_BUFFER_SIZE:
|
||||
_history[system_id] = buf.slice(buf.size() - RING_BUFFER_SIZE)
|
||||
|
||||
# Notify listeners if the selected system received new data
|
||||
if not selected_system.is_empty() and data.has(selected_system):
|
||||
economy_data_updated.emit(selected_system, data[selected_system])
|
||||
|
||||
|
||||
## Select a system for detailed display. Emits economy_data_updated if history exists.
|
||||
func select_system(system_id: String) -> void:
|
||||
selected_system = system_id
|
||||
if not selected_system.is_empty() and _history.has(selected_system):
|
||||
var buf: Array = _history[selected_system]
|
||||
if buf.size() > 0:
|
||||
economy_data_updated.emit(selected_system, buf[buf.size() - 1])
|
||||
|
||||
|
||||
## Get the full ring buffer for a system (for chart/sparkline rendering).
|
||||
## Returns empty array if no history exists.
|
||||
func get_history(system_id: String) -> Array:
|
||||
return _history.get(system_id, [])
|
||||
|
||||
|
||||
## Get the latest tick's signals for a system, or empty dict.
|
||||
func get_latest(system_id: String) -> Dictionary:
|
||||
var buf: Array = _history.get(system_id, [])
|
||||
if buf.size() > 0:
|
||||
return buf[buf.size() - 1]
|
||||
return {}
|
||||
|
||||
|
||||
## Get all system IDs that have received at least one tick of data.
|
||||
func get_known_systems() -> Array:
|
||||
return _history.keys()
|
||||
|
||||
|
||||
## Toggle via HUD layer system (D-170). INSERT mode — shares screen with gameplay.
|
||||
func toggle_visible() -> void:
|
||||
HudGroups.toggle_app(APP_PATH, HudGroups.Mode.INSERT)
|
||||
|
||||
|
||||
## Called from main.gd when insert state changes (D-170).
|
||||
func set_insert_active(active: bool) -> void:
|
||||
_insert_active = active
|
||||
if not active and HudGroups.is_app_active(APP_PATH):
|
||||
HudGroups.close_app()
|
||||
|
||||
|
||||
## Respond to app layer changes (D-170).
|
||||
func _on_app_changed(app_path: String, mode: int) -> void:
|
||||
if app_path != APP_PATH:
|
||||
return
|
||||
if mode == HudGroups.Mode.FULLSCREEN or mode == HudGroups.Mode.INSERT:
|
||||
visible = true
|
||||
else:
|
||||
visible = false
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# System list — populated from star_map_data.json
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _load_system_list() -> void:
|
||||
if not FileAccess.file_exists(STAR_MAP_DATA):
|
||||
push_warning("EconomicsPanel: %s not found" % STAR_MAP_DATA)
|
||||
return
|
||||
var file := FileAccess.open(STAR_MAP_DATA, FileAccess.READ)
|
||||
if file == null:
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
for node: Dictionary in parsed.get("nodes", []):
|
||||
var sid: String = node.get("system_id", "")
|
||||
if not sid.is_empty():
|
||||
_systems.append(node)
|
||||
_systems.sort_custom(
|
||||
func(a: Dictionary, b: Dictionary) -> bool:
|
||||
var na: String = a.get("proper_name", a.get("system_id", ""))
|
||||
var nb: String = b.get("proper_name", b.get("system_id", ""))
|
||||
return na < nb
|
||||
)
|
||||
if not _systems.is_empty():
|
||||
selected_system = _systems[0].get("system_id", "")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Visual panel — D-169 ImplantPanel composition
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_panel() -> void:
|
||||
_panel = ImplantPanel.new()
|
||||
_panel.name = "EconPanel"
|
||||
_panel.theme_resource = _implant_theme
|
||||
_panel.custom_minimum_size.x = PANEL_WIDTH
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_panel.position = Vector2(PANEL_MARGIN, PANEL_MARGIN)
|
||||
add_child(_panel)
|
||||
_rebuild_panel()
|
||||
|
||||
|
||||
## Full rebuild of panel components. Called on system change and initial build.
|
||||
func _rebuild_panel() -> void:
|
||||
if not _panel:
|
||||
return
|
||||
_panel.clear()
|
||||
_commodity_rows.clear()
|
||||
|
||||
var node: Dictionary = _current_node()
|
||||
var sys_name: String = node.get("proper_name", node.get("system_id", "—"))
|
||||
var sys_id: String = node.get("system_id", "")
|
||||
var total: int = _systems.size()
|
||||
|
||||
# ── Header ────────────────────────────────────────────────────────────────
|
||||
_header = ImplantHeader.new("ECONOMICS MONITOR", sys_name)
|
||||
_panel.add_component(_header)
|
||||
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
|
||||
# ── System selector nav ────────────────────────────────────────────────────
|
||||
var nav_hint := "◄ ► · %s [%d / %d]" % [sys_id, _selected_idx + 1, total]
|
||||
_nav_row = ImplantDataRow.new(nav_hint)
|
||||
_panel.add_component(_nav_row)
|
||||
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
|
||||
# ── GDP strip ─────────────────────────────────────────────────────────────
|
||||
var gdp_str: String = node.get("gdp", "—")
|
||||
_gdp_row = ImplantDataRow.new("gdp " + gdp_str)
|
||||
_panel.add_component(_gdp_row)
|
||||
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
|
||||
# ── Price table ───────────────────────────────────────────────────────────
|
||||
_panel.add_component(ImplantTextBlock.new("MARKET PRICES"))
|
||||
|
||||
var latest: Dictionary = get_latest(sys_id)
|
||||
var commodity_signals: Array = latest.get("price_current", [])
|
||||
|
||||
for c: Dictionary in PLACEHOLDER_COMMODITIES:
|
||||
var cid: String = c.get("id", "")
|
||||
var price: int = c.get("price", 0)
|
||||
var trend: int = c.get("trend", 0)
|
||||
|
||||
# Overlay live data when available (D-181 signal 1-2)
|
||||
for sig: Dictionary in commodity_signals:
|
||||
if sig.get("commodity_id", "") == cid:
|
||||
price = int(sig.get("price_current", price))
|
||||
trend = int(sig.get("price_trend", trend))
|
||||
break
|
||||
|
||||
var row_text: String = "%-14s %5d %s" % [c["name"], price, _trend_glyph(trend)]
|
||||
var row := ImplantDataRow.new(row_text)
|
||||
_panel.add_component(row)
|
||||
_commodity_rows.append(row)
|
||||
|
||||
# ── Placeholder notice ────────────────────────────────────────────────────
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
var notice_text: String = (
|
||||
"[LIVE MARKET — #822 PENDING]" if _history.is_empty() else "LIVE DATA ACTIVE"
|
||||
)
|
||||
_placeholder_notice = ImplantTextBlock.new(notice_text)
|
||||
_panel.add_component(_placeholder_notice)
|
||||
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
_panel.add_component(ImplantTextBlock.new("[ ] select system · N close"))
|
||||
|
||||
|
||||
func _current_node() -> Dictionary:
|
||||
if _systems.is_empty():
|
||||
return {}
|
||||
_selected_idx = clampi(_selected_idx, 0, _systems.size() - 1)
|
||||
return _systems[_selected_idx]
|
||||
|
||||
|
||||
func _trend_glyph(trend: int) -> String:
|
||||
if trend > 0:
|
||||
return "▲"
|
||||
if trend < 0:
|
||||
return "▼"
|
||||
return "—"
|
||||
|
||||
|
||||
## Respond to economy_data_updated signal — refresh the price table in-place.
|
||||
func _on_economy_data_updated(system_id: String, data: Dictionary) -> void:
|
||||
if not _panel or _commodity_rows.is_empty():
|
||||
return
|
||||
if system_id != selected_system:
|
||||
return
|
||||
|
||||
var commodity_signals: Array = data.get("price_current", [])
|
||||
|
||||
for i: int in range(PLACEHOLDER_COMMODITIES.size()):
|
||||
if i >= _commodity_rows.size():
|
||||
break
|
||||
var c: Dictionary = PLACEHOLDER_COMMODITIES[i]
|
||||
var cid: String = c.get("id", "")
|
||||
var price: int = c.get("price", 0)
|
||||
var trend: int = c.get("trend", 0)
|
||||
|
||||
for sig: Dictionary in commodity_signals:
|
||||
if sig.get("commodity_id", "") == cid:
|
||||
price = int(sig.get("price_current", price))
|
||||
trend = int(sig.get("price_trend", trend))
|
||||
break
|
||||
|
||||
var row_text: String = "%-14s %5d %s" % [c["name"], price, _trend_glyph(trend)]
|
||||
_commodity_rows[i].text = row_text
|
||||
|
||||
if _placeholder_notice and not _history.is_empty():
|
||||
_placeholder_notice.text = "LIVE DATA ACTIVE"
|
||||
|
||||
|
||||
## Cycle the system selector by delta steps (+1 or -1).
|
||||
## Called from main.gd _unhandled_key_input — [ and ] keys when panel is active.
|
||||
func navigate(delta: int) -> void:
|
||||
if _systems.is_empty():
|
||||
return
|
||||
_selected_idx = wrapi(_selected_idx + delta, 0, _systems.size())
|
||||
selected_system = _systems[_selected_idx].get("system_id", "")
|
||||
_rebuild_panel()
|
||||
@@ -0,0 +1,18 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/implant/economics_panel.gd" id="1_econ"]
|
||||
|
||||
; #824: Economics Monitor insert panel — price data and GDP for selected system.
|
||||
; Composed from ImplantPanel component library (D-169). Registered under implant/economics (D-170).
|
||||
; Toggle with E key in implant mode. Data flows from EconomySnapshot via snapshot_consumers.
|
||||
; Placeholder commodity prices shown until server ticket #822 ships.
|
||||
|
||||
[node name="EconomicsPanel" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 1
|
||||
script = ExtResource("1_econ")
|
||||
@@ -135,7 +135,6 @@ func _process(_delta: float) -> void:
|
||||
_dirty = false
|
||||
|
||||
|
||||
## Called from main.gd when insert state changes.
|
||||
## Called from main.gd when insert state changes.
|
||||
func set_insert_active(active: bool) -> void:
|
||||
_insert_active = active
|
||||
@@ -507,10 +506,13 @@ func _rebuild_info_panel() -> void:
|
||||
|
||||
# Population + GDP
|
||||
var population: String = node.get("population", "")
|
||||
if not population.is_empty():
|
||||
var gdp: String = node.get("gdp", "")
|
||||
if not population.is_empty() or not gdp.is_empty():
|
||||
_info_panel.add_component(ImplantDataRow.new("")) # blank line spacer
|
||||
_info_panel.add_component(ImplantDataRow.new("pop " + population))
|
||||
_info_panel.add_component(ImplantDataRow.new("GDP —"))
|
||||
if not population.is_empty():
|
||||
_info_panel.add_component(ImplantDataRow.new("pop " + population))
|
||||
var gdp_label: String = "gdp " + (gdp if not gdp.is_empty() else "—")
|
||||
_info_panel.add_component(ImplantDataRow.new(gdp_label))
|
||||
|
||||
# ── GTTR excerpt ─────────────────────────────────────────────────────────
|
||||
var gttr: String = node.get("gttr_excerpt", "")
|
||||
|
||||
+2
-2
@@ -10,11 +10,11 @@ Cross-domain decisions live in one file with cross-reference notes in related fi
|
||||
|
||||
| File | Domain | Decisions |
|
||||
|------|--------|-----------|
|
||||
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073, D-085, D-088, D-094, D-096, D-097, D-099, D-100, D-101, D-102, D-103, D-106, D-108, D-109, D-113, D-133, D-134, D-135, D-136, D-137, D-141, D-148, D-149, D-150, D-151, D-152 |
|
||||
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073, D-085, D-088, D-094, D-096, D-097, D-099, D-100, D-101, D-102, D-103, D-106, D-108, D-109, D-113, D-133, D-134, D-135, D-136, D-137, D-141, D-148, D-149, D-150, D-151, D-152, D-191 |
|
||||
| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072, D-076, D-077, D-078, D-086 |
|
||||
| [content.md](content.md) | NPC, dialogue, templates | D-023, D-024, D-025, D-028, D-029, D-032, D-034, D-035, D-036, D-037, D-050, D-062, D-063, D-064, D-074, D-075, D-084, D-090, D-092, D-093, D-095, D-098, D-104, D-105, D-107, D-121, D-122, D-123, D-124, D-125, D-126, D-127, D-128, D-129, D-130, D-131, D-132, D-138, D-139, D-140, D-142, D-147 |
|
||||
| [scope.md](scope.md) | Game concept, prototype | D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065, D-087, D-089, D-091, D-114, D-115, D-116, D-117, D-118, D-119, D-120, D-145, D-146, D-153, D-154, D-155, D-156, D-157 |
|
||||
| [economics.md](economics.md) | Economics layer, currencies, corporations, simulation | D-171, D-172, D-173, D-174, D-175, D-176, D-177, D-178, D-179, D-180, D-181, D-182, D-183, D-184, D-185, D-186, D-187 |
|
||||
| [economics.md](economics.md) | Economics layer, currencies, corporations, simulation | D-171, D-172, D-173, D-174, D-175, D-176, D-177, D-178, D-179, D-180, D-181, D-182, D-183, D-184, D-185, D-186, D-187, D-189, D-190 |
|
||||
| [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040 |
|
||||
| [questions.md](questions.md) | Open questions (index) | Q-001 through Q-094 |
|
||||
| [questions-architecture.md](questions-architecture.md) | Technical questions | Q-001, Q-006, Q-009, Q-018–Q-023, Q-029, Q-030, Q-046, Q-059, Q-060, Q-063–Q-094 |
|
||||
|
||||
@@ -654,4 +654,87 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
|
||||
---
|
||||
|
||||
*51 decisions. Last updated: 2026-04-06 (D-188 biome_summary → planet_class rename)*
|
||||
### D-191: Atlas of the Reach — Phase 3 Scope and Pipeline
|
||||
- **Date:** 2026-04-10
|
||||
- **Decision:** Phase 3 delivers the Atlas of the Reach as an extension of the implant map (`implant/map`), adding planetary and regional zoom levels to the existing star map. The Atlas is a read-only spatial intelligence tool — the player looks at it to plan, not to execute actions. It serves dual purpose: gameplay information layer and world-texture content system.
|
||||
|
||||
**1. Zoom Hierarchy**
|
||||
- 4 levels: Reach map (Phase 1, exists) → System view (orbital diagram, new) → Planetary view (hemisphere, new) → Regional view (hundreds-of-km, new — core Phase 3 deliverable)
|
||||
- Atlas is the star map extended downward, not a separate app. `implant/map` at different zoom levels.
|
||||
- Station maps and underground/cave city maps are DEFERRED to a later sprint (similar bounded generation pattern).
|
||||
|
||||
**2. Existing Foundation**
|
||||
- 2,394 heightmap PNGs (1024×512 equirectangular, production quality)
|
||||
- 2,394 markers.json files with procedural geometry (rivers, oceans, mountains) — all names null, all cities/roads/rail/POIs empty
|
||||
- Planet-gen pipeline (`tooling/planet-gen/`) explicitly designed for Phase 3: `render_heightmap.py` line 26-27 defers cultural overlay to the atlas app
|
||||
- 301 systems in systems.db, 273 inhabited bodies, 466 stations, 668 gate links
|
||||
|
||||
**3. Content Pipeline — Sequential Settlement Growth Simulation**
|
||||
- City placement is terrain-aware and sequential (not scatter)
|
||||
- Capital first: favor river mouths (~50% of capitals), scored by habitability (temperature, moisture, slope), coastal access, flat hinterland
|
||||
- Subsequent cities: grow along rail corridors from capital (multi-source Dijkstra on cost grid)
|
||||
- At cities 3–4: first foothold on a new continent if one exists (port city)
|
||||
- Roads and rail: A* pathfinding on terrain cost grid (water=impassable, mountains=expensive, rivers=cheap corridors), minimum spanning tree — NO straight lines
|
||||
- Quadrant distribution: after 2 cities in the same map quadrant, subsequent cities must prefer unoccupied quadrants unless those quadrants have no habitable land
|
||||
- Variation: ±25% noise on scoring per seed — same terrain, different seed → different city network
|
||||
- Settlement pattern modifies spacing (`urban_concentrated`=tight, `dispersed`=wide)
|
||||
- All systems same depth, scaled by population. No manual tier classification.
|
||||
|
||||
**4. Naming Pipeline — Gemma 2 Voice Pipeline**
|
||||
- Geographic names generated by the existing Gemma 2 voice pipeline (`server/src/voice/`, `sr-voice` binary)
|
||||
- Input: body wiki page (`planet_class`, `cultural_corridor`, `settlement_pattern`, population, economic_role, atmospheric_tone) + corridor naming palette
|
||||
- Output: culturally-appropriate names for rivers, oceans, mountains, regions, cities
|
||||
- Corridor palettes: north_reach (Anglo-Saxon), south_reach (Iberian/Portuguese), east_reach (East Asian), west_reach (Germanic/Nordic), inner_orbit (institutional Latin/Anglo)
|
||||
- Dual purpose: Phase 3 content generation AND quality/consistency test of the in-game LLM pipeline
|
||||
- Batch throughput: ~80 min for all 2,394 bodies (placement) + ~40 min for 273 inhabited (naming). Parallelizable.
|
||||
- Earth-name blocklist as post-processing safety net. Dedup check against full name corpus.
|
||||
|
||||
**5. Core Systems as Templates**
|
||||
- Core systems (Gateway/Sirius, Groombridge/Lendel, etc.) hand-authored as templates
|
||||
- Templates establish rulesets and quality bar for the generator
|
||||
- Generator produces all remaining bodies → hand-author refinements over the batch
|
||||
- Lendel (GJ 380c) is the first proving ground: arid, `urban_concentrated`, financial hub, 900M pop
|
||||
|
||||
**6. Atlas Panel Architecture**
|
||||
- FULLSCREEN implant app (z=20), same component library as economics panel
|
||||
- 3 navigation levels: system picker → orbital diagram → body atlas (heightmap + overlays)
|
||||
- Heightmap displayed as `Texture2D` with pan/zoom
|
||||
- Marker overlay renders cities, roads, rail, POIs, named features on top of heightmap
|
||||
- Click city → City Data Panel (name, population, currency zone, Commission presence, shadow economy zone, gate distance, economics panel link)
|
||||
- Economics panel link: click-through to Phase 2 economics panel pre-filtered to that node — primary Phase 2/3 integration point
|
||||
|
||||
**7. Overlay System — 9 MVP Overlays**
|
||||
- 5 always-on: terrain, infrastructure, named features, gate/spaceport POIs, political zones
|
||||
- 4 toggleable: population density, production zones, shadow economy zones (broad bands), corporate presence (Tier 1 only)
|
||||
- Deferred overlays visible in toggle bar but locked with unlock requirements shown on hover (creates pull toward Phase 4+ systems)
|
||||
- Maps to D-181 signal visibility ladder
|
||||
|
||||
**8. Settlement Data Model**
|
||||
- markers.json schema per body: `cities` (name, lat/lon, population_tier, primary_function, gate_terminal, continent_id), `roads` (path polylines, connects), `railroads` (path polylines, connects), `pois` (name, kind, position), plus existing rivers/oceans/mountains with names filled
|
||||
- Population tier → city count: `floor(log10(pop/1M))`, modified by `settlement_pattern`
|
||||
- Gate terminal POI: at largest population center, sometimes scattered to a smaller one
|
||||
- Moons: same depth as planets, scale with population
|
||||
|
||||
**9. Implementation Pipeline**
|
||||
- Python: `tooling/planet-gen/generate_atlas.py` — reuses `planet_simulation.simulate()`, adds `city_placement.py`, `infrastructure_gen.py`, `gemma_naming.py`, `markers_writer.py`
|
||||
- `make atlas-generate` runs the full batch. Deterministic per seed. Incremental (skips up-to-date bodies).
|
||||
- Pipeline order: simulate terrain → analyze (continents, habitability, river mouths, cost grid) → place cities sequentially → generate infrastructure (A* MST) → name via Gemma 2 → write markers.json
|
||||
|
||||
**10. MVP Completion Criteria**
|
||||
1. Navigation chain works end-to-end (Reach → system → planet → regional)
|
||||
2. Regional map content complete for all inhabited bodies
|
||||
3. Population-scaled depth (core systems rich, frontier sparse)
|
||||
4. City data panel works (click any city, see data + economics link)
|
||||
5. Economics panel integration works (link opens Phase 2 panel filtered to node)
|
||||
6. All 9 MVP overlays present and functional
|
||||
7. Atlas is read-only (no verbs execute from map)
|
||||
8. Stations show at system view with mini data panel (no drill-down)
|
||||
|
||||
- **Rationale:** The heightmap pipeline was designed with Phase 3 in mind — 2,394 base maps exist. The critical path is content (filling empty markers.json arrays), not technology (the atlas panel follows the established implant component pattern). Sequential settlement growth simulation produces more realistic city networks than scatter placement — each city's location is informed by previous placements, terrain, and economic logic. Using the Gemma 2 voice pipeline for naming tests the in-game LLM quality while generating content. Population-scaled depth without manual tier classification keeps the pipeline simple and the authoring burden manageable.
|
||||
- **Raised by:** Full planning team workshop, Sprint 34 (#748). Participants: Gestalt (systems design), Tyre (technical architecture), Miri (worldbuilding), with Jeroen as workshop participant.
|
||||
- **Dissent:** None.
|
||||
- **Cross-reference:** [D-166](architecture.md#d-166) (development cascade — Phase 3), [D-036](content.md#d-036) (Sova as canonical setting), [D-093](content.md#d-093-sova-transit-district--spatial-layout-and-district-topology) (Sova spatial layout), [D-094](#d-094) (district hierarchy), [D-095](content.md#d-095) (Horizon stations), [D-170](#d-170) (HUD visibility/implant apps), [D-169](#d-169) (implant component library), [D-181](economics.md#d-181-signal-vocabulary) (signal vocabulary/visibility ladder), [D-174](economics.md#d-174-shadow-economy-layer) (shadow economy intensity), [D-175](economics.md#d-175-corporation-taxonomy-and-prerequisite) (corporation taxonomy), [D-138](content.md#d-138-llm-re-voicing-pipeline-for-npc-voice) (Gemma 2 voice pipeline)
|
||||
|
||||
---
|
||||
|
||||
*53 decisions. Last updated: 2026-04-10 (D-191 Atlas of the Reach — Phase 3 scope and pipeline)*
|
||||
|
||||
+137
-1
@@ -304,4 +304,140 @@ This domain covers: currency system, commodity taxonomy, shadow economy, corpora
|
||||
|
||||
---
|
||||
|
||||
*17 decisions (D-171–D-187), 1 rejected alternative (R-011). Domain: economics. Last updated: 2026-04-05.*
|
||||
### D-189: Brand Layer Architecture
|
||||
- **Date:** 2026-04-10
|
||||
- **Decision:** The simulation supports a brand layer above the commodity tâtonnement. Brands are NOT commodities (D-185). They consume commodities as demand nodes and are priced through an administered pricing model with cultural premium curves. The brand layer serves dual purposes: economic simulation (demand nodes, pricing, GDP contribution) and queryable localized content for client UI (bar shelves, restaurant menus, shop displays, entertainment listings).
|
||||
|
||||
**1. Brand Taxonomy**
|
||||
- 8 categories: `terroir`, `heritage_craft`, `tech_premium`, `cultural`, `service_premium`, `commodity_branded`, `design_heritage`, `platform_catalogue`
|
||||
- `value_trajectory`: `appreciating` | `depreciating` | `timeless` (first-class field)
|
||||
- 3 scale tiers for generated brands: local (1–3 systems), regional (corridor-scale), reach-wide budget (everywhere, corridor-neutral naming, no cultural premium)
|
||||
|
||||
**2. Pricing Model**
|
||||
```
|
||||
brand_price = max(price_floor, [base_cost × (1 + target_margin) + cultural_premium × (1 + veblen × scarcity)] × value_trajectory_factor × currency_factor)
|
||||
```
|
||||
- Cultural premium split into `identity_term` + `exotic_term` with `exotic_floor` to support 3 curve types: Scarcity-Distance (artisan), Dual-Peak (media/content), Aspirational Gradient (tech)
|
||||
- Two-component scarcity: structural (`production_volume / addressable_demand` ratio, permanent) + situational (stockpile depletion, temporary)
|
||||
- Veblen per-location derived: `veblen_base × income_quintile × corridor_affinity` — zero authoring cost
|
||||
- `cost_passthrough_ratio`: insulates brand pricing from tâtonnement volatility (low 0.10–0.25 for terroir, high 0.50–0.75 for tech)
|
||||
- `value_trajectory_factor`: appreciating goods gain value with vintage age, depreciating goods lose value with a floor, timeless = 1.0; updated per game-year
|
||||
|
||||
**3. Halo/Volume Tier Structure**
|
||||
- Universal pattern: every notable brand has a halo product (defines identity ceiling) + volume tier(s) (makes the brand economically relevant at population scale ~80B)
|
||||
- `halo_lift_factor`: volume tier borrows a fraction of the halo's cultural premium
|
||||
- Direction inverts by category: terroir pushes scarcity up, tech/media pushes quality up from a mass base
|
||||
- Brands without volume tiers are economically marginal regardless of prestige — population asymmetry (10B systems vs. 50k) makes this structurally necessary
|
||||
|
||||
**4. Brand Census**
|
||||
- Notable (hand-authored): 120–170 corps with TOML records; ~27 currently named
|
||||
- Minor (template-generated): ~10,000 brands from ~120–130 template definitions (35–40 archetypes × 3 sub-variants), corridor-specific naming patterns, 3 scale tiers
|
||||
- Naming patterns: corridor-appropriate — north_reach = British/Australian inflection, east_reach = Korean/Japanese, west_reach = German/Dutch/Nordic, south_reach = Portuguese/Swahili, inner_corridor = pan-corridor neutral, frontier = founder surname + noun
|
||||
- Queryable content: `brand_products JOIN corp_presence` filtered by location and `product_subcategory`; sub-millisecond at 10K+ rows — primary use case alongside economic simulation
|
||||
|
||||
**5. DB Schema**
|
||||
- `brand_products`: `brand_product_id`, `corp_id`, `product_name`, `brand_category` (8 values), `value_trajectory`, `scarcity_class` (`capped` / `constrained` / `scalable` / `unlimited`), `product_subcategory`, `base_premium_multiplier`, `premium_floor`, `origin_system`, `terroir_locked`, `currency_denomination`, `shadow_viable`, `brand_tier` (`halo` / `volume`), `halo_brand_id` (for volume tiers)
|
||||
- `brand_inputs`: `brand_product_id`, `commodity_id`, `quantity`
|
||||
- `system_fiscal`: `system_id`, `corp_tax_rate`, `collection_efficiency` (derived from `shadow_economy_intensity`)
|
||||
- `corp_financial_state` + `corp_lifecycle_events` tables for acquisition/startup lifecycle
|
||||
- Composite index on `brand_products(corp_id, brand_category)` for UI queries
|
||||
|
||||
**6. Corp Tax & GDP**
|
||||
- Corp tax = `revenue × (1 - category_deduction) × tax_rate × collection_efficiency`
|
||||
- Category deductions: terroir 0.30, geological 0.20, tech 0.60, media 0.15, vehicles 0.55, apparel 0.45
|
||||
- Tax flows to HQ system; GDP computed every 100 ticks
|
||||
- `collection_efficiency = 1.0 - shadow_economy_intensity × 0.6`
|
||||
- Visible line item when player owns a company
|
||||
|
||||
**7. Player Verb Ladder**
|
||||
- 6 stages: Operate → Specialize → Distribute → Create → Scale → Corporate
|
||||
- Founding verbs: `brand`, `register`, `market`
|
||||
- Content verbs: `acquire-rights`, `royalty-contract`, `exclusive-window`
|
||||
- Corporate verbs: `acquire`, `merge`, `spin-off`, `license-out`
|
||||
- Temporal verbs: `cellar` (appreciating), `refresh` (depreciating), `license-legacy` (EOL)
|
||||
- `authenticate` is the mechanically richest new verb — 3 service economies: expert appraisal (artisan), Commission inspection (tech/vehicles), Meridian rights lookup (content)
|
||||
|
||||
**8. Acquisition & Startup Lifecycle**
|
||||
- Corp lifecycle states: Founded → Growing → Active → Distressed → Acquired/Dissolved
|
||||
- Startup triggers: market gap, spin-off, player-founded, storyteller event
|
||||
- Acquisition triggers: financial distress, strategic AI acquisition, player-initiated, hostile takeover event
|
||||
- Player acquisition and mergers are in scope; if acquisition exists, startups must exist (or the well dries)
|
||||
- Phase 2: corp health tracking as passive metric. Phase 3: lifecycle state machine, startup generation, player acquisition
|
||||
|
||||
**9. Events**
|
||||
- `BrandPrestigeShock` via EconEvent port (D-180) — very occasional scandal events (pollution, fraud)
|
||||
- Exponential decay with authored half-life
|
||||
- Direct-vector only: events hit the brand's known vectors (commodity input price, production location); no indirect cascading beyond that
|
||||
|
||||
**10. Phase 2 Boundary**
|
||||
- Phase 2 (this sprint cycle): brand corps as commodity demand stubs in tâtonnement, `brand_products` / `brand_inputs` / `system_fiscal` schema, `corp_financial_state` passive tracking, `generate_brands` pipeline
|
||||
- Brand layer (post-Phase 2): pricing engine, awareness propagation, cultural preference curves, aging pipeline simulation, lifecycle state machine, player acquisition verbs
|
||||
|
||||
**11. Named Brand Corps (~27)**
|
||||
| Corp | Category | Origin |
|
||||
|------|----------|--------|
|
||||
| Calloway Distillery | terroir | north_reach |
|
||||
| VGV | terroir | west_reach |
|
||||
| thrds | heritage_craft | north_reach |
|
||||
| Bífröst Marmor | terroir | north_reach / Compact |
|
||||
| Destilaria Confluência / Lento | terroir | south_reach |
|
||||
| Veldfontein Botanical | terroir + heritage_craft | south_reach |
|
||||
| Comptoir Lendel | terroir + service_premium | inner_core |
|
||||
| Maison Cinq | design_heritage | inner_core / Gateway |
|
||||
| MVG (Manifattura Veicoli Gherardi) | design_heritage | west_reach Italian |
|
||||
| Higashiyama Vehicle Engineering | tech_premium + design_heritage | east_reach |
|
||||
| Rijdbaar Personal Mobility | design_heritage | west_reach / Compact |
|
||||
| Byeolbit Entertainment | cultural + platform_catalogue | east_reach |
|
||||
| Leerfeld Records | cultural | west_reach / Compact |
|
||||
| Vuma Sound | cultural | south_reach |
|
||||
| Resonance Premium | platform_catalogue | inner_core |
|
||||
| Dalbit Systems | tech_premium + platform_catalogue | east_reach |
|
||||
| Arclamp | cultural + design_heritage | inner_core |
|
||||
| Kellervolk | cultural | Compact |
|
||||
| Hangang Studio | platform_catalogue | east_reach |
|
||||
| Hanyang Precision | — (Tier 1, branded_products) | — |
|
||||
| Sato Medical | — (Tier 1, branded_products) | — |
|
||||
| Takamori Lattice | — (Tier 1, branded_products) | — |
|
||||
| Thalassa Resort | — (Tier 1, branded_products) | — |
|
||||
| Somatic Futures | — (Tier 1, branded_products) | — |
|
||||
| The Registry | — (Tier 1, branded_products) | — |
|
||||
| Meridian Risk | — (Tier 1, branded_products) | — |
|
||||
|
||||
- **Rationale:** Administered pricing is the correct model because brands violate all three tâtonnement assumptions: heterogeneity (Calloway ≠ VGV ≠ generic spirits), supply inelasticity (terroir production cannot respond to price signals per D-177), and Veblen demand effects (prestige goods can have upward-sloping demand). The one-way interface (commodity prices → brand input costs; brand output prices do NOT feed back into tâtonnement) is architecturally clean and matches the D-178 layer model. The identity/exotic split in cultural premium is the minimal structural addition needed to produce all three observed pricing curves (Scarcity-Distance, Dual-Peak, Aspirational Gradient). Population asymmetry (~80B total Reach population, systems ranging from 10B to <50k) makes the halo/volume tier pattern structurally necessary — brands from tiny worlds are astronomically exclusive and need volume derivatives to be economically relevant.
|
||||
- **Raised by:** Full planning team workshop, Sprint 34 (#811).
|
||||
- **Dissent:** None.
|
||||
- **Cross-reference:** [D-185](#d-185-brands-are-not-commodities) (brands are not commodities), [D-184](#d-184-commodity-catalog-36-types) (commodity catalog), [D-177](#d-177-productivity-constraints-lore-derived) (productivity constraints), [D-175](#d-175-corporation-taxonomy-and-prerequisite) (corporation taxonomy), [D-178](#d-178-economic-model-architecture) (economic model architecture), [D-180](#d-180-event-input-port) (event input port), [D-181](#d-181-signal-vocabulary) (signal vocabulary), [D-173](#d-173-commodity-taxonomy) (commodity taxonomy), [D-171](#d-171-three-currency-system) (three-currency system), [D-131](content.md#d-131-broad-economic-verb-vocabulary--life-verbs-not-tycoon-specific) (economic verb vocabulary), [D-118](scope.md#d-118-small-business-owner-starting-state--tycoon-is-aspiration-not-starting-position) (small business owner starting state)
|
||||
|
||||
---
|
||||
|
||||
### D-190: Brand Volume Calibration — Population-Relative Scale
|
||||
- **Date:** 2026-04-10
|
||||
- **Decision:** All brand production and distribution volume numbers must be specified relative to a reference population, not as absolute counts. Volume without a reference population is not meaningful at Reach scale:
|
||||
- **Scale reference table (~80B Reach):**
|
||||
- Single-system local phenomenon: 100M–500M (1–5% of a 10B system)
|
||||
- Corridor-known hit: 250M–1B (~0.5% of corridor addressable population)
|
||||
- Reach-wide genuine hit: ~1B (1 in 80 Reach population)
|
||||
- All-time Reach canonical: 2B–5B (1 in 16–40 Reach population)
|
||||
- **Earth rule of thumb:** multiply Earth-scale phenomenon volumes by 12–15× for comparable cultural penetration. A 100M-seller on Earth ≈ 1.2–1.5B in the Reach.
|
||||
- **40M benchmark:** 40M units/streams Reach-wide = 0.04% penetration — a cult hit or successful regional release, not a cultural touchstone. 40M within a single large system (10B population) = 0.4% — respectable but not legendary.
|
||||
- **Authoring rule:** wiki volume figures for media brands and any good with `brand_category = cultural` must include a `reference_population` annotation alongside the count. "40M" is incomplete; "40M (west_reach corridor, ~10B addressable)" is correct. This applies to corporation production ceilings, media distribution figures, and market penetration estimates in wiki pages and TOML files.
|
||||
- **Structural scarcity principle:** for physical brand goods, production volume only has meaning relative to addressable demand. The `structural_scarcity_base` parameter in the brand pricing layer (D-189) is derived from this ratio: `1.0 - min(1.0, annual_volume / (addressable_population × demand_rate))`. A 12,000-unit/year artisan product against 100M addressable consumers yields structural_scarcity_base ≈ 0.88 — perpetually near-maximum scarcity regardless of local stockpile state. This scarcity floor is permanent, not situational.
|
||||
- **Rationale:** The Reach's population asymmetry (core systems 10B+, frontier systems under 50K) makes absolute volume numbers meaningless without a reference population. Without an explicit calibration rule, brand and media content significance will be systematically miscalibrated across all authoring. The structural scarcity principle connects volume calibration to the administered pricing model: a brand's Veblen premium floor is derived from the same population-relative ratio, ensuring that pricing and authoring are grounded in the same underlying reality.
|
||||
- **Raised by:** Jeroen (population asymmetry insight), Burnelli-Sheldon (structural scarcity derivation and calibration table), Sprint 34 Workshop #811.
|
||||
- **Dissent:** None.
|
||||
- **Cross-reference:** [D-189](#d-189-brand-layer-architecture) (structural_scarcity_base parameter), [D-177](#d-177-productivity-constraints-lore-derived) (lore-constrained production ceilings), [D-175](#d-175-corporation-taxonomy-and-prerequisite) (corporation production volumes)
|
||||
|
||||
---
|
||||
|
||||
## Rejected Alternatives
|
||||
|
||||
### R-011: Single currency for Phase 2 (rejected)
|
||||
- **Date:** 2026-04-05
|
||||
- **Proposed by:** Burnelli-Sheldon (economist), Sprint 32 Workshop #796 Round 1
|
||||
- **Proposal:** Use a single currency for the Phase 2 economics simulation to reduce model complexity. Exchange rate mechanics could be added in a later phase.
|
||||
- **Rejected because:** Three currencies create structural economic bloc tension as an emergent property of initialization — no event generation required. The Tractus/Mark divide maps directly to the Assembly vs. Compact political divide that is already canonical lore. Deferring currencies to a later phase would require retrofitting political geography into an already-running simulation. The complexity cost of three currencies is low; the design value is high.
|
||||
- **Raised by:** Lead directive overruling the recommendation.
|
||||
|
||||
---
|
||||
|
||||
*19 decisions (D-171–D-187, D-189–D-190), 1 rejected alternative (R-011). Domain: economics. Last updated: 2026-04-10.*
|
||||
|
||||
@@ -280,15 +280,6 @@ tooling/db/sqlite-query "SELECT * FROM tickets WHERE status='open'"
|
||||
tooling/db/sqlite-exec "UPDATE tickets SET status='done' WHERE id=1"
|
||||
```
|
||||
|
||||
## Qdrant / Document Search
|
||||
|
||||
```bash
|
||||
tooling/db/qdrant-search "asymmetric information design"
|
||||
tooling/db/qdrant-index docs/briefings/tyre.md
|
||||
tooling/db/qdrant-health
|
||||
tooling/db/qdrant-count
|
||||
```
|
||||
|
||||
## Decisions System
|
||||
|
||||
Decisions are split into domain files under `decisions/` (see `decisions/README.md` for the full index). A SQLite index table syncs metadata for cross-referencing and querying.
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Qatux - Project Briefing"
|
||||
description: "Decision archivist and documentation maintainer — owns all D/Q/R-records in decisions/, agent briefings, discussion rounds, diagrams, and Qdrant indexing"
|
||||
description: "Decision archivist and documentation maintainer — owns all D/Q/R-records in decisions/, agent briefings, discussion rounds, and diagrams"
|
||||
type: briefing
|
||||
status: active
|
||||
agent: Qatux
|
||||
@@ -51,8 +51,7 @@ None assigned directly. Track all Q-NNN and Q-WTF-* records.
|
||||
3. Update `docs/discussions/README.md` with new round entries after formal closure
|
||||
4. Update relevant agent briefing files with new decision references
|
||||
5. **Create and update diagrams** whenever D-records are added or modified — use the `/d2-diagram` skill to generate d2 source + PNG. Existing diagrams in `docs/diagrams/{category}/` must be updated when their source decisions change. Categories: architecture, data-flow, entity, state, ui.
|
||||
6. Re-index changed documents in Qdrant after updates
|
||||
7. Verify briefing freshness against decision domain files
|
||||
6. Verify briefing freshness against decision domain files
|
||||
|
||||
## Key Documents
|
||||
- `decisions/` — domain-split decision files (see `decisions/README.md` for index)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Sprint 33: Pecunia — CI Tasks
|
||||
|
||||
**Goal:** Full economics simulation running — Leontief production, spatial price equilibrium, currency zones, corporate behavioral agents across all three corporation tiers, stability tests passing.
|
||||
|
||||
**Branch:** `sprint-33/ci`
|
||||
**Agents:** Justine (build/deploy)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #816 | Remove Qdrant semantic search infrastructure | — |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full ticket details.
|
||||
|
||||
## Notes
|
||||
|
||||
**#816 — Remove Qdrant semantic search infrastructure**
|
||||
- The Qdrant index ('commonwealth' collection, 475 points, tower-of-joy:6333) is stale — it points at old worktree paths from previous sprints and nobody maintains it. Grep covers all current search needs.
|
||||
- Five removal steps (all five must be done together for a clean removal):
|
||||
1. Drop the Qdrant collection on `tower-of-joy:6333` — use the Qdrant HTTP API directly (`DELETE /collections/commonwealth`). Confirm the collection no longer exists before proceeding.
|
||||
2. Remove `tooling/db/qdrant_connector.py`.
|
||||
3. Remove the `/docs-search` skill (find it under `.claude/skills/`).
|
||||
4. Remove Qdrant references from `tooling/db/config.json` and `.claude/rules/local-services.md`.
|
||||
5. Remove `qdrant-search` and `qdrant-index` from the CLI tool table in `CLAUDE.md`.
|
||||
- After removal, run a repo-wide grep for `qdrant` (case-insensitive) to catch any remaining references in other docs, rules, or briefings. Clean them up.
|
||||
- No replacement tooling is needed — grep is sufficient and already in use.
|
||||
- This is a low-priority maintenance ticket. Do not let it block other work. If the Qdrant server is unreachable when you attempt step 1, skip it and note in the PR that the collection deletion must be done manually.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#816 (remove Qdrant) → standalone
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "chore(ci): description" --description "body" --base main --head sprint-33/ci
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
# Sprint 33: Pecunia — Client Tasks
|
||||
|
||||
**Goal:** Full economics simulation running — Leontief production, spatial price equilibrium, currency zones, corporate behavioral agents across all three corporation tiers, stability tests passing.
|
||||
|
||||
**Branch:** `sprint-33/client`
|
||||
**Agents:** Stig (UI dev)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #818 | Create icon_tint.gdshader for runtime icon recoloring | — |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full ticket details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/architecture.md` — D-169 (implant UI component library), D-170 (HUD visibility groups)
|
||||
|
||||
## Notes
|
||||
|
||||
**#818 — Create icon_tint.gdshader for runtime icon recoloring**
|
||||
- The implant HUD icon set (D-086, #795) references `icon_tint.gdshader` for runtime color replacement via ShaderMaterial. This shader was assumed to exist but was not authored during Sprint 32.
|
||||
- The shader must handle two icon types:
|
||||
- **Stroke-based icons** (majority of the set) — the icon is drawn as colored outlines/strokes on a transparent background. Recolor by replacing the stroke color at runtime.
|
||||
- **Fill-based icons** (health cross, at minimum) — the icon is a solid filled shape. Recolor the fill.
|
||||
- Integration spec: `docs/design/icon-set-v01.md` section 3.2 details the Godot ShaderMaterial parameter interface. Read that section before writing the shader.
|
||||
- Placement: `client/ui/implant/` or `client/shaders/` — check where other shaders live in the client tree and follow the existing convention.
|
||||
- The shader takes a `ShaderMaterial` parameter (the tint color) and outputs a recolored version of the source texture, preserving alpha. The icon textures are single-color SVG exports — the shader replaces the source color, not a specific channel.
|
||||
- Test by attaching to an `ImplantDataRow` or standalone `TextureRect` with one of the HUD icon textures. Verify both stroke and fill icon types recolor correctly at all implant theme semantic colors (`ACCENT_ACTIVE`, `ACCENT_POSITIVE`, `ACCENT_NEGATIVE`, `ACCENT_WARNING`, per `client/ui/implant/default_implant.tres`).
|
||||
- This is a standalone task — no server-side dependencies.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#818 (icon_tint.gdshader) → standalone
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(ui): description" --description "body" --base main --head sprint-33/client
|
||||
```
|
||||
@@ -0,0 +1,85 @@
|
||||
# Sprint 33: Pecunia — Copy Tasks
|
||||
|
||||
**Goal:** Full economics simulation running — Leontief production, spatial price equilibrium, currency zones, corporate behavioral agents across all three corporation tiers, stability tests passing.
|
||||
|
||||
**Branch:** `sprint-33/copy`
|
||||
**Agents:** Mellanie (author), Miri (worldbuilding), Paula (narrative)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #820 | Author MARK_PRIMARY currency zone assignments for Compact systems | — |
|
||||
| #803 | Define shadow economy intensity ranges | — |
|
||||
| #799 | Generate ~150 Tier-2 regional corporations | #798 (DONE) |
|
||||
| #800 | Build corporation generation pipeline for Tier-3 | #798 (DONE) |
|
||||
| #812 | Wiki commodity copy pass | — |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full ticket details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/economics.md` — D-171 (three-currency system: Tractus/Mark/Sol), D-172 (currency zone initialization: affiliation-based, NOT hop-distance), D-173 (commodity taxonomy), D-174 (shadow economy layer: Compact shadow is principled economic resistance, not frontier lawlessness), D-175 (corporation taxonomy: Tier 1/2/3 structure, prerequisite for Phase 2), D-182 (TOML source of truth), D-183 (iterative development cycle), D-184 (36-type commodity catalog), D-185 (brands not commodities)
|
||||
- `decisions/architecture.md` — D-166 (Phase 2 deliverable: economics simulation with runtime-tweakable parameters)
|
||||
|
||||
## Notes
|
||||
|
||||
**#820 — Author MARK_PRIMARY currency zone assignments for Compact systems**
|
||||
- The `currency_zone` column exists on `star_systems` (default `TRACTUS_PRIMARY`). Sol (GJ 0) is already `MIXED`. All other systems are currently `TRACTUS_PRIMARY`.
|
||||
- Per D-172: zone assignment derives from **political affiliation** (Compact membership, Commission presence) — NOT from hop distance. Hop distance correlates with Compact membership but is not the rule. A hop-5 Assembly system stays `TRACTUS_PRIMARY`; a hop-8 Compact member is `MARK_PRIMARY`.
|
||||
- Output: a TOML or JSON data file (e.g., `wiki/economics/currency_zones.toml`) that lists each Compact-member system_id and its zone flag (`MARK_PRIMARY` or `MIXED`). Systems not listed default to `TRACTUS_PRIMARY`.
|
||||
- Miri owns the lore: which systems are Compact of Westphalia members? Cross-reference `wiki/factions/compact-of-westphalia.md` and system wiki pages. `MIXED` is for Compact-sympathetic systems where both currencies are accepted but neither dominates — use it for border/transitional systems.
|
||||
- The import pipeline (`tooling/economy-db/import_economics.py` function `set_currency_zones`) already has a placeholder comment: "Future: Compact systems → MARK_PRIMARY (requires authored Compact membership data)." Your output feeds directly into that pipeline extension (server team #805 will wire it up).
|
||||
- Compact members default to `gate_energy_connected = false` per D-186 — your zone assignments also drive that default, so precision matters.
|
||||
|
||||
**#803 — Define shadow economy intensity ranges**
|
||||
- Output: `wiki/economics/shadow_economy.toml` with per-system-type intensity ranges.
|
||||
- Geographic bands from D-174: core systems 0.0–0.2, mid-reach 0.3–0.6, Compact and frontier 0.6–0.9.
|
||||
- Seeding inputs (additive, D-174): Commission presence (inverse — high Commission = low shadow), Compact membership (elevated independently of distance), hop distance from Core, gate topology (dead-end systems higher than transit nodes).
|
||||
- Critical framing (D-174): The Compact's shadow economy is **principled economic resistance** to Assembly currency friction on enforcement costs — not frontier lawlessness. This distinction must be legible in the intensity value commentary and any associated narrative notes. Compact members at hop 6+ can have high intensity (0.7–0.8) while remaining culturally coherent citizens.
|
||||
- The `official_coverage_ratio` signal (D-181 signal 7) is derived from this data — it is the gap between what the lattice audit trail sees and the real economic activity. Write the TOML so it is queryable by system_type and by specific system_id overrides.
|
||||
- Paula owns the narrative framing. Miri owns geographic distribution. Mellanie owns the file structure and prose flavor.
|
||||
|
||||
**#799 — Generate ~150 Tier-2 regional corporations**
|
||||
- #798 (archetype taxonomy) is DONE. `wiki/economics/archetypes/lore.toml` (28 lore-taxonomy archetypes: extraction, agriculture, manufacturing, trade/logistics, services, intelligence, east-reach) and `wiki/economics/archetypes/behavioral.toml` (6 behavioral archetypes) are the templates.
|
||||
- Output: TOML records in `wiki/economics/corporations/tier2/` — one file per corporation or one file per corridor/sector (your call, but keep it reviewable in git).
|
||||
- Each record needs: name, sector (lore archetype), corridor, backstory, distinct character, behavioral archetype. Plus: `products[]` — 2–5 branded product names based on primary commodity and cultural corridor. Brands are authored, not templated (D-185).
|
||||
- Distribution: across all corridors including east reach. East reach was flagged as a gap in D-175 — ensure coverage there. Coverage rule: every commodity type must have at least one Tier-2 producer; every major corridor must have at least one Tier-2 firm.
|
||||
- These corporations are the regional competitors — the ones that can fail, grow, or be acquired. Give them distinct character: each should feel like a real business with a particular personality, not a slot-filler. A Cooperative in Braemar behaves and speaks differently from an Intermediary in the Compact zone.
|
||||
- The Tier-2 corpus feeds #809 (server corporate agents) — server team will read `behavioral_archetype` from these records to instantiate simulation parameters. Keep that field clean.
|
||||
|
||||
**#800 — Build corporation generation pipeline for Tier-3**
|
||||
- Note: this ticket is assigned to the **server team** (it is a Rust binary). The copy team's dependency is upstream: the archetype TOML files in `wiki/economics/archetypes/` must be finalized before the pipeline runs. Verify with server team that `lore.toml` and `behavioral.toml` schemas are stable before Sprint 33 mid-point.
|
||||
- Copy team action for this ticket: review and sign off on the generated output (`generated_corporations.toml`) for lore consistency. The Tier-3 instances stock brands from Tier-1 and Tier-2 corporations in the same supply chain corridor — Paula should verify the brand attribution feels geographically coherent.
|
||||
|
||||
**#812 — Wiki commodity copy pass**
|
||||
- 36 commodity stub pages exist at `wiki/economics/commodities/` — currently auto-generated field tables only (no flavor text).
|
||||
- Output: flesh out each stub with flavor text, lore context, and production chain descriptions.
|
||||
- Reference: `wiki/economics/commodities/index.md` for the full list. Reference: `wiki/economics/commodities.toml` and `wiki/economics/production_chains.toml` for the technical data to translate into prose.
|
||||
- Voice: these pages appear in the implant wiki (GTTR equivalent for economic data). Write them as objective technical/commercial entries — not marketing copy, not academic dry. The voice of an economic intelligence briefing that a trader would actually read.
|
||||
- Key commodities to handle with care:
|
||||
- `fusion_fuel` — intermediate, not raw; 8:1 water yield ratio (D-187); utility demand at every node; frontier premium is structural, not event-driven.
|
||||
- Services (`commission_certification`, `financial_services`, `medical_reembodiment`, `insurance`, `entertainment`, `hospitality`) — location-bound, non-transportable through gates; they consume goods but do not produce them.
|
||||
- Brands are NOT commodities (D-185) — do not create stub pages for Calloway whisky, VGV wine, etc. The catalog terminates at abstract generic finals (e.g., "premium spirits").
|
||||
- Mellanie owns the writing. Paula reviews for narrative voice consistency. Miri flags any lore collisions.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#820 (Compact zone assignments) → feeds server #805 corp pipeline
|
||||
#803 (shadow economy ranges) → feeds server #808 currency zones ticket
|
||||
|
||||
#799 (Tier-2 corps) ──────────────────────────────→ #809 (server: corporate agents)
|
||||
#800 (Tier-3 pipeline, server executes) ──────────→ #809 (server: corporate agents)
|
||||
|
||||
#812 (commodity copy) → standalone, no code blockers
|
||||
```
|
||||
|
||||
#799 and #803 are the sprint-critical outputs — server team cannot close #808 and #809 without them. Start these first.
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(copy): description" --description "body" --base main --head sprint-33/copy
|
||||
```
|
||||
@@ -0,0 +1,83 @@
|
||||
# Sprint 33: Pecunia — Joint Notes
|
||||
|
||||
**Goal:** Full economics simulation running — Leontief production, spatial price equilibrium, currency zones, corporate behavioral agents across all three corporation tiers, stability tests passing.
|
||||
|
||||
## Sprint Completion Proof
|
||||
|
||||
When Sprint 33 is done, you can:
|
||||
|
||||
1. **Run `economy-sim --stability-check`** and see all four D-179 tests pass: cold-start convergence (±5% within 100 days), long-run stability (no drift > ±2% over 1,000 days), shock response (recovery within 200 ticks, no negative prices), cross-zone re-stabilization (within 50 ticks).
|
||||
2. **See Compact systems with `MARK_PRIMARY` currency zones** in `server/data/systems.db` — `SELECT system_id, currency_zone FROM star_systems WHERE currency_zone != 'TRACTUS_PRIMARY'` returns a non-trivial list matching Compact membership.
|
||||
3. **See 150 Tier-2 corporations** in `wiki/economics/corporations/tier2/` with distinct names, corridors, backstories, and branded product lists.
|
||||
4. **See `generated_corporations.toml`** with 5,000+ Tier-3 template instances covering all inhabited systems.
|
||||
5. **Read 36 commodity wiki pages** at `wiki/economics/commodities/` with full flavor text, lore context, and production chain descriptions.
|
||||
6. **See `wiki/economics/shadow_economy.toml`** with per-system-type intensity ranges authored and annotated.
|
||||
7. **See the `icon_tint.gdshader`** in the client with recoloring working on both stroke and fill icon types at all implant theme semantic colors.
|
||||
8. **Grep for `qdrant`** in the repo and find no references (removal complete).
|
||||
|
||||
## Phase 2 Context
|
||||
|
||||
Phase 2 deliverable (D-166): "Economics spreadsheets/graphs with runtime-tweakable simulation."
|
||||
|
||||
Sprint 33 is the first economics implementation sprint. The economics schema (`#804`, DONE), commodity catalog (`wiki/economics/commodities.toml`), and production chains (`wiki/economics/production_chains.toml`) were completed in Sprint 32. The Tier-1 corporation corpus (38 named corporations, `#797`, DONE) and archetype taxonomy (`#798`, DONE) are also in place.
|
||||
|
||||
This sprint builds the simulation binary and completes the corporation corpus (Tier 2 authored, Tier 3 generated). It does not close Phase 2 — the phase deliverable includes the full signal pipeline and runtime-tweakable parameters, which will be Sprint 34+ work. But passing all four stability tests in Sprint 33 validates the model architecture and unblocks everything downstream.
|
||||
|
||||
## Pre-Sprint Checklist
|
||||
|
||||
Before any simulation code is written, verify:
|
||||
|
||||
| Item | Owner | Status |
|
||||
|------|-------|--------|
|
||||
| `server/data/systems-schema.sql` has `currency_zone` on `star_systems` | server | Done (#804) |
|
||||
| `corp_presence` table exists in schema | server | Done (#804) |
|
||||
| `wiki/economics/commodities.toml` — 36 types present | copy | Done (#801) |
|
||||
| `wiki/economics/production_chains.toml` — 21 chains present | copy | Done (#801) |
|
||||
| `wiki/economics/archetypes/lore.toml` — 28 archetypes defined | copy | Done (#798) |
|
||||
| `wiki/economics/archetypes/behavioral.toml` — 6 archetypes defined | copy | Done (#798) |
|
||||
| 38 Tier-1 corporations in DB + wiki | copy | Done (#797) |
|
||||
|
||||
## Cross-Team Dependencies
|
||||
|
||||
The server simulation chain (#806 → #807 → #808 → #809) has hard blockers on copy team output:
|
||||
|
||||
- **#809 (corporate agents)** cannot be started until #799 (Tier-2 corps) is in a queryable state. Server team must be able to read `behavioral_archetype` from Tier-2 TOML records.
|
||||
- **#808 (currency zones)** consumes `wiki/economics/shadow_economy.toml` (#803) for per-node `shadow_economy_intensity`. Copy team should deliver #803 by mid-sprint.
|
||||
- **#805 (corp pipeline)** will wire in Compact zone assignments from #820. Copy team must finalize the list of `MARK_PRIMARY` system IDs before #805 runs its full validation pass.
|
||||
- **#800 (Tier-3 pipeline)** is a server Rust binary but reads archetype TOML format authored by copy. Schema for `wiki/economics/archetypes/` must be stable before #800 starts. Server and copy teams: align on this format in the first two days of the sprint.
|
||||
|
||||
## Iterative Development Cycle (D-183)
|
||||
|
||||
This sprint follows the iterative economics cycle: skeleton sim (#806) → data population (copy work running in parallel) → test (#807 stability) → currency layer (#808) → agents (#809). Copy and server work in parallel — they are not sequentially gated except at the #809 merge point.
|
||||
|
||||
If stability tests fail in #807, do not proceed to #808 until Tests 1 and 2 pass. The tâtonnement parameters (α=0.03, β=0.4) are starting values — Dudley/Tyre should tune them if the model oscillates or drifts. Report to team lead before making architectural changes.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
Server chain:
|
||||
#813 (energy-over-gate schema) ─────────────────────────────────────────┐
|
||||
#805 (corp pipeline + validation) → #806 (skeleton sim) |
|
||||
→ #807 (trade flows + stability) |
|
||||
→ #808 (currency zones) ←───┘
|
||||
→ #809 (corporate agents)
|
||||
|
||||
Copy unblocks server:
|
||||
#820 (MARK_PRIMARY assignments) → feeds #805 validation
|
||||
#803 (shadow economy ranges) → feeds #808 shadow modifier
|
||||
#799 (Tier-2 corps) ─────────────────────────────────────────────────→ #809
|
||||
#800 (Tier-3 pipeline, server executes) ───────────────────────────→ #809
|
||||
|
||||
Independent:
|
||||
#812 (commodity copy) → standalone
|
||||
#818 (icon_tint shader, client) → standalone
|
||||
#816 (remove Qdrant, CI) → standalone
|
||||
```
|
||||
|
||||
## Not In Scope
|
||||
|
||||
- Phase 2 signal delivery to the Godot client — that is Phase 4 player interaction territory
|
||||
- Event input port exercising — port is stubbed in #809, not exercised until Phase 3+
|
||||
- Heightmap batch (#794) — deferred from Sprint 32, carries into a later sprint
|
||||
- Character creation UI (#694, #618, #619) — Phase 4
|
||||
- World generation — Phase 5
|
||||
@@ -0,0 +1,103 @@
|
||||
# Sprint 33: Pecunia — Server Tasks
|
||||
|
||||
**Goal:** Full economics simulation running — Leontief production, spatial price equilibrium, currency zones, corporate behavioral agents across all three corporation tiers, stability tests passing.
|
||||
|
||||
**Branch:** `sprint-33/server`
|
||||
**Agents:** Dudley (simulation dev), Tyre (architecture), Hoshe (QA)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #813 | Energy-over-gate schema extension | — |
|
||||
| #805 | Extend economy-db with corporation pipeline and validation | #804 (DONE) |
|
||||
| #806 | Build skeleton economy_sim binary | #805 |
|
||||
| #807 | Add trade flows and stability testing | #806 |
|
||||
| #808 | Add currency zones and exchange rates | #807 |
|
||||
| #809 | Add corporate agent behavior | #808, #799, #800 |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full ticket details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/economics.md` — D-171 (three-currency system), D-172 (currency zone initialization), D-173 (commodity taxonomy), D-174 (shadow economy layer), D-175 (corporation taxonomy), D-176 (productivity seeding), D-177 (productivity constraints), D-178 (economic model architecture), D-179 (stability acceptance criteria), D-180 (event input port), D-181 (signal vocabulary), D-182 (TOML source of truth), D-183 (iterative development cycle), D-184 (commodity catalog 36 types), D-185 (brands not commodities), D-186 (gate transmission levels), D-187 (fusion fuel as intermediate)
|
||||
- `decisions/architecture.md` — D-166 (development cascade: Phase 2 deliverable = economics spreadsheets/graphs with runtime-tweakable simulation)
|
||||
|
||||
## Notes
|
||||
|
||||
**#813 — Energy-over-gate schema extension**
|
||||
- `currency_zone` column already exists on `star_systems` (`server/data/systems-schema.sql`). Need to add `gate_energy_connected` boolean column to the appropriate node table.
|
||||
- Per D-186: `MARK_PRIMARY` zones default to `gate_energy_connected = false` (Compact refused Gate Corp dependency deliberately). All other zones default to `true`.
|
||||
- Demand reduction: nodes with `gate_energy_connected = true` get ~0.3× `fusion_fuel` utility demand. The reduction applies only to utility/habitation consumption — industrial chain inputs (`smelt_ore` 0.3, `alloy_fabrication` 0.2, `electronics_fabrication` 0.2) are unaffected.
|
||||
- This is a schema + migration ticket. No simulation logic yet — that is consumed by #806.
|
||||
- The `gate_energy_connected` field will be read by the sim binary when it computes per-node utility demand.
|
||||
|
||||
**#805 — Extend economy-db with corporation pipeline and validation**
|
||||
- The existing import pipeline (`tooling/economy-db/import_economics.py`) already handles: gate_links, commodities, production_chains, currency_zone on star_systems. The `corp_presence` table exists in schema but the comment at line 12 reads "Does NOT populate corp_presence — that's a future pipeline step." This is that step.
|
||||
- Extend the pipeline to: read `wiki/corporations/*.md` and/or the DB `corporations` table, populate `corp_presence` rows from authored location data, validate that wiki corporation names match DB `corporations.proper_name` records (sync constraint from D-182), enforce coverage rules: 3+ corporations per major commodity type, 1+ per inhabited system with population > 100K.
|
||||
- Add chain completeness validation: every intermediate commodity must have at least one production chain that produces it.
|
||||
- Coverage validation failures must be hard errors (non-zero exit), not warnings. The Phase 2 prerequisite from D-175 requires this gate.
|
||||
- Input: `wiki/corporations/` markdown files, `server/data/systems.db` (corporations and system_economy tables). Output: populated `corp_presence` table.
|
||||
|
||||
**#806 — Build skeleton economy_sim binary**
|
||||
- New Rust binary at `tooling/econ-sim/`. Follow patterns from `server/src/bin/atlas/` for CLI structure (argparse via clap, SQLite reads via rusqlite).
|
||||
- Loads transport graph from `systems.db` (`gate_links` table, bidirectional). Reads economy config from the built `.db` (commodities, production_chains, chain_inputs, corp_presence).
|
||||
- Seeds per-corporation productivity from PRNG seed (D-176): five dimensions (`extraction_rate`, `processing_throughput`, `transit_capacity`, `service_throughput`, `service_capacity`). Log-normal distribution, 0.4–1.8× multiplier for standard nodes, 0.7–1.4× for monopoly-source nodes. Corridor correlation ~0.6 — nearby sites should draw correlated samples.
|
||||
- Initial scope: Leontief production + consumption + price adjustment (Layer 1 only, per D-178). No inter-system trade flows, no currency zones, no corporate behavior.
|
||||
- Outputs per-node CSV with: node_id, commodity_id, supply, demand, price, tick.
|
||||
- The `--stability-check` flag is scaffolded here but not yet meaningful — it will be exercised in #807.
|
||||
- Lore-derived constraints from D-177 must be respected: do not seed location of production, biological monopoly ceilings, aging pipeline contents, or gate topology.
|
||||
|
||||
**#807 — Add trade flows and stability testing**
|
||||
- Extends the binary from #806 with Layer 2 (spatial price equilibrium via damped tâtonnement, α=0.03, β=0.4, per D-178).
|
||||
- Prices propagate through the gate transport graph. Lagged adjustment — not instant equilibrium. Transport costs: 5–12%/hop on gate edges, 1–3% on orbital edges.
|
||||
- Market node tiering (D-178): ~760 active market nodes (inhabited bodies + all stations), ~240 passive producers (feed output to nearest active node), ~2,700 inert. Floyd-Warshall over active subgraph at startup (~0.5s expected, one-time cost).
|
||||
- Stockpile buffers per node: prevents instantaneous price explosions on single-tick supply disruptions.
|
||||
- `--stability-check` mode must now pass Tests 1 and 2 from D-179:
|
||||
- Test 1: Cold-start convergence — prices settle within ±5% of equilibrium within 100 game-days.
|
||||
- Test 2: Long-run stability — zero drift > ±2% over 1,000 game-days with zero external events.
|
||||
- If the model oscillates or diverges under no external input, that is a broken model, not a feature. Tune α/β first before concluding the model is wrong.
|
||||
|
||||
**#808 — Add currency zones and exchange rates**
|
||||
- Extends the binary with Layer 2 currency dynamics (per D-171, D-172).
|
||||
- Three currencies: Tractus (numeraire), Mark (Compact zone), Sol (shadow only — no formal exchange rate, modeled as shadow economy commodity per D-174).
|
||||
- Cross-zone conversion friction: ~3% cost on Tractus↔Mark trade. Zero internal friction within `MARK_PRIMARY` zones (Compact "no internal tariffs" principle).
|
||||
- Exchange rate float driven by trade balance — Tractus/Mark rate adjusts over time based on cross-zone import/export imbalances.
|
||||
- Shadow economy modifier: apply per-node `shadow_economy_intensity` (0.0–1.0, from authored `wiki/economics/shadow_economy.toml` — authored by copy team in #803) to adjust shadow pricing signals. The `official_coverage_ratio` signal (D-181 signal 7) is derived from this.
|
||||
- `--stability-check` must now also pass Tests 3 and 4 from D-179:
|
||||
- Test 3: Shock response — after single supply shock, cascade propagates realistically, recovery within 200 ticks, no price explosions or negative prices.
|
||||
- Test 4: Cross-zone trade balance — after cross-zone trade volume change, exchange rate adjusts and re-stabilizes within 50 ticks.
|
||||
- Compact zone connectivity: `gate_energy_connected = false` nodes (from #813) should show elevated `fusion_fuel` utility demand in their signals.
|
||||
|
||||
**#809 — Add corporate agent behavior**
|
||||
- Extends the binary with Layer 3 (corporate behavioral agents, per D-178).
|
||||
- Six behavioral archetypes (D-175): Monopolist, Distributor, Producer, Specialist, Cooperative, Intermediary. Parameters are template-instantiated — read archetype templates from `wiki/economics/archetypes/behavioral.toml`, then instantiate per corporation from the populated `corp_presence` table.
|
||||
- Corporations must be loaded from the DB (populated by #805 and seeded by copy team work in #799/#800). Do not hardcode corporation data.
|
||||
- Each archetype has distinct price-setting behavior, trade routing preferences, and response to competitor presence. Details in `decisions/economics.md` D-175 and `wiki/economics/archetypes/behavioral.toml`.
|
||||
- The event input port (D-180) is stubbed here — define the `EconEvent` struct with all fields (`target`, `effect`, `duration`, `visibility`) and a no-op handler. The port is not exercised until Phase 3, but must compile.
|
||||
- All 7 signals from D-181 must be produced per active node: `price_current`, `price_trend`, `trade_flow_volume`, `corporate_presence`, `stockpile_weeks`, `production_vs_baseline`, `official_coverage_ratio`.
|
||||
- This ticket closes the sprint: when all four stability tests pass with corporate agents active, the Phase 2 economics simulation is functionally complete.
|
||||
- Blocked by #808 (currency layer must be in place) and #799/#800 (copy team corporation corpus must be available in DB).
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#813 (energy-over-gate schema) → consumed by #806, #808
|
||||
|
||||
#805 (corp pipeline + validation) → #806 (skeleton sim)
|
||||
→ #807 (trade flows + stability)
|
||||
→ #808 (currency zones)
|
||||
→ #809 (corporate agents)
|
||||
|
||||
#799 (Tier-2 corps, copy) ─────────────────────────────────┐
|
||||
#800 (Tier-3 pipeline, server) ────────────────────────────→ #809 (corporate agents)
|
||||
```
|
||||
|
||||
Note: #800 (Tier-3 generation pipeline) is assigned to the server team (it is a Rust binary), but its output depends on archetype taxonomy (#798, DONE) from copy. Coordinate with copy team on `wiki/economics/archetypes/` TOML format before starting #800.
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(simulation): description" --description "body" --base main --head sprint-33/server
|
||||
```
|
||||
@@ -0,0 +1,92 @@
|
||||
# Sprint 34: Pulse — Client Tasks
|
||||
|
||||
**Goal:** Close Phase 2 — wire the economics simulation into the live game, expose price history and trade flows in the implant, and make the economy observable and tweakable at runtime.
|
||||
|
||||
**Branch:** `sprint-34/client`
|
||||
**Agents:** Stig (UI), Tyre (architecture)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #785 | Add system population and GDP to star map info panel | — |
|
||||
| #824 | Economics insert panel — price history charts and GDP display | #822 (server) |
|
||||
| #825 | Economics debug console commands — event triggers and param sliders | #823 (server) |
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/economics.md` — D-181 (7-signal vocabulary — signals 1-2 are Phase 2: price_current, price_trend), D-180 (event port — the commands #825 fires)
|
||||
- `decisions/architecture.md` — D-169 (implant UI component library — compose from client/ui/implant/), D-170 (HUD visibility groups — economics panel lives in INSERT mode), D-020 (IPC — EconomySnapshot arrives in ObserverSnapshot)
|
||||
|
||||
## Notes
|
||||
|
||||
### #785 — System population and GDP to star map info panel
|
||||
|
||||
When a system is selected in the star map (`client/ui/star_map.gd`, `client/ui/star_map.tscn`), the popup built with ImplantPanel components shows: name, star type, hop distance, corridor, GTTR excerpt, bodies, adjacents. Add two new `ImplantDataRow` entries: `POPULATION` and `GDP`. Data is already in `res://data/star_map_data.json` (regenerated by `tooling/generate-star-map-data.py` from `systems.db`). Check whether population and GDP fields are present in the JSON; if not, update the generation script as part of this ticket. This is standalone — no server dependency. Good warmup ticket; complete it first.
|
||||
|
||||
### #824 — Economics insert panel
|
||||
|
||||
New implant panel: **Economics Monitor**. Lives in INSERT mode (D-170), accessible via implant navigation alongside the star map.
|
||||
|
||||
Scene: `client/ui/implant/economics_panel.tscn` + `client/ui/implant/economics_panel.gd`
|
||||
|
||||
Compose strictly from the existing component library (`client/ui/implant/`):
|
||||
- `ImplantPanel` — root container
|
||||
- `ImplantHeader` — "ECONOMICS MONITOR" title + selected system subtitle
|
||||
- `ImplantSeparator` — section dividers
|
||||
- `ImplantDataRow` — key/value rows for price and GDP data
|
||||
- `ImplantTextBlock` — top commodity summary text
|
||||
|
||||
Layout (three sections):
|
||||
1. **System selector** — searchable/scrollable list of systems (can reuse star map system data). Selecting a system triggers an `EconStateQuery` PlayerAction to the server.
|
||||
2. **Price table** — top 6 commodities for selected system, each as an `ImplantDataRow` with `price_current` and a directional trend indicator (▲ / ▼ / —) derived from `price_trend`.
|
||||
3. **GDP strip** — total economic activity for the system displayed as a single row. Update each time a new `EconomySnapshot` arrives.
|
||||
|
||||
Data flow: `snapshot_handler.gd` receives ObserverSnapshot v21. When `economy_snapshot` is present, forward to `economics_panel.gd` via a signal or direct call. The panel caches the last 20 ticks of price data per system for trend display (ring buffer in GDScript Dictionary).
|
||||
|
||||
Do NOT draw custom canvas sparklines unless time allows — `ImplantDataRow` with a trend arrow is the MVP. The price chart can be a follow-on.
|
||||
|
||||
Register the panel in `client/scripts/autoloads/hud_groups.gd` under path `implant/economics`. Add a keyboard shortcut (e.g. `E` in implant mode) and an entry in the implant navigation menu.
|
||||
|
||||
Blocked by #822 (server must expose EconomySnapshot before the panel has real data). Build the panel with placeholder data first; wire live data once #822 ships.
|
||||
|
||||
### #825 — Economics debug console commands
|
||||
|
||||
The debug console exists at `client/ui/debug_console.gd` + `client/ui/debug_console.tscn`. The console already dispatches `DebugCommandKind` variants via `PlayerAction::DebugCommand` through the IPC bridge.
|
||||
|
||||
Add three new command parsers in `_parse_command()` / `_dispatch_command()`:
|
||||
|
||||
```
|
||||
econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]
|
||||
→ InjectEconEvent { system_id, commodity_id, effect, magnitude, duration_ticks }
|
||||
|
||||
econ param <alpha|beta|friction> <value> [system_a] [system_b]
|
||||
→ SetEconParam { param, value }
|
||||
|
||||
econ inspect <system_id>
|
||||
→ GetEconState { system_id }
|
||||
```
|
||||
|
||||
`econ inspect` returns all 7 D-181 signals for the system; display in console output log as a multi-line block. `econ inject` and `econ param` print a confirmation + the server's `DebugResponsePayload.text`.
|
||||
|
||||
Update `_print_help()` to include the `econ` command family. Blocked by #823 (server must handle the variants before the client can send them meaningfully, though client-side parsing can be built in parallel).
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#785 (star map GDP) — standalone, start here
|
||||
|
||||
#822 (server IPC, Sprint 34/server) → #824 (economics insert panel)
|
||||
#823 (server debug handler, Sprint 34/server) → #825 (debug console commands)
|
||||
|
||||
#824 and #825 are parallel after their respective server blockers clear.
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
|
||||
--title "feat(ui): economics insert panel and debug console econ commands" \
|
||||
--description "Sprint 34 client work" \
|
||||
--base main --head sprint-34/client
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
# Sprint 34: Pulse — Copy Tasks
|
||||
|
||||
**Goal:** Close Phase 2 — wire the economics simulation into the live game, expose price history and trade flows in the implant, and make the economy observable and tweakable at runtime.
|
||||
|
||||
**Branch:** `sprint-34/copy`
|
||||
**Agents:** Mellanie (author), Paula (narrative)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #814 | Rail infrastructure corporation gap | — |
|
||||
| #695 | Author overheard conversations for remaining 24 zone types | — |
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/economics.md` — D-182 (TOML source of truth — all economics content lives in `wiki/economics/`), D-175 (corporation taxonomy — Tier 1/2/3 structure)
|
||||
- `decisions/content.md` — D-142 (zone-type template architecture — 31 zone types defined), D-139 (composable behavior primitives — overheard conversations are Layer 2 cultural flavor)
|
||||
|
||||
## Notes
|
||||
|
||||
### #814 — Rail infrastructure corporation gap
|
||||
|
||||
**Context:** The corporation validation pipeline (`tooling/economy-db/import_economics.py`) flagged a gap: no existing wiki corporation produces the `rail_infrastructure` commodity. This is a lore-world gap as much as a data gap — rail is the primary intra-continental transit system on inhabited worlds (see `decisions/architecture.md` D-093 for Sova Transit context, `wiki/economics/production_chains.toml` for chain definitions).
|
||||
|
||||
**Deliverable:** Either (a) assign `rail_infrastructure` production to an existing Tier 1 or Tier 2 corporation (MVG — Marvian Gravity Works — is the most plausible candidate given its Tier 1 infrastructure mandate) or (b) create a new corporation if no existing corp fits. Update:
|
||||
- `wiki/economics/corporations.toml` — add production entry
|
||||
- Corresponding wiki corporation page (if new corp: `wiki/corporations/<name>.md`)
|
||||
- Verify `make economy-db` passes after the change
|
||||
|
||||
Do not assign to a Tier 3 regional corp — rail infrastructure is a systemic commodity that should have Tier 1 or Tier 2 backing.
|
||||
|
||||
### #695 — Overheard conversations for remaining 24 zone types
|
||||
|
||||
**Context:** `server/content/global/overheard.ron` currently covers 5 of 29 zone types (~17% coverage). The remaining 24 types need 2-4 role-pair conversations each. These are the passive ambient dialogue lines that play when NPCs are overheard by the player without direct engagement (D-078).
|
||||
|
||||
**Format:** Each conversation entry in `overheard.ron` follows the existing pattern — two role slugs, a setting line (terse, 1 sentence describing where/when), and 3-5 lines of dialogue. Lines should feel naturalistic for the zone type; the NPC pair should be plausible co-workers or passers-by given the zone's economic activity.
|
||||
|
||||
**Zone types to cover:** Check `server/content/global/zone-types/` for the full list. Currently covered: the 5 types already in `overheard.ron` (verify by reading the file). Write 2-4 conversations per remaining zone type. Prioritize the zone types most likely to be visited first in a playthrough: `residential_dense`, `commercial_retail`, `transit_hub`, `office_district`, `industrial_light`.
|
||||
|
||||
**Lore anchors:** Use the wiki cultural pages (`wiki/cultures/`) for voice and slang. Zone types that map to specific planetary environments (agricultural, wilderness) should reflect the relevant culture. Avoid generic SF clichés — these lines should feel like they belong in the Reach.
|
||||
|
||||
**Volume:** 24 zone types × 3 conversations average × 4 lines each = ~288 lines total. Work zone-type by zone-type; commit partial coverage. Do not block on completing all 24 before committing.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#814 (rail corp gap) — standalone
|
||||
#695 (overheard conversations) — standalone, parallel
|
||||
```
|
||||
|
||||
Both tickets are independent and can run in parallel.
|
||||
|
||||
## PR Workflow
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
|
||||
--title "content(economics): rail corp gap and overheard conversation coverage" \
|
||||
--description "Sprint 34 copy work" \
|
||||
--base main --head sprint-34/copy
|
||||
```
|
||||
@@ -0,0 +1,115 @@
|
||||
# Sprint 34: Pulse — Joint Briefing
|
||||
|
||||
**Goal:** Close Phase 2 — wire the economics simulation into the live game, expose price history and trade flows in the implant, and make the economy observable and tweakable at runtime.
|
||||
|
||||
---
|
||||
|
||||
## Pre-Sprint: Decisions and Schema
|
||||
|
||||
No blocking pre-sprint decisions required. All economic architecture decisions (D-178 through D-188) are confirmed. #810 (event port) is the technical gate for the full chain — it must be the first ticket the server team starts.
|
||||
|
||||
| Item | Owner | Status |
|
||||
|------|-------|--------|
|
||||
| D-178: Economic Model Architecture | decisions/economics.md | Confirmed |
|
||||
| D-179: Stability Acceptance Criteria | decisions/economics.md | Confirmed |
|
||||
| D-180: Event Input Port | decisions/economics.md | Confirmed |
|
||||
| D-181: Signal Vocabulary | decisions/economics.md | Confirmed |
|
||||
| ObserverSnapshot v21 schema | Server → client | New this sprint (#822) |
|
||||
|
||||
---
|
||||
|
||||
## Sprint Ticket Map
|
||||
|
||||
### Server (sprint-34/server)
|
||||
```
|
||||
#810 Event port implementation
|
||||
→ #821 Integrate econ-sim into server tick loop
|
||||
→ #822 Expose economy state over IPC (ObserverSnapshot v21)
|
||||
→ #823 Economics debug command handler
|
||||
```
|
||||
|
||||
### Client (sprint-34/client)
|
||||
```
|
||||
#785 Star map: system population + GDP (standalone)
|
||||
#822 (server, blocker) → #824 Economics insert panel
|
||||
#823 (server, blocker) → #825 Debug console econ commands
|
||||
```
|
||||
|
||||
### Copy (sprint-34/copy)
|
||||
```
|
||||
#814 Rail infrastructure corporation gap (standalone)
|
||||
#695 Overheard conversations — 24 zone types (standalone, parallel)
|
||||
```
|
||||
|
||||
### Planning (sprint-34/planning)
|
||||
```
|
||||
#811 Brand layer design (early sprint)
|
||||
#748 Phase 3 breakdown workshop (after server tickets in_progress)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-Team Integration Points
|
||||
|
||||
**ObserverSnapshot v21 (server → client)**
|
||||
- Server: `server/src/bridge/types.rs` — add `EconomySnapshot` struct, bump `PROTOCOL_VERSION` to 21
|
||||
- Client: `client/scripts/snapshot_handler.gd` — parse `economy_snapshot` field, route to economics panel
|
||||
- Coordination: Server team defines the struct; client team consumes it. Server team ships #822 first; client team builds #824 with placeholder data in the meantime.
|
||||
|
||||
**Debug command flow (both teams)**
|
||||
- Server: `server/src/bridge/types.rs` — add `InjectEconEvent`, `SetEconParam`, `GetEconState` to `DebugCommandKind`
|
||||
- Client: `client/ui/debug_console.gd` — add `econ inject`, `econ param`, `econ inspect` command parsers
|
||||
- Coordination: Server team ships #823 before client team wires #825. Client team can build command parsing and help text independently; just gate the send path on #823 being merged.
|
||||
|
||||
**Star map GDP (#785)**
|
||||
- Client-only ticket. Check whether `res://data/star_map_data.json` already includes `population` and `gdp` fields. If not, update `tooling/generate-star-map-data.py` to include them from `server/data/systems.db`. This is a self-contained warmup — complete before #824.
|
||||
|
||||
---
|
||||
|
||||
## Cascade Enforcement
|
||||
|
||||
**No Phase 4 tickets.** This sprint closes Phase 2 and initiates Phase 3 planning via #748 and #811. The following are explicitly out of scope and must not be started, designed, or discussed:
|
||||
|
||||
- Character creation (#618, #619, #694, #606)
|
||||
- Tycoon starting states (#615)
|
||||
- Bookmark system (#614)
|
||||
- NPC personality surface area (#621)
|
||||
- Apartment generator (#617, #681)
|
||||
- Any ticket under Phase 4 epic #749
|
||||
|
||||
Phase 4 cannot start until Phase 3 delivers (Atlas of the Reach). Phase 3 planning workshop (#748) runs this sprint — but Phase 3 implementation tickets do not start until Sprint 35+.
|
||||
|
||||
---
|
||||
|
||||
## Sprint Completion Proof
|
||||
|
||||
The sprint is done when a developer can do all three of the following in the running game:
|
||||
|
||||
1. **Open the implant economics panel** — select any system, see live price data (price_current and price_trend for at least 6 commodities) updating in real time as the economy runs.
|
||||
2. **Trigger a supply shock from the debug console** — type `econ inject <system_id> shock 0.5 200`, observe the price_current values shift in the economics panel within a few ticks, then recover toward equilibrium over the next 200 ticks.
|
||||
3. **Mutate α from the debug console** — type `econ param alpha 0.01`, observe slower price adjustment in the panel; type `econ param alpha 0.06`, observe faster adjustment.
|
||||
|
||||
None of the above require a character. The economics panel and debug console can be exercised from the main game loop without entering the simulation world — they operate via the IPC bridge on whatever player session is active.
|
||||
|
||||
---
|
||||
|
||||
## Test Plan
|
||||
|
||||
**Phase alignment:** Sprint 34 is Phase 2 delivery infrastructure. Test focus is economic state correctness over IPC, not simulation model correctness (that was Sprint 33 / D-179 stability tests).
|
||||
|
||||
| Test | Tier | Owner |
|
||||
|------|------|-------|
|
||||
| ObserverSnapshot v21 roundtrip — serialize/deserialize EconomySnapshot | Tier 1 (fixture) | Server |
|
||||
| EconStateQuery → economy_snapshot present in next snapshot | Tier 2 (bridge) | Server |
|
||||
| `econ inject` → DebugResponsePayload.success true + price shift observable | Tier 2 (bridge) | Server |
|
||||
| Economics insert panel renders with fixture EconomySnapshot | Tier 3 (client live) | Client |
|
||||
| Debug console parses `econ inject` without error | Tier 3 (client live) | Client |
|
||||
| Star map popup shows population + GDP fields | Tier 3 (client live) | Client |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
None blocking implementation. One design question in-flight:
|
||||
|
||||
- **Q: Brand layer architecture** (#811 planning) — not blocking Sprint 34 implementation work. Resolved by planning team this sprint; produces Phase 3 tickets for Sprint 35.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Sprint 34: Pulse — Planning Tasks
|
||||
|
||||
**Goal:** Close Phase 2 — wire the economics simulation into the live game, expose price history and trade flows in the implant, and make the economy observable and tweakable at runtime.
|
||||
|
||||
**Branch:** `sprint-34/planning`
|
||||
**Agents:** Gestalt (systems), Burnelli-Sheldon (economics), Tyre (technical), Miri (worldbuilding), Qatux (documenter), SI (project manager)
|
||||
|
||||
## Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #748 | Phase 3: Planetary/moon maps and station layouts — Atlas of the Reach | #747 (in progress → closes this sprint) |
|
||||
| #811 | Brand layer design | — |
|
||||
|
||||
## Context to Read Before Discussion
|
||||
|
||||
For **#811 (Brand layer design):**
|
||||
- `decisions/economics.md` — D-185 (Brands Are Not Commodities), D-184 (Commodity Catalog — what exists), D-173 (Commodity Taxonomy — three-tier structure)
|
||||
- `decisions/scope.md` — D-131 (broad economic verb vocabulary), D-118 (small business owner starting state — brands are the Phase 3 player layer)
|
||||
- `wiki/economics/commodities.toml`, `wiki/economics/production_chains.toml`
|
||||
|
||||
For **#748 (Phase 3 breakdown):**
|
||||
- `CLAUDE.md` — Development cascade table (Phase 3 = Planetary/moon maps, deliverable = Atlas of the Reach)
|
||||
- `decisions/architecture.md` — D-093 (Sova Transit District spatial layout), D-094 (district spatial hierarchy), D-095 (Horizon stations and gate infrastructure)
|
||||
- Sprint 33 deliverable: `server/data/systems.db` populated with 300+ systems, gate links, currency zones
|
||||
- `docs/atlas/` — existing atlas content
|
||||
|
||||
---
|
||||
|
||||
## #811 — Brand Layer Design
|
||||
|
||||
**Type:** Planning discussion — produces a D-record in `decisions/economics.md`
|
||||
|
||||
**What this is:** The brand/luxury goods system sits on top of the commodity layer (D-185 confirms brands are NOT commodities). Brands consume commodities as inputs. Brand pricing is driven by cultural/emotional/want mechanics, not tâtonnement. This design work answers the Phase 3 question: how does a player engage with the economy as a participant (producer/trader/brand-builder) rather than an observer?
|
||||
|
||||
**Discussion rounds:**
|
||||
|
||||
**Round 1 — Inventory (what exists, what is missing)**
|
||||
- What is the full design space of "brand" in the Reach? (Gestalt, Miri)
|
||||
- What D-records already constrain brand design? (Tyre reads economics.md, scope.md)
|
||||
- What is the player's economic verb set when brands exist? (Burnelli-Sheldon, Gestalt)
|
||||
|
||||
**Round 2 — Proposals**
|
||||
- Brand representation: is a brand a DB entity, a modifier on a commodity, or a separate production chain layer? (Tyre, Burnelli-Sheldon)
|
||||
- Cultural pricing model: how does a brand's cultural origin affect demand across currency zones? (Miri, Gestalt)
|
||||
- Player access: what verbs does a player have toward an existing brand vs. founding one? (Gestalt)
|
||||
|
||||
**Round 3 — Convergence**
|
||||
- Draft one D-record covering: brand representation in the data model, cultural demand pricing, player access verbs, and the boundary with Phase 2 commodity tâtonnement
|
||||
- SI creates follow-up implementation tickets for Phase 3 sprint
|
||||
|
||||
**Output:** D-NNN in `decisions/economics.md` (claim ID via `tooling/db/decision claim D economics "Brand layer architecture"`). Qatux files the record. SI creates 2-4 Phase 3 implementation tickets from the decision.
|
||||
|
||||
**CONSTRAINT:** This design session covers brand layer architecture only. No character creation, no tycoon states, no apartment generators. Phase 4 work is out of scope until Phase 3 delivers.
|
||||
|
||||
---
|
||||
|
||||
## #748 — Phase 3 Planetary Maps Breakdown Workshop
|
||||
|
||||
**Type:** Planning discussion — produces a sprint-ready ticket breakdown for Phase 3
|
||||
|
||||
**What this is:** Phase 3 deliverable is the Atlas of the Reach (implant app) — region-level maps at hundreds-of-km scale. Cities, rivers, mountains, rail lines, gate/portal locations, road hierarchy, named areas. This workshop answers: what is the minimal scope for a shippable Phase 3, and what tickets does it generate?
|
||||
|
||||
**Timing:** This discussion runs AFTER the server team confirms Phase 2 is closing (economics in-game, IPC bridge live). Do not start this discussion until Sprint 34 server tickets are at least in_progress.
|
||||
|
||||
**Discussion rounds:**
|
||||
|
||||
**Round 1 — Inventory**
|
||||
- What does Phase 3 require that does not exist? Read `docs/atlas/`, existing world data in `server/data/systems.db`. (Miri, Tyre)
|
||||
- What systems from Phase 2 does Phase 3 build on (gate network, system data, planet_class)? (Gestalt, Tyre)
|
||||
- What is the rendering target? (Implant app panel — same component library as economics panel?) (Tyre)
|
||||
|
||||
**Round 2 — Scope definition**
|
||||
- Define the MVP Atlas: which systems get maps first? (Miri — Sova/Krenn as canonical first-system per D-036)
|
||||
- Data authoring pipeline: how are planetary maps authored? Hand-drawn overlays on procedural heightmaps? Pure procedural? (Miri, Gestalt)
|
||||
- Implant app design: what does the Atlas panel look like? Click-through from the star map? (Tyre)
|
||||
|
||||
**Round 3 — Ticket breakdown**
|
||||
- Break Phase 3 into 4-8 implementation tickets across server, client, copy, visual teams
|
||||
- Assign team and priority to each
|
||||
- SI creates the tickets and blocks them appropriately under #748
|
||||
|
||||
**Output:** 4-8 new tickets (server + client + copy + visual) with team assignments, priorities, and explicit dependencies. SI creates them immediately at round end.
|
||||
|
||||
---
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#811 (brand layer design) — run early in sprint, unblocks Phase 3 planning
|
||||
#748 (Phase 3 breakdown) — run after server Sprint 34 tickets are in_progress
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
Planning branch produces decisions and ticket updates only — no code. Commit decisions and close tickets:
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
|
||||
--title "planning(economics): brand layer design and Phase 3 breakdown" \
|
||||
--description "Sprint 34 planning work" \
|
||||
--base main --head sprint-34/planning
|
||||
```
|
||||
@@ -0,0 +1,123 @@
|
||||
# Sprint 34: Pulse — Server Tasks
|
||||
|
||||
**Goal:** Close Phase 2 — wire the economics simulation into the live game, expose price history and trade flows in the implant, and make the economy observable and tweakable at runtime.
|
||||
|
||||
**Branch:** `sprint-34/server`
|
||||
**Agents:** Dudley (simulation), Tyre (architecture)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #810 | Event input port implementation | #809 (done) |
|
||||
| #821 | Integrate econ-sim into game server tick loop | #810 |
|
||||
| #822 | Expose economy state over IPC bridge to client | #821 |
|
||||
| #823 | Economics debug command handler — event injection and parameter mutation | #821 |
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/economics.md` — D-178 (model architecture — Leontief + tâtonnement + agents), D-179 (stability criteria), D-180 (event input port — EconEvent struct and visibility modes), D-181 (7-signal vocabulary per node), D-183 (iterative dev cycle)
|
||||
- `decisions/architecture.md` — D-020 (IPC architecture — ObserverSnapshot + PlayerAction), D-031 (tick-to-time mapping — 10 ticks = 1 game-minute)
|
||||
|
||||
## Notes
|
||||
|
||||
### #810 — Event input port implementation
|
||||
|
||||
The EconEvent struct (D-180) must be added to `tooling/econ-sim/src/model.rs` or a new `events.rs` module. The port is the typed interface through which all external disruptions enter the simulation. An event carries:
|
||||
|
||||
```
|
||||
EconEvent {
|
||||
target: Node | NodeSet | Corridor | TradeRoute | Currency | Commodity,
|
||||
effect: ProductivityMultiplier | CapacityMultiplier | DemandShock | ExchangeShock,
|
||||
duration: ticks,
|
||||
visibility: Global | Proximate(hops) | Disclosed(specific_nodes) | Hidden,
|
||||
}
|
||||
```
|
||||
|
||||
Visibility modes are defined in D-180. For this sprint, only `Global` and `Proximate` need to be exercised — `Hidden` is Phase 3 territory (requires the player inspect verb). The port must accept events from: (a) the server tick loop (#821), and (b) debug commands (#823). Test: inject a supply shock, verify cascade propagates and prices recover within 200 ticks per D-179 Test 3.
|
||||
|
||||
### #821 — Integrate econ-sim into game server tick loop
|
||||
|
||||
The econ-sim is currently a standalone CLI binary at `tooling/econ-sim/`. This ticket makes it run inside the server process. Approach:
|
||||
|
||||
1. Extract the simulation logic from `tooling/econ-sim/src/main.rs` into a reusable library crate (e.g. `tooling/econ-sim/src/lib.rs` or a new `server/src/economy/` module — Tyre to decide the crate boundary).
|
||||
2. Add a `bevy_ecs` `System` that advances the economy N ticks per game tick (rate TBD — likely 1 economy tick per 10 game ticks given D-031 tick-to-time mapping).
|
||||
3. Store the current economy state as a `Resource` in bevy_ecs so downstream systems (#822, #823) can query it.
|
||||
4. Economy state must include all 7 D-181 signals per active node so the bridge can later serialize the relevant subset.
|
||||
|
||||
Key files: `server/src/simulation/ticker.rs` (where per-tick systems run), `tooling/econ-sim/src/model.rs` (simulation state), `tooling/econ-sim/src/trade.rs` (tâtonnement step). The DB at `server/data/systems.db` is already populated from Sprint 33.
|
||||
|
||||
Do NOT load the econ DB on every tick — load once at server startup into the bevy_ecs Resource.
|
||||
|
||||
### #822 — Expose economy state over IPC bridge to client
|
||||
|
||||
Extend `ObserverSnapshot` to version 21 with an `economy_snapshot` field:
|
||||
|
||||
```rust
|
||||
#[serde(default)]
|
||||
pub economy_snapshot: Option<EconomySnapshot>,
|
||||
```
|
||||
|
||||
`EconomySnapshot` carries per-system data for the client's economics panel (#824). Phase 2 deliverable is D-181 signals 1–2 only (price_current, price_trend). Struct sketch:
|
||||
|
||||
```rust
|
||||
pub struct EconomySnapshot {
|
||||
pub tick: u64,
|
||||
pub nodes: Vec<EconNodeSnapshot>,
|
||||
}
|
||||
|
||||
pub struct EconNodeSnapshot {
|
||||
pub system_id: u32,
|
||||
pub commodity_id: u32,
|
||||
pub price_current: f64,
|
||||
pub price_trend: f64, // delta over last N ticks
|
||||
}
|
||||
```
|
||||
|
||||
Add `EconStateQuery` to the `PlayerAction` enum for on-demand pulls — the client does not need economy data every tick (that would balloon snapshot size). The server responds to `EconStateQuery` by populating `economy_snapshot` on the next snapshot. Without a query, `economy_snapshot` is `None`.
|
||||
|
||||
Update `PROTOCOL_VERSION` to 21 in `server/src/bridge/types.rs`.
|
||||
|
||||
### #823 — Economics debug command handler
|
||||
|
||||
Extend `DebugCommandKind` in `server/src/bridge/types.rs` with three new variants:
|
||||
|
||||
```rust
|
||||
/// Inject an economic event into the running simulation.
|
||||
InjectEconEvent {
|
||||
system_id: u32,
|
||||
commodity_id: Option<u32>, // None = system-wide
|
||||
effect: EconDebugEffect,
|
||||
magnitude: f64,
|
||||
duration_ticks: u32,
|
||||
},
|
||||
/// Mutate a tâtonnement parameter at runtime.
|
||||
SetEconParam {
|
||||
param: EconParamKind, // Alpha | Beta | CorridorFriction { system_a, system_b }
|
||||
value: f64,
|
||||
},
|
||||
/// Return all 7 D-181 signals for a named system.
|
||||
GetEconState {
|
||||
system_id: u32,
|
||||
},
|
||||
```
|
||||
|
||||
Wire these into the existing debug command dispatch in `server/src/simulation/` (wherever `DebugCommandKind` is matched). Return results via `DebugResponsePayload.text` as a human-readable multi-line string. Blocked by #821 (economy resource must exist to query or mutate).
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#810 (event port) → #821 (server tick integration) → #822 (IPC exposure)
|
||||
→ #823 (debug command handler)
|
||||
```
|
||||
|
||||
#822 and #823 are parallel after #821 completes.
|
||||
|
||||
## PR Workflow
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
|
||||
--title "feat(simulation): economics in-game tick loop and IPC bridge" \
|
||||
--description "Sprint 34 server work" \
|
||||
--base main --head sprint-34/server
|
||||
```
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
name: The Settled Reach
|
||||
version: 0.1.32
|
||||
version: 0.1.34
|
||||
repository: settled-reach
|
||||
|
||||
|
||||
|
||||
Generated
+66
-3
@@ -226,7 +226,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"toml_edit",
|
||||
"toml_edit 0.23.10+spec-1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -570,6 +570,17 @@ version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
|
||||
|
||||
[[package]]
|
||||
name = "econ-sim"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"rand",
|
||||
"rand_chacha",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1221,6 +1232,15 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
@@ -1236,13 +1256,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.31"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
"bincode",
|
||||
"clap",
|
||||
"crossbeam-channel",
|
||||
"econ-sim",
|
||||
"pathfinding",
|
||||
"rand",
|
||||
"rand_chacha",
|
||||
@@ -1254,6 +1275,7 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"sysinfo",
|
||||
"thiserror",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
@@ -1378,6 +1400,27 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
@@ -1387,6 +1430,20 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_write",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.23.10+spec-1.0.0"
|
||||
@@ -1394,7 +1451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow",
|
||||
]
|
||||
@@ -1408,6 +1465,12 @@ dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.32"
|
||||
version = "0.1.34"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -22,6 +22,9 @@ crossbeam-channel = "0.5"
|
||||
sysinfo = "0.35"
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
toml = "0.8"
|
||||
# Economics simulation — Leontief + tâtonnement + D-180 event port (#821)
|
||||
econ-sim = { path = "../tooling/econ-sim" }
|
||||
|
||||
[features]
|
||||
default = ["gauntlet"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,11 @@ CREATE TABLE IF NOT EXISTS star_systems (
|
||||
-- Economics (D-172)
|
||||
currency_zone TEXT DEFAULT 'TRACTUS_PRIMARY', -- TRACTUS_PRIMARY | MARK_PRIMARY | MIXED
|
||||
|
||||
-- Energy-over-gate (D-186)
|
||||
-- Gate Corp energy service: on-grid nodes get ~0.3× fusion_fuel utility demand.
|
||||
-- MARK_PRIMARY zones default false (Compact refused Gate Corp dependency).
|
||||
gate_energy_connected INTEGER DEFAULT 1, -- boolean 0/1
|
||||
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,543 @@
|
||||
//! Deterministic name generation for Tier-3 corporations.
|
||||
//!
|
||||
//! Names are composed from culture-specific pools matching the Settled Reach's
|
||||
//! geographic sectors. Each sector has dominant cultural influences derived
|
||||
//! from lore (wiki settlements, founding cultures, corridor identities).
|
||||
//!
|
||||
//! Pattern: `{surname/word} {business_suffix}` where surname draws from
|
||||
//! the sector's cultural pool and suffix from the lore category.
|
||||
|
||||
use rand::prelude::*;
|
||||
use rand_chacha::ChaCha8Rng;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Surname pools by sector (drawn from founding cultures in wiki canon)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Core systems: cosmopolitan mix — the Reach's center of gravity.
|
||||
const CORE_NAMES: &[&str] = &[
|
||||
"Alvarez",
|
||||
"Benoit",
|
||||
"Carvalho",
|
||||
"Durand",
|
||||
"Eriksen",
|
||||
"Fournier",
|
||||
"Gao",
|
||||
"Hartmann",
|
||||
"Ishida",
|
||||
"Johansson",
|
||||
"Kirchner",
|
||||
"Lemaire",
|
||||
"Moreau",
|
||||
"Nakamura",
|
||||
"Olsson",
|
||||
"Pelletier",
|
||||
"Richter",
|
||||
"Saito",
|
||||
"Torres",
|
||||
"Ueda",
|
||||
"Vasquez",
|
||||
"Werner",
|
||||
"Xu",
|
||||
"Yamada",
|
||||
"Zhou",
|
||||
"Andersen",
|
||||
"Beaumont",
|
||||
"Costa",
|
||||
"Delacroix",
|
||||
"Engel",
|
||||
"Fujita",
|
||||
"Gutierrez",
|
||||
"Hayashi",
|
||||
"Ibarra",
|
||||
"Jensen",
|
||||
"Klein",
|
||||
"Laurent",
|
||||
"Mercier",
|
||||
"Novak",
|
||||
"Ortiz",
|
||||
"Park",
|
||||
"Reuter",
|
||||
"Suzuki",
|
||||
"Takahashi",
|
||||
"Ulrich",
|
||||
"Valentin",
|
||||
"Wagner",
|
||||
"Xie",
|
||||
"Yilmaz",
|
||||
"Zhang",
|
||||
];
|
||||
|
||||
/// North reach: Nordic, Scottish, northern European — Calloway heritage.
|
||||
const NORTH_REACH_NAMES: &[&str] = &[
|
||||
"Andersson",
|
||||
"Bjornsson",
|
||||
"Calloway",
|
||||
"Dalsgaard",
|
||||
"Eklund",
|
||||
"Falk",
|
||||
"Grimstad",
|
||||
"Hedlund",
|
||||
"Ivarsson",
|
||||
"Jonasson",
|
||||
"Kirkpatrick",
|
||||
"Lindqvist",
|
||||
"MacLeod",
|
||||
"Nordstrom",
|
||||
"Olafsson",
|
||||
"Pettersson",
|
||||
"Rehn",
|
||||
"Strandberg",
|
||||
"Thorsen",
|
||||
"Ulvskog",
|
||||
"Vikstrom",
|
||||
"Wahlberg",
|
||||
"Aberg",
|
||||
"Berglund",
|
||||
"Carlsen",
|
||||
"Dalgaard",
|
||||
"Engstrom",
|
||||
"Forsell",
|
||||
"Gustafsson",
|
||||
"Halvorsen",
|
||||
"Ingvarsson",
|
||||
"Jansson",
|
||||
"Knudsen",
|
||||
"Lundin",
|
||||
"MacPherson",
|
||||
"Nylund",
|
||||
"Ostergaard",
|
||||
"Palsson",
|
||||
"Rasmussen",
|
||||
"Sjoberg",
|
||||
"Toft",
|
||||
"Ulfsson",
|
||||
"Vestergaard",
|
||||
"Wiklund",
|
||||
"Aasen",
|
||||
"Brannstrom",
|
||||
"Dahl",
|
||||
"Eide",
|
||||
"Friberg",
|
||||
"Gren",
|
||||
];
|
||||
|
||||
/// South reach: Eastern European, East Asian industrial — Stalownia corridor.
|
||||
const SOUTH_REACH_NAMES: &[&str] = &[
|
||||
"Adamski",
|
||||
"Baranov",
|
||||
"Chernov",
|
||||
"Dubois",
|
||||
"Egorov",
|
||||
"Filipov",
|
||||
"Gromov",
|
||||
"Horvat",
|
||||
"Ivanova",
|
||||
"Jankovic",
|
||||
"Kowalski",
|
||||
"Lazarev",
|
||||
"Morozov",
|
||||
"Novikov",
|
||||
"Ostrowski",
|
||||
"Petrov",
|
||||
"Reznik",
|
||||
"Sokolov",
|
||||
"Tkachenko",
|
||||
"Uvarov",
|
||||
"Volkov",
|
||||
"Wojcik",
|
||||
"Yakimov",
|
||||
"Zheng",
|
||||
"Babic",
|
||||
"Chernyshev",
|
||||
"Dragunov",
|
||||
"Fedorov",
|
||||
"Grushevsky",
|
||||
"Havel",
|
||||
"Ito",
|
||||
"Jovanovic",
|
||||
"Katsaros",
|
||||
"Lebedev",
|
||||
"Mazur",
|
||||
"Nemec",
|
||||
"Ochoa",
|
||||
"Popov",
|
||||
"Radic",
|
||||
"Smirnov",
|
||||
"Tanaka",
|
||||
"Urasawa",
|
||||
"Vasiliev",
|
||||
"Watanabe",
|
||||
"Xiang",
|
||||
"Yegorov",
|
||||
"Zaytsev",
|
||||
"Borysko",
|
||||
"Chen",
|
||||
"Dimitrov",
|
||||
];
|
||||
|
||||
/// West reach: German, Central European — Compact territory, Westphalian influence.
|
||||
const WEST_REACH_NAMES: &[&str] = &[
|
||||
"Albrecht",
|
||||
"Baumann",
|
||||
"Christensen",
|
||||
"Dietrich",
|
||||
"Eisenberg",
|
||||
"Fischer",
|
||||
"Gruber",
|
||||
"Hoffmann",
|
||||
"Ingolstadt",
|
||||
"Jaeger",
|
||||
"Kessler",
|
||||
"Lehmann",
|
||||
"Mueller",
|
||||
"Neumann",
|
||||
"Obermann",
|
||||
"Pfeiffer",
|
||||
"Quandt",
|
||||
"Roth",
|
||||
"Schaefer",
|
||||
"Thiel",
|
||||
"Urban",
|
||||
"Vogt",
|
||||
"Weidenfeld",
|
||||
"Ziegler",
|
||||
"Becker",
|
||||
"Claussen",
|
||||
"Dorfmann",
|
||||
"Eberhardt",
|
||||
"Fleischer",
|
||||
"Gerstner",
|
||||
"Haber",
|
||||
"Imhof",
|
||||
"Jung",
|
||||
"Kraemer",
|
||||
"Linden",
|
||||
"Metzger",
|
||||
"Niedermann",
|
||||
"Opitz",
|
||||
"Preuss",
|
||||
"Raabe",
|
||||
"Steinbach",
|
||||
"Trautmann",
|
||||
"Unger",
|
||||
"Vollmer",
|
||||
"Winterberg",
|
||||
"Zahn",
|
||||
"Auerbach",
|
||||
"Bruckner",
|
||||
"Dahlem",
|
||||
"Eckhardt",
|
||||
];
|
||||
|
||||
/// East reach: Filipino, Korean, maritime Asian — distinctive identity.
|
||||
const EAST_REACH_NAMES: &[&str] = &[
|
||||
"Aquino",
|
||||
"Bautista",
|
||||
"Cruz",
|
||||
"Dalisay",
|
||||
"Espiritu",
|
||||
"Flores",
|
||||
"Garcia",
|
||||
"Hernandez",
|
||||
"Ilagan",
|
||||
"Jeon",
|
||||
"Kim",
|
||||
"Lim",
|
||||
"Magalang",
|
||||
"Navarro",
|
||||
"Ocampo",
|
||||
"Park",
|
||||
"Quijano",
|
||||
"Reyes",
|
||||
"Santos",
|
||||
"Tan",
|
||||
"Uy",
|
||||
"Villanueva",
|
||||
"Wong",
|
||||
"Yoo",
|
||||
"Aguilar",
|
||||
"Buenaventura",
|
||||
"Castillo",
|
||||
"Dizon",
|
||||
"Enriquez",
|
||||
"Fernandez",
|
||||
"Gonzales",
|
||||
"Hwang",
|
||||
"Ignacio",
|
||||
"Jeong",
|
||||
"Kwon",
|
||||
"Lee",
|
||||
"Marasigan",
|
||||
"Nakamura",
|
||||
"Oh",
|
||||
"Perez",
|
||||
"Ramos",
|
||||
"Son",
|
||||
"Tolentino",
|
||||
"Umali",
|
||||
"Valdez",
|
||||
"Yun",
|
||||
"Zamora",
|
||||
"Baek",
|
||||
"Choi",
|
||||
"Dela Cruz",
|
||||
];
|
||||
|
||||
/// Deep frontier: mixed backgrounds from all settler waves — no dominant culture.
|
||||
const FRONTIER_NAMES: &[&str] = &[
|
||||
"Adeyemi",
|
||||
"Bergstrom",
|
||||
"Chandra",
|
||||
"Duval",
|
||||
"Emeka",
|
||||
"Fonseca",
|
||||
"Gupta",
|
||||
"Hassan",
|
||||
"Ibrahim",
|
||||
"Jansson",
|
||||
"Kovac",
|
||||
"Liu",
|
||||
"Martinez",
|
||||
"Nkosi",
|
||||
"Okafor",
|
||||
"Patel",
|
||||
"Quinn",
|
||||
"Rodriguez",
|
||||
"Sousa",
|
||||
"Thorne",
|
||||
"Uddin",
|
||||
"Varga",
|
||||
"Wu",
|
||||
"Xiong",
|
||||
"Yoshida",
|
||||
"Zhao",
|
||||
"Abara",
|
||||
"Beaumont",
|
||||
"Cardenas",
|
||||
"Doyle",
|
||||
"Ekwueme",
|
||||
"Ferreira",
|
||||
"Gomes",
|
||||
"Henriksen",
|
||||
"Idris",
|
||||
"Juma",
|
||||
"Kato",
|
||||
"Larsen",
|
||||
"Morales",
|
||||
"Ndlovu",
|
||||
"Osei",
|
||||
"Petrov",
|
||||
"Ruiz",
|
||||
"Singh",
|
||||
"Tavares",
|
||||
"Uchida",
|
||||
"Volkov",
|
||||
"Wang",
|
||||
"Yang",
|
||||
"Zaman",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Business suffix pools by lore category
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EXTRACTION_SUFFIXES: &[&str] = &[
|
||||
"Mining Co.",
|
||||
"Extraction",
|
||||
"Resources",
|
||||
"Minerals",
|
||||
"Mining",
|
||||
"Quarry Works",
|
||||
"Deep Drill",
|
||||
"Ore Works",
|
||||
"Claims",
|
||||
"Mining & Salvage",
|
||||
"Prospecting",
|
||||
"Dig Co.",
|
||||
"Rock Works",
|
||||
"Shaft Mining",
|
||||
"Surface Mining",
|
||||
];
|
||||
|
||||
const AGRICULTURE_SUFFIXES: &[&str] = &[
|
||||
"Farms",
|
||||
"Agricultural Co.",
|
||||
"Growers",
|
||||
"Harvest",
|
||||
"Provisions",
|
||||
"Ranchers",
|
||||
"Fisheries",
|
||||
"Food Co.",
|
||||
"Plantations",
|
||||
"Cultivators",
|
||||
"Produce",
|
||||
"Orchard",
|
||||
"Dairy",
|
||||
"Stockfeed",
|
||||
"Processing",
|
||||
];
|
||||
|
||||
const MANUFACTURING_SUFFIXES: &[&str] = &[
|
||||
"Manufacturing",
|
||||
"Works",
|
||||
"Industries",
|
||||
"Fabrication",
|
||||
"Engineering",
|
||||
"Precision",
|
||||
"Assembly",
|
||||
"Components",
|
||||
"Foundry",
|
||||
"Machine Works",
|
||||
"Systems",
|
||||
"Technical",
|
||||
"Metalworks",
|
||||
"Forging",
|
||||
"Production",
|
||||
];
|
||||
|
||||
const TRADE_LOGISTICS_SUFFIXES: &[&str] = &[
|
||||
"Freight",
|
||||
"Logistics",
|
||||
"Shipping",
|
||||
"Transport",
|
||||
"Haulage",
|
||||
"Cargo",
|
||||
"Transit",
|
||||
"Distribution",
|
||||
"Forwarding",
|
||||
"Express",
|
||||
"Lines",
|
||||
"Carriers",
|
||||
"Fleet",
|
||||
"Couriers",
|
||||
"Supply Co.",
|
||||
];
|
||||
|
||||
const SERVICES_SUFFIXES: &[&str] = &[
|
||||
"Services",
|
||||
"Associates",
|
||||
"Consulting",
|
||||
"Partners",
|
||||
"Group",
|
||||
"Holdings",
|
||||
"Clinic",
|
||||
"Bureau",
|
||||
"Agency",
|
||||
"Office",
|
||||
"Practice",
|
||||
"Solutions",
|
||||
"Advisors",
|
||||
"Trust",
|
||||
"Institute",
|
||||
];
|
||||
|
||||
const INTELLIGENCE_SUFFIXES: &[&str] = &[
|
||||
"Analytics",
|
||||
"Intelligence",
|
||||
"Data Services",
|
||||
"Information",
|
||||
"Research",
|
||||
"Advisory",
|
||||
"Insights",
|
||||
"Consulting",
|
||||
"Networks",
|
||||
"Analysis",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Name generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn names_for_sector(sector: &str) -> &'static [&'static str] {
|
||||
match sector {
|
||||
"core" => CORE_NAMES,
|
||||
"north_reach" => NORTH_REACH_NAMES,
|
||||
"south_reach" => SOUTH_REACH_NAMES,
|
||||
"west_reach" => WEST_REACH_NAMES,
|
||||
"east_reach" => EAST_REACH_NAMES,
|
||||
"deep_frontier" => FRONTIER_NAMES,
|
||||
_ => CORE_NAMES,
|
||||
}
|
||||
}
|
||||
|
||||
fn suffixes_for_category(category: &str) -> &'static [&'static str] {
|
||||
match category {
|
||||
"extraction" => EXTRACTION_SUFFIXES,
|
||||
"agriculture" => AGRICULTURE_SUFFIXES,
|
||||
"manufacturing" => MANUFACTURING_SUFFIXES,
|
||||
"trade_logistics" => TRADE_LOGISTICS_SUFFIXES,
|
||||
"services" => SERVICES_SUFFIXES,
|
||||
"intelligence" => INTELLIGENCE_SUFFIXES,
|
||||
_ => SERVICES_SUFFIXES,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a plausible business name for the given sector and lore category.
|
||||
/// Deterministic for a given RNG state.
|
||||
pub fn generate_name(rng: &mut ChaCha8Rng, sector: &str, category: &str) -> String {
|
||||
let names = names_for_sector(sector);
|
||||
let suffixes = suffixes_for_category(category);
|
||||
|
||||
let surname = names[rng.random_range(0..names.len())];
|
||||
let suffix = suffixes[rng.random_range(0..suffixes.len())];
|
||||
|
||||
// 20% chance of double-barrel name (Surname & Surname Suffix)
|
||||
if rng.random::<f64>() < 0.20 {
|
||||
let surname2 = names[rng.random_range(0..names.len())];
|
||||
if surname != surname2 {
|
||||
return format!("{} & {} {}", surname, surname2, suffix);
|
||||
}
|
||||
}
|
||||
|
||||
// 15% chance of "Surname's Suffix" or "Surname Bros. Suffix"
|
||||
if rng.random::<f64>() < 0.15 {
|
||||
return format!("{} Bros. {}", surname, suffix);
|
||||
}
|
||||
|
||||
format!("{} {}", surname, suffix)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rand::SeedableRng;
|
||||
|
||||
#[test]
|
||||
fn deterministic_names() {
|
||||
let mut rng1 = ChaCha8Rng::seed_from_u64(42);
|
||||
let mut rng2 = ChaCha8Rng::seed_from_u64(42);
|
||||
|
||||
for _ in 0..100 {
|
||||
let a = generate_name(&mut rng1, "core", "extraction");
|
||||
let b = generate_name(&mut rng2, "core", "extraction");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_not_empty() {
|
||||
let mut rng = ChaCha8Rng::seed_from_u64(1);
|
||||
for sector in &[
|
||||
"core",
|
||||
"north_reach",
|
||||
"south_reach",
|
||||
"west_reach",
|
||||
"east_reach",
|
||||
"deep_frontier",
|
||||
] {
|
||||
for cat in &[
|
||||
"extraction",
|
||||
"agriculture",
|
||||
"manufacturing",
|
||||
"trade_logistics",
|
||||
"services",
|
||||
"intelligence",
|
||||
] {
|
||||
let name = generate_name(&mut rng, sector, cat);
|
||||
assert!(!name.is_empty(), "Empty name for {}/{}", sector, cat);
|
||||
assert!(name.contains(' '), "No space in name: {}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
-1
@@ -8,10 +8,14 @@
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::bridge::types::{DebugCommandKind, DebugEnabled, DebugResponsePayload, SnapshotBuffer};
|
||||
use crate::bridge::types::{
|
||||
DebugCommandKind, DebugEnabled, DebugResponsePayload, EconDebugEffect, EconParamKind,
|
||||
SnapshotBuffer,
|
||||
};
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::conversation::NpcName;
|
||||
use crate::simulation::economy::{EconSimResource, EconStateResource};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
@@ -69,6 +73,8 @@ pub fn handle_debug_commands(
|
||||
(Entity, &TilePosition, Option<&NpcName>),
|
||||
(With<Npc>, With<ActiveSim>, Without<PlayerCharacter>),
|
||||
>,
|
||||
mut econ_sim: Option<ResMut<EconSimResource>>,
|
||||
econ_state: Option<Res<EconStateResource>>,
|
||||
) {
|
||||
// Gate: debug must be enabled
|
||||
let enabled = debug_enabled.as_ref().is_some_and(|d| d.0);
|
||||
@@ -325,6 +331,135 @@ pub fn handle_debug_commands(
|
||||
}
|
||||
}
|
||||
}
|
||||
DebugCommandKind::InjectEconEvent {
|
||||
ref target,
|
||||
ref effect,
|
||||
magnitude,
|
||||
duration_ticks,
|
||||
} => {
|
||||
use econ_sim::events::{
|
||||
EconEvent, EconEventEffect, EconEventTarget, EconEventVisibility,
|
||||
};
|
||||
if let Some(ref mut sim) = econ_sim {
|
||||
let econ_effect = match effect {
|
||||
EconDebugEffect::CapacityMultiplier => {
|
||||
EconEventEffect::CapacityMultiplier(magnitude)
|
||||
}
|
||||
EconDebugEffect::ProductivityMultiplier => {
|
||||
EconEventEffect::ProductivityMultiplier(magnitude)
|
||||
}
|
||||
EconDebugEffect::DemandShock => EconEventEffect::DemandShock(magnitude),
|
||||
EconDebugEffect::ExchangeShock => {
|
||||
EconEventEffect::ExchangeShock(magnitude)
|
||||
}
|
||||
};
|
||||
sim.sim.events.push(EconEvent {
|
||||
target: EconEventTarget::Node(target.clone()),
|
||||
effect: econ_effect,
|
||||
duration: duration_ticks,
|
||||
visibility: EconEventVisibility::Global,
|
||||
});
|
||||
DebugResponsePayload {
|
||||
command: format!("InjectEconEvent({}, {:?}, {}×{})", target, effect, magnitude, duration_ticks),
|
||||
text: format!(
|
||||
"Event injected: {:?} ×{} on node '{}' for {} ticks.\nTakes effect on next economy tick.",
|
||||
effect, magnitude, target, duration_ticks
|
||||
),
|
||||
success: true,
|
||||
}
|
||||
} else {
|
||||
DebugResponsePayload {
|
||||
command: "InjectEconEvent".to_string(),
|
||||
text: "Economy simulation not loaded.".to_string(),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
DebugCommandKind::SetEconParam { ref param, value } => {
|
||||
if let Some(ref mut sim) = econ_sim {
|
||||
match param {
|
||||
EconParamKind::TatonnementStep => {
|
||||
let old = sim.sim.alpha;
|
||||
sim.sim.alpha = value;
|
||||
DebugResponsePayload {
|
||||
command: format!("SetEconParam(TatonnementStep, {})", value),
|
||||
text: format!("α (tâtonnement step): {} → {}", old, value),
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
EconParamKind::DampingFactor => {
|
||||
let old = sim.sim.beta;
|
||||
sim.sim.beta = value;
|
||||
DebugResponsePayload {
|
||||
command: format!("SetEconParam(DampingFactor, {})", value),
|
||||
text: format!("β (damping factor): {} → {}", old, value),
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
EconParamKind::CorridorFriction { ref corridor_id } => {
|
||||
DebugResponsePayload {
|
||||
command: format!("SetEconParam(CorridorFriction({}))", corridor_id),
|
||||
text: "Per-corridor friction override not yet implemented (requires corridor friction model in trade.rs).".to_string(),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DebugResponsePayload {
|
||||
command: "SetEconParam".to_string(),
|
||||
text: "Economy simulation not loaded.".to_string(),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
DebugCommandKind::GetEconState { ref system_id } => {
|
||||
if let Some(ref state) = econ_state {
|
||||
let signals: Vec<_> = state
|
||||
.signals
|
||||
.iter()
|
||||
.filter(|((sys, _), _)| sys == system_id)
|
||||
.collect();
|
||||
if signals.is_empty() {
|
||||
DebugResponsePayload {
|
||||
command: format!("GetEconState({})", system_id),
|
||||
text: format!("System '{}' not found in economy state.", system_id),
|
||||
success: false,
|
||||
}
|
||||
} else {
|
||||
let mut lines = vec![
|
||||
format!(
|
||||
"=== Economy state for '{}' (econ_tick={}) ===",
|
||||
system_id, state.econ_tick
|
||||
),
|
||||
format!(" FX rate (Tractus/Mark): {:.4}", state.tractus_mark_rate),
|
||||
];
|
||||
for ((_, commodity_id), sig) in &signals {
|
||||
lines.push(format!(
|
||||
" {} | price={:.2} trend={:+.2} flow={:.1} corps={} stockpile_wks={:.1} prod_vs_base={:.3} coverage={:.2}",
|
||||
commodity_id,
|
||||
sig.price_current,
|
||||
sig.price_trend,
|
||||
sig.trade_flow_volume,
|
||||
sig.corporate_presence,
|
||||
sig.stockpile_weeks,
|
||||
sig.production_vs_baseline,
|
||||
sig.official_coverage_ratio,
|
||||
));
|
||||
}
|
||||
DebugResponsePayload {
|
||||
command: format!("GetEconState({})", system_id),
|
||||
text: lines.join("\n"),
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DebugResponsePayload {
|
||||
command: format!("GetEconState({})", system_id),
|
||||
text: "Economy simulation not loaded.".to_string(),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -227,6 +227,7 @@ impl Plugin for BridgePlugin {
|
||||
receive_bridge_inputs.before(crate::simulation::input::process_player_input),
|
||||
debug::handle_debug_commands
|
||||
.after(crate::simulation::input::process_player_input)
|
||||
.after(crate::simulation::economy::tick_economy_simulation)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
crate::perception::observer::compute_visibility_geometry
|
||||
.after(crate::simulation::movement::validate_movement),
|
||||
|
||||
@@ -320,6 +320,7 @@ mod tests {
|
||||
debug_response: None,
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
economy_snapshot: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,6 +462,7 @@ mod tests {
|
||||
debug_response: None,
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
economy_snapshot: None,
|
||||
};
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(text.contains("Tick 0"));
|
||||
|
||||
@@ -17,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
|
||||
/// negotiation is unnecessary. Client should reject snapshots with version !=
|
||||
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
|
||||
/// period, then the default is removed once both sides are updated.
|
||||
pub const PROTOCOL_VERSION: u8 = 20;
|
||||
pub const PROTOCOL_VERSION: u8 = 21;
|
||||
|
||||
/// Handshake message sent as the very first framed message after connection (#555).
|
||||
/// Client reads this before entering the normal tick loop and validates
|
||||
@@ -81,6 +81,8 @@ pub struct StartupMessage {
|
||||
/// v18 adds: debug_response (#580, debug console server — command/response wire).
|
||||
/// v19 adds: character_archetype on StartupMessage (#587), current_ticker (#591).
|
||||
/// v20 adds: settings_response (#627, SQLite settings IPC).
|
||||
/// v21 adds: economy_snapshot (#822, D-181 7-signal snapshot per queried system),
|
||||
/// EconStateQuery PlayerAction variant (#822).
|
||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
@@ -216,6 +218,12 @@ pub struct ObserverSnapshot {
|
||||
/// Client reads to confirm setting changes or to populate the settings UI.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub settings_response: Option<crate::settings::types::SettingsResponseWire>,
|
||||
/// Economy snapshot (#822, D-181 7-signal snapshot).
|
||||
/// Present for exactly one tick after an `EconStateQuery` is processed.
|
||||
/// Contains all 7 D-181 signals for each commodity in the queried system.
|
||||
/// None during normal gameplay; client queries explicitly via `EconStateQuery`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub economy_snapshot: Option<EconomySnapshot>,
|
||||
}
|
||||
|
||||
/// A single news ticker headline crossing the wire boundary (#591).
|
||||
@@ -550,6 +558,12 @@ pub enum PlayerAction {
|
||||
DeleteSetting {
|
||||
key: String,
|
||||
},
|
||||
/// Query economy state for a named system (#822, D-181).
|
||||
/// Server responds with `ObserverSnapshot.economy_snapshot` for one tick.
|
||||
/// Absent when economy is not loaded or `system_id` is unknown.
|
||||
EconStateQuery {
|
||||
system_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl PlayerAction {
|
||||
@@ -595,6 +609,21 @@ pub enum DebugCommandKind {
|
||||
ListPopulation,
|
||||
/// Return `ContaminationActive` status and current tick.
|
||||
GetContaminationStatus,
|
||||
/// Inject a D-180 economic event into the running simulation (#823).
|
||||
/// The event fires at the next economy tick and lasts for `duration_ticks`.
|
||||
/// `target` is a system_id (node-level events only in v0.1).
|
||||
InjectEconEvent {
|
||||
target: String,
|
||||
effect: EconDebugEffect,
|
||||
magnitude: f64,
|
||||
duration_ticks: u32,
|
||||
},
|
||||
/// Mutate a simulation parameter at runtime (#823, D-178).
|
||||
/// Changes take effect on the next `Simulation::step()` call.
|
||||
SetEconParam { param: EconParamKind, value: f64 },
|
||||
/// Return all 7 D-181 signals for the named system (#823).
|
||||
/// Equivalent to `EconStateQuery` but via the debug console.
|
||||
GetEconState { system_id: String },
|
||||
}
|
||||
|
||||
/// Debug response payload included in `ObserverSnapshot` (#580).
|
||||
@@ -612,6 +641,72 @@ pub struct DebugResponsePayload {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
/// Wire type for a single commodity's 7 D-181 signals at a node (#822).
|
||||
///
|
||||
/// Compact snapshot used in `EconomySnapshot.nodes`. Mirrors `EconNodeSignals`
|
||||
/// in `simulation::economy` but is Serializable for wire transmission.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EconNodeSnapshot {
|
||||
pub commodity_id: String,
|
||||
/// Signal 1: current market price in Tractus (Public).
|
||||
pub price_current: f64,
|
||||
/// Signal 2: price delta over last TREND_WINDOW economy ticks (Public).
|
||||
pub price_trend: f64,
|
||||
/// Signal 3: trade flow volume proxy (Observable).
|
||||
pub trade_flow_volume: f64,
|
||||
/// Signal 4: number of corporations at this node (Observable).
|
||||
pub corporate_presence: u32,
|
||||
/// Signal 5: stockpile in weeks at current demand rate (Semi-private).
|
||||
pub stockpile_weeks: f64,
|
||||
/// Signal 6: supply vs. baseline supply from first tick (Private).
|
||||
pub production_vs_baseline: f64,
|
||||
/// Signal 7: ratio of formal to total activity (Meta-signal).
|
||||
pub official_coverage_ratio: f64,
|
||||
}
|
||||
|
||||
/// Wire type for economy state snapshot (#822, D-181).
|
||||
///
|
||||
/// Returned in `ObserverSnapshot.economy_snapshot` for one tick after an
|
||||
/// `EconStateQuery` is processed. Contains signals for all commodities in the
|
||||
/// queried system. None when economy is not loaded or system_id is unknown.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EconomySnapshot {
|
||||
/// The system this snapshot covers.
|
||||
pub system_id: String,
|
||||
/// Economy tick at which this snapshot was produced.
|
||||
pub econ_tick: u64,
|
||||
/// Current Tractus/Mark exchange rate (1.0 = parity).
|
||||
pub tractus_mark_rate: f64,
|
||||
/// Signals for each commodity active in this system.
|
||||
pub nodes: Vec<EconNodeSnapshot>,
|
||||
}
|
||||
|
||||
/// Effect type for `InjectEconEvent` debug command (#823, D-180).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum EconDebugEffect {
|
||||
/// Multiply production capacity of the target node by `magnitude`.
|
||||
/// < 1.0 = capacity shock; > 1.0 = capacity boost.
|
||||
CapacityMultiplier,
|
||||
/// Multiply productivity of all operations at the target node by `magnitude`.
|
||||
ProductivityMultiplier,
|
||||
/// Add `magnitude` to demand for all commodities at the target node.
|
||||
DemandShock,
|
||||
/// Apply a one-time exchange rate shock of `magnitude` to the FX rate.
|
||||
ExchangeShock,
|
||||
}
|
||||
|
||||
/// Parameter selector for `SetEconParam` debug command (#823, D-178).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum EconParamKind {
|
||||
/// Tâtonnement step size (α, D-178 Layer 2). Default: 0.03.
|
||||
TatonnementStep,
|
||||
/// Trade flow damping factor (β, D-178). Default: 0.4.
|
||||
DampingFactor,
|
||||
/// Per-corridor friction override (not yet implemented in simulation).
|
||||
#[allow(dead_code)] // Used by future corridor friction model (#TODO)
|
||||
CorridorFriction { corridor_id: String },
|
||||
}
|
||||
|
||||
/// Whether the debug console is enabled (#580).
|
||||
///
|
||||
/// Set at server startup. Cannot be toggled mid-session via IPC.
|
||||
@@ -941,6 +1036,8 @@ pub struct SnapshotBuffer {
|
||||
pub pending_debug_response: Option<DebugResponsePayload>,
|
||||
/// Pending settings response, consumed once by `compute_observer_snapshot` (#627).
|
||||
pub pending_settings_response: Option<crate::settings::types::SettingsResponseWire>,
|
||||
/// Pending economy snapshot, consumed once by `compute_observer_snapshot` (#822).
|
||||
pub pending_economy_response: Option<EconomySnapshot>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -328,6 +328,7 @@ fn send_panic_error(app: &App, panic_msg: &str) {
|
||||
debug_response: None,
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
economy_snapshot: None,
|
||||
sim_errors: vec![SimError {
|
||||
kind: SimErrorKind::Panic,
|
||||
message: format!("Simulation panic: {}", panic_msg),
|
||||
|
||||
@@ -394,6 +394,9 @@ pub fn compute_observer_snapshot(
|
||||
None
|
||||
};
|
||||
|
||||
// Consume pending economy snapshot for this tick (#822).
|
||||
let economy_snapshot = buffer.pending_economy_response.take();
|
||||
|
||||
// Consume pending save/load result for this tick (#553).
|
||||
let save_result = buffer.pending_save_result.take();
|
||||
|
||||
@@ -489,6 +492,7 @@ pub fn compute_observer_snapshot(
|
||||
debug_response,
|
||||
current_ticker,
|
||||
settings_response,
|
||||
economy_snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
//! Economy simulation integration — runs econ-sim inside the server tick loop.
|
||||
//!
|
||||
//! Bridges the standalone `econ_sim` library into the Bevy ECS tick loop.
|
||||
//! The simulation advances one economy tick every `ECON_TICK_RATE` game ticks.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! - [`EconSimResource`] — holds the running `Simulation` + price history for trends.
|
||||
//! Loaded once at startup from `server/data/systems.db`. Never reloaded mid-session.
|
||||
//!
|
||||
//! - [`EconStateResource`] — all 7 D-181 signals per active (system_id, commodity_id).
|
||||
//! Updated every `ECON_TICK_RATE` game ticks by `tick_economy_simulation`.
|
||||
//! Queryable by the IPC bridge (#822) and debug commands (#823).
|
||||
//!
|
||||
//! - `tick_economy_simulation` — bevy System registered in `SimulationPlugin`.
|
||||
//! Advances one economy tick, then rebuilds `EconStateResource`.
|
||||
//!
|
||||
//! ## Rate (D-031)
|
||||
//!
|
||||
//! `ECON_TICK_RATE = 10` game ticks per economy tick.
|
||||
//! At 10 game ticks/game-minute (D-031), this means the economy advances once
|
||||
//! per game-minute — a reasonable granularity for macro-scale price dynamics.
|
||||
//!
|
||||
//! ## D-181 signals
|
||||
//!
|
||||
//! 1. `price_current` — current market price (Public)
|
||||
//! 2. `price_trend` — Δprice over the last `TREND_WINDOW` economy ticks (Public)
|
||||
//! 3. `trade_flow_volume` — supply volume proxy (Observable; Phase 3 will refine)
|
||||
//! 4. `corporate_presence` — corp count at this node (Observable)
|
||||
//! 5. `stockpile_weeks` — stockpile ÷ weekly demand rate (Semi-private)
|
||||
//! 6. `production_vs_baseline` — supply ÷ initial baseline supply (Private)
|
||||
//! 7. `official_coverage_ratio` — 1 − shadow_intensity (Meta-signal)
|
||||
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use econ_sim::Simulation;
|
||||
|
||||
use crate::bridge::types::{EconNodeSnapshot, EconomySnapshot, SnapshotBuffer};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Game ticks between each economy tick (D-031: 10 ticks/game-minute).
|
||||
pub const ECON_TICK_RATE: u64 = 10;
|
||||
|
||||
/// Number of economy ticks to average for price trend signal 2 (D-181).
|
||||
const TREND_WINDOW: usize = 5;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The running economics simulation (loaded once at startup).
|
||||
///
|
||||
/// Never reinitialize mid-session — the economy state is continuous.
|
||||
#[derive(Resource)]
|
||||
pub struct EconSimResource {
|
||||
pub sim: Simulation,
|
||||
/// Price history for signal 2 (price_trend) computation.
|
||||
/// Ring-buffer keyed by (system_id, commodity_id) → last TREND_WINDOW prices.
|
||||
price_history: BTreeMap<(String, String), VecDeque<f64>>,
|
||||
/// Baseline supply from first economy tick for signal 6 (production_vs_baseline).
|
||||
baseline_supply: BTreeMap<(String, String), f64>,
|
||||
}
|
||||
|
||||
impl EconSimResource {
|
||||
pub fn new(sim: Simulation) -> Self {
|
||||
Self {
|
||||
sim,
|
||||
price_history: BTreeMap::new(),
|
||||
baseline_supply: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 7 D-181 signals for a single active (system_id, commodity_id) pair.
|
||||
///
|
||||
/// Updated every `ECON_TICK_RATE` game ticks. All fields present when the
|
||||
/// node is active; queries for inactive nodes return nothing.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EconNodeSignals {
|
||||
pub system_id: String,
|
||||
pub commodity_id: String,
|
||||
/// Signal 1: current market price in Tractus (Public).
|
||||
pub price_current: f64,
|
||||
/// Signal 2: price delta over last `TREND_WINDOW` economy ticks (Public).
|
||||
/// Positive = price rising; negative = falling. Absolute delta, not percentage.
|
||||
pub price_trend: f64,
|
||||
/// Signal 3: trade flow volume proxy — supply volume this tick (Observable).
|
||||
/// Phase 2 proxy: actual inter-node flow tracking is Phase 3.
|
||||
pub trade_flow_volume: f64,
|
||||
/// Signal 4: number of corporations operating at this node (Observable).
|
||||
pub corporate_presence: u32,
|
||||
/// Signal 5: estimated stockpile in weeks at current demand rate (Semi-private).
|
||||
pub stockpile_weeks: f64,
|
||||
/// Signal 6: supply vs. baseline supply from first tick (Private).
|
||||
/// 1.0 = at baseline; < 1.0 = below baseline; > 1.0 = above.
|
||||
pub production_vs_baseline: f64,
|
||||
/// Signal 7: ratio of formal to total (formal + shadow) activity (Meta-signal).
|
||||
/// Derived from `shadow_intensity`: 1.0 = fully formal, 0.0 = fully shadow.
|
||||
pub official_coverage_ratio: f64,
|
||||
}
|
||||
|
||||
/// Current economy state — all 7 D-181 signals for all active nodes.
|
||||
///
|
||||
/// Updated every `ECON_TICK_RATE` game ticks. Queryable by the IPC bridge
|
||||
/// (#822) and debug command handler (#823). Absent when the economy DB is
|
||||
/// not loaded (graceful degradation).
|
||||
#[derive(Resource, Default)]
|
||||
pub struct EconStateResource {
|
||||
/// Economy tick at which this snapshot was produced.
|
||||
pub econ_tick: u64,
|
||||
/// Current Tractus/Mark exchange rate (1.0 = parity).
|
||||
pub tractus_mark_rate: f64,
|
||||
/// Signal map: (system_id, commodity_id) → 7-signal snapshot.
|
||||
pub signals: BTreeMap<(String, String), EconNodeSignals>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// System: advance the economy simulation one tick every `ECON_TICK_RATE` game ticks.
|
||||
///
|
||||
/// Runs after `time::advance_tick` (needs current game tick) and before
|
||||
/// `compute_observer_snapshot` (so signals are fresh for the snapshot).
|
||||
///
|
||||
/// No-op when the game tick is not divisible by `ECON_TICK_RATE`.
|
||||
/// Both `EconSimResource` and `EconStateResource` must be present (inserted
|
||||
/// at startup only when the economy DB loaded successfully).
|
||||
pub fn tick_economy_simulation(
|
||||
time: Res<SimulationTime>,
|
||||
econ_sim_opt: Option<ResMut<EconSimResource>>,
|
||||
econ_state_opt: Option<ResMut<EconStateResource>>,
|
||||
) {
|
||||
let (mut econ_sim, mut econ_state) = match (econ_sim_opt, econ_state_opt) {
|
||||
(Some(s), Some(st)) => (s, st),
|
||||
_ => return, // economy not loaded — no-op
|
||||
};
|
||||
|
||||
if !time.tick.is_multiple_of(ECON_TICK_RATE) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step the simulation one economy tick
|
||||
econ_sim.sim.step();
|
||||
|
||||
let econ_tick = econ_sim.sim.tick();
|
||||
let fx_rate = econ_sim.sim.tractus_mark_rate();
|
||||
|
||||
// Rebuild signal map from updated node states
|
||||
rebuild_signals(&mut econ_sim, &mut econ_state, econ_tick, fx_rate);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn rebuild_signals(
|
||||
econ_sim: &mut EconSimResource,
|
||||
econ_state: &mut EconStateResource,
|
||||
econ_tick: u64,
|
||||
fx_rate: f64,
|
||||
) {
|
||||
econ_state.econ_tick = econ_tick;
|
||||
econ_state.tractus_mark_rate = fx_rate;
|
||||
econ_state.signals.clear();
|
||||
|
||||
// Collect shadow intensities and corp counts once (avoid repeated borrows)
|
||||
let shadow_intensities: BTreeMap<String, f64> = econ_sim
|
||||
.sim
|
||||
.shadow()
|
||||
.intensity
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), *v))
|
||||
.collect();
|
||||
|
||||
let corp_counts: BTreeMap<String, u32> = econ_sim
|
||||
.sim
|
||||
.economy()
|
||||
.presences_by_system
|
||||
.iter()
|
||||
.map(|(sys, corps)| (sys.clone(), corps.len() as u32))
|
||||
.collect();
|
||||
|
||||
// Snapshot current node states into the price_history and baseline_supply maps,
|
||||
// then build signals. We need to separate the borrow from the iteration.
|
||||
let node_snapshots: Vec<(String, Vec<(String, f64, f64, f64, f64)>)> = econ_sim
|
||||
.sim
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|(system_id, node)| {
|
||||
let commodities: Vec<(String, f64, f64, f64, f64)> = node
|
||||
.commodities
|
||||
.iter()
|
||||
.map(|(commodity_id, state)| {
|
||||
(
|
||||
commodity_id.clone(),
|
||||
state.price,
|
||||
state.supply,
|
||||
state.stockpile,
|
||||
state.demand,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
(system_id.clone(), commodities)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (system_id, commodities) in &node_snapshots {
|
||||
let shadow_intensity = shadow_intensities.get(system_id).copied().unwrap_or(0.0);
|
||||
let corp_count = corp_counts.get(system_id).copied().unwrap_or(0);
|
||||
|
||||
for (commodity_id, price, supply, stockpile, demand) in commodities {
|
||||
let key = (system_id.clone(), commodity_id.clone());
|
||||
|
||||
// Signal 6 baseline: record first-tick supply
|
||||
econ_sim
|
||||
.baseline_supply
|
||||
.entry(key.clone())
|
||||
.or_insert(*supply);
|
||||
let baseline = *econ_sim.baseline_supply.get(&key).unwrap_or(supply);
|
||||
|
||||
// Signal 2: price trend via ring buffer
|
||||
let history = econ_sim.price_history.entry(key.clone()).or_default();
|
||||
history.push_back(*price);
|
||||
if history.len() > TREND_WINDOW {
|
||||
history.pop_front();
|
||||
}
|
||||
let price_trend = if history.len() >= 2 {
|
||||
price - history[0]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Signal 5: stockpile in weeks (7 economy ticks per week approximation)
|
||||
let weekly_demand = demand * 7.0; // 7 econ ticks ≈ 1 week
|
||||
let stockpile_weeks = if weekly_demand > 1e-9 {
|
||||
*stockpile / weekly_demand
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Signal 6: production vs baseline
|
||||
let production_vs_baseline = if baseline > 1e-9 {
|
||||
supply / baseline
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
// Signal 7: official coverage ratio
|
||||
let official_coverage_ratio = 1.0 - shadow_intensity;
|
||||
|
||||
econ_state.signals.insert(
|
||||
key,
|
||||
EconNodeSignals {
|
||||
system_id: system_id.clone(),
|
||||
commodity_id: commodity_id.clone(),
|
||||
price_current: *price,
|
||||
price_trend,
|
||||
trade_flow_volume: *supply, // Phase 2 proxy
|
||||
corporate_presence: corp_count,
|
||||
stockpile_weeks,
|
||||
production_vs_baseline,
|
||||
official_coverage_ratio,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC query buffer (#822)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pending system_id from an `EconStateQuery` PlayerAction.
|
||||
///
|
||||
/// Populated by `process_player_input`; consumed by `serve_econ_state_query`.
|
||||
/// `None` on ticks when no query was received.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct EconQueryBuffer {
|
||||
pub pending: Option<String>,
|
||||
}
|
||||
|
||||
/// System: serve a pending `EconStateQuery` by building an `EconomySnapshot`
|
||||
/// and storing it in `SnapshotBuffer.pending_economy_response`.
|
||||
///
|
||||
/// Runs after `tick_economy_simulation` (signals must be fresh) and before
|
||||
/// `compute_observer_snapshot` (which consumes the response).
|
||||
/// No-op when `EconStateResource` is absent or no query is pending.
|
||||
///
|
||||
/// **D-181 visibility (Phase 2):** All 7 signals are sent unfiltered.
|
||||
/// Phase 3 will gate signals 3–7 behind the D-181 visibility ladder
|
||||
/// (Observable → Semi-private → Private → Meta) based on the player's
|
||||
/// information access at the queried node.
|
||||
pub fn serve_econ_state_query(
|
||||
mut query_buf: ResMut<EconQueryBuffer>,
|
||||
econ_state: Option<Res<EconStateResource>>,
|
||||
mut snapshot_buf: ResMut<SnapshotBuffer>,
|
||||
) {
|
||||
let system_id = match query_buf.pending.take() {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let econ_state = match econ_state {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
// Economy not loaded — no response (client receives None in snapshot)
|
||||
tracing::debug!(system_id = %system_id, "EconStateQuery: economy not loaded");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Collect signals for all commodities in the requested system
|
||||
let nodes: Vec<EconNodeSnapshot> = econ_state
|
||||
.signals
|
||||
.iter()
|
||||
.filter(|((sys, _), _)| sys == &system_id)
|
||||
.map(|((_, commodity_id), sig)| EconNodeSnapshot {
|
||||
commodity_id: commodity_id.clone(),
|
||||
price_current: sig.price_current,
|
||||
price_trend: sig.price_trend,
|
||||
trade_flow_volume: sig.trade_flow_volume,
|
||||
corporate_presence: sig.corporate_presence,
|
||||
stockpile_weeks: sig.stockpile_weeks,
|
||||
production_vs_baseline: sig.production_vs_baseline,
|
||||
official_coverage_ratio: sig.official_coverage_ratio,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if nodes.is_empty() {
|
||||
tracing::debug!(system_id = %system_id, "EconStateQuery: system not found in economy state");
|
||||
return;
|
||||
}
|
||||
|
||||
snapshot_buf.pending_economy_response = Some(EconomySnapshot {
|
||||
system_id,
|
||||
econ_tick: econ_state.econ_tick,
|
||||
tractus_mark_rate: econ_state.tractus_mark_rate,
|
||||
nodes,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Attempt to load the economy simulation.
|
||||
///
|
||||
/// Returns `Some((EconSimResource, EconStateResource))` on success, `None` on
|
||||
/// failure (with the error logged at warn level). The server inserts these as
|
||||
/// resources when present; the economy features degrade gracefully when absent.
|
||||
pub fn try_load_economy(run_seed: u64) -> Option<(EconSimResource, EconStateResource)> {
|
||||
match Simulation::load_auto(run_seed) {
|
||||
Ok(sim) => {
|
||||
tracing::info!(
|
||||
commodities = sim.economy().commodities.len(),
|
||||
active_nodes = sim.nodes.len(),
|
||||
"Economy simulation loaded"
|
||||
);
|
||||
Some((EconSimResource::new(sim), EconStateResource::default()))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"Economy simulation not loaded — economics features disabled for this session"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use crate::bridge::types::{FacingDirection, ObjectType, PlayerAction, PlayerInpu
|
||||
use crate::knowledge::{EntityRegistry, StableId};
|
||||
use crate::perception::vision_cone::{facing_from_delta, Facing};
|
||||
use crate::settings::{SettingsCommand, SettingsCommandBuffer};
|
||||
use crate::simulation::economy::EconQueryBuffer;
|
||||
use crate::simulation::interaction::{DoorInteractRequest, DoorState, TerminalInteractRequest};
|
||||
use crate::simulation::inventory::{
|
||||
find_next_slot, occupied_slots_for, CarriedBy, InventorySlot, ItemName, MAX_INVENTORY_SLOTS,
|
||||
@@ -102,6 +103,7 @@ pub fn process_player_input(
|
||||
mut save_load: Option<ResMut<SaveLoadPending>>,
|
||||
mut debug_cmd_buffer: Option<ResMut<DebugCommandBuffer>>,
|
||||
mut settings_cmd_buffer: Option<ResMut<SettingsCommandBuffer>>,
|
||||
mut econ_query_buf: Option<ResMut<EconQueryBuffer>>,
|
||||
door_states: Query<&DoorState>,
|
||||
object_types: Query<&ObjectType>,
|
||||
) {
|
||||
@@ -127,6 +129,7 @@ pub fn process_player_input(
|
||||
| PlayerAction::ChangeSetting { .. }
|
||||
| PlayerAction::RequestAllSettings
|
||||
| PlayerAction::DeleteSetting { .. }
|
||||
| PlayerAction::EconStateQuery { .. }
|
||||
)
|
||||
{
|
||||
continue;
|
||||
@@ -410,6 +413,13 @@ pub fn process_player_input(
|
||||
);
|
||||
}
|
||||
}
|
||||
PlayerAction::EconStateQuery { system_id } => {
|
||||
if let Some(ref mut buf) = econ_query_buf {
|
||||
buf.pending = Some(system_id);
|
||||
} else {
|
||||
tracing::debug!("EconStateQuery received but EconQueryBuffer not registered — economy not loaded");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod chunk_streaming;
|
||||
pub mod contraband;
|
||||
pub mod conversation;
|
||||
pub mod dialogue;
|
||||
pub mod economy;
|
||||
pub mod examine;
|
||||
pub mod follow;
|
||||
pub mod generator;
|
||||
@@ -166,6 +167,28 @@ impl Plugin for SimulationPlugin {
|
||||
// Initialize TickerPool with empty default; populated by ContentPlugin at Startup.
|
||||
app.init_resource::<ticker::TickerPool>();
|
||||
|
||||
// Economy simulation (#821, D-031) — loaded once at startup, no-op when DB absent.
|
||||
// Uses seed 0 for now; will be threaded through StartupMessage world seed (#826).
|
||||
if let Some((econ_sim, econ_state)) = economy::try_load_economy(0) {
|
||||
app.insert_resource(econ_sim).insert_resource(econ_state);
|
||||
}
|
||||
// EconQueryBuffer: always registered so EconStateQuery PlayerActions are accepted
|
||||
// even when the economy DB is absent (queries just produce no response).
|
||||
app.init_resource::<economy::EconQueryBuffer>();
|
||||
// tick_economy_simulation + serve_econ_state_query use Option<ResMut<...>> — safe to
|
||||
// register unconditionally. They no-op when EconSimResource / EconStateResource absent.
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
economy::tick_economy_simulation
|
||||
.after(time::advance_tick)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
economy::serve_econ_state_query
|
||||
.after(economy::tick_economy_simulation)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
),
|
||||
);
|
||||
|
||||
tracing::debug!("SimulationPlugin initialized");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ fn protocol_version_constant_matches_snapshot() {
|
||||
let snapshot = test_snapshot(0, vec![]);
|
||||
assert_eq!(snapshot.version, PROTOCOL_VERSION);
|
||||
assert_eq!(
|
||||
PROTOCOL_VERSION, 20,
|
||||
PROTOCOL_VERSION, 21,
|
||||
"bump this assertion when protocol version changes"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
# Test Plan: Sprint 33 — Pecunia (Economics Simulation)
|
||||
|
||||
- **Sprint:** 33
|
||||
- **Date:** 2026-04-07
|
||||
- **Author:** Hoshe (QA)
|
||||
- **Branch:** `sprint-33/server`
|
||||
- **Tickets:** #813, #805, #806, #807, #808, #809
|
||||
- **Key spec:** `decisions/economics.md` (D-171–D-187), D-179 (stability acceptance criteria)
|
||||
|
||||
---
|
||||
|
||||
## How to Use This Document
|
||||
|
||||
Verification queries are written for `tooling/db/sqlite-query`. Stability tests run via
|
||||
`tooling/econ-sim --stability-check` once #807 lands. All SQL queries assume a fully-imported
|
||||
`server/data/systems.db` (after running `make economy-db`).
|
||||
|
||||
**Pass/fail convention:** Each test has an **Expected** clause. A test fails if the output
|
||||
deviates from Expected in any measurable way. Failures from #807 and later that involve
|
||||
oscillation or divergence indicate a broken model — tune α/β before calling it a feature (D-179).
|
||||
|
||||
---
|
||||
|
||||
## Pre-Flight: Baseline Data Sanity
|
||||
|
||||
Run these before testing any ticket. If they fail, the DB state is corrupted and ticket-level
|
||||
tests are meaningless.
|
||||
|
||||
```sql
|
||||
-- BF-1: Commodity count must be 36 (D-184)
|
||||
SELECT COUNT(*) FROM commodities;
|
||||
-- Expected: 36
|
||||
|
||||
-- BF-2: Commodity tier breakdown must match D-184 (9/10/9/5/3)
|
||||
SELECT tier, COUNT(*) FROM commodities GROUP BY tier ORDER BY tier;
|
||||
-- Expected:
|
||||
-- intermediate 10
|
||||
-- raw 9
|
||||
-- final 9
|
||||
-- service_professional 5
|
||||
-- service_luxury 3
|
||||
|
||||
-- BF-3: Production chain count must be 21 (D-184)
|
||||
SELECT COUNT(*) FROM production_chains;
|
||||
-- Expected: 21
|
||||
|
||||
-- BF-4: Chain input count must be 40 (count inputs from production_chains.toml)
|
||||
SELECT COUNT(*) FROM chain_inputs;
|
||||
-- Expected: 40
|
||||
|
||||
-- BF-5: Gate links must be bidirectional (every from→to has a matching to→from)
|
||||
SELECT COUNT(*) FROM gate_links gl
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM gate_links rev
|
||||
WHERE rev.from_system_id = gl.to_system_id
|
||||
AND rev.to_system_id = gl.from_system_id
|
||||
);
|
||||
-- Expected: 0
|
||||
|
||||
-- BF-6: All chain inputs reference valid commodities
|
||||
SELECT COUNT(*) FROM chain_inputs ci
|
||||
LEFT JOIN commodities c ON ci.input_commodity_id = c.commodity_id
|
||||
WHERE c.commodity_id IS NULL;
|
||||
-- Expected: 0
|
||||
|
||||
-- BF-7: All chain outputs reference valid commodities
|
||||
SELECT COUNT(*) FROM production_chains pc
|
||||
LEFT JOIN commodities c ON pc.output_commodity_id = c.commodity_id
|
||||
WHERE c.commodity_id IS NULL;
|
||||
-- Expected: 0
|
||||
```
|
||||
|
||||
**Known discrepancy to verify:** The `commodities.toml` section header reads
|
||||
`# PROFESSIONAL SERVICES (7)` but only 5 entries follow. The total must be 36 (matching
|
||||
D-184: 9+10+9+5+3). Flag if the count is 38.
|
||||
|
||||
---
|
||||
|
||||
## #813 — Energy-over-gate Schema Extension
|
||||
|
||||
**Spec ref:** D-186
|
||||
**Assigned to:** Tyre
|
||||
**Status:** in_progress
|
||||
|
||||
### What was changed
|
||||
|
||||
- `gate_energy_connected INTEGER DEFAULT 1` column added to `star_systems`
|
||||
(system-level, not per body/station — all nodes in a system inherit the system's setting
|
||||
via a join. Gate Corp energy is a system-wide commercial contract, not a per-node toggle.)
|
||||
- `MARK_PRIMARY` zones default to `false` (0)
|
||||
- All other zones default to `true` (1)
|
||||
- Migration is idempotent (safe to re-run via COLUMN_MIGRATIONS)
|
||||
- `set_gate_energy()` runs as step 6, after `set_currency_zones()` step 5 (correct ordering)
|
||||
|
||||
### Verification queries
|
||||
|
||||
```sql
|
||||
-- 813-1: Column exists on star_systems table
|
||||
PRAGMA table_info(star_systems);
|
||||
-- Expected: row with name='gate_energy_connected' and type='INTEGER'
|
||||
|
||||
-- 813-2: MARK_PRIMARY systems have gate_energy_connected = 0
|
||||
SELECT COUNT(*) FROM star_systems
|
||||
WHERE currency_zone = 'MARK_PRIMARY'
|
||||
AND gate_energy_connected != 0;
|
||||
-- Expected: 0
|
||||
|
||||
-- 813-3: TRACTUS_PRIMARY systems have gate_energy_connected = 1
|
||||
SELECT COUNT(*) FROM star_systems
|
||||
WHERE currency_zone = 'TRACTUS_PRIMARY'
|
||||
AND gate_energy_connected != 1;
|
||||
-- Expected: 0
|
||||
|
||||
-- 813-4: MIXED systems have gate_energy_connected = 1
|
||||
SELECT COUNT(*) FROM star_systems
|
||||
WHERE currency_zone = 'MIXED'
|
||||
AND gate_energy_connected != 1;
|
||||
-- Expected: 0
|
||||
|
||||
-- 813-5: gate_energy_connected is never NULL
|
||||
SELECT COUNT(*) FROM star_systems WHERE gate_energy_connected IS NULL;
|
||||
-- Expected: 0
|
||||
|
||||
-- 813-6: At least one MARK_PRIMARY system exists (validates zone data is present)
|
||||
SELECT COUNT(*) FROM star_systems WHERE currency_zone = 'MARK_PRIMARY';
|
||||
-- Expected: > 0 (requires #820 copy work to be merged first; skip if copy branch not merged)
|
||||
|
||||
-- 813-7: Sim binary can read gate_energy via join (integration spot-check)
|
||||
-- The sim must join bodies/stations to star_systems to get gate_energy_connected.
|
||||
-- Verify the join is correct:
|
||||
SELECT b.body_id, ss.gate_energy_connected
|
||||
FROM bodies b
|
||||
JOIN star_systems ss ON b.system_id = ss.system_id
|
||||
WHERE b.inhabited = 1
|
||||
LIMIT 5;
|
||||
-- Expected: 5 rows with gate_energy_connected = 0 or 1 (not NULL)
|
||||
```
|
||||
|
||||
### Edge cases
|
||||
|
||||
**813-E1: Idempotent migration**
|
||||
Run `make economy-db` twice on the same DB. Second run must not raise an error, and query
|
||||
813-1 through 813-7 must still pass.
|
||||
|
||||
**813-E2: Systems with NULL currency_zone**
|
||||
If any star system has `currency_zone IS NULL`, the migration logic must treat it as
|
||||
`TRACTUS_PRIMARY` (default to `true`). Verify no bodies end up with `gate_energy_connected = 0`
|
||||
due to a NULL zone.
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM star_systems WHERE currency_zone IS NULL;
|
||||
-- Expected: 0 (import pipeline sets default; but verify regardless)
|
||||
```
|
||||
|
||||
**813-E3: Demand reduction is NOT implemented here**
|
||||
Confirm the `~0.3× fusion_fuel` utility demand reduction is absent from the schema-only ticket.
|
||||
The demand model lives in the sim binary (#806). Verify:
|
||||
- No column named `utility_demand_modifier` or similar on star_systems
|
||||
- No new columns beyond `gate_energy_connected` on star_systems
|
||||
|
||||
### Regression markers
|
||||
|
||||
- `tooling/economy-db/import_economics.py` migration block must still be idempotent
|
||||
- Existing BF-1 through BF-7 must still pass after #813 migration
|
||||
|
||||
---
|
||||
|
||||
## #805 — Corporation Pipeline and Validation
|
||||
|
||||
**Spec ref:** D-175, D-182
|
||||
**Assigned to:** Dudley
|
||||
**Status:** in_progress
|
||||
|
||||
### What was changed
|
||||
|
||||
- `import_economics.py` (or a new companion script) reads `wiki/corporations/` markdown files
|
||||
- Populates `corp_presence` table from authored location data
|
||||
- Validates wiki corp names ↔ DB `corporations.proper_name` sync (D-182 sync constraint)
|
||||
- Coverage rules: 3+ corps per major commodity type, 1+ per inhabited system >100K pop
|
||||
- Chain completeness validation: every intermediate commodity has ≥1 producing chain
|
||||
- Coverage failures exit non-zero (D-175 phase gate)
|
||||
|
||||
### Verification queries
|
||||
|
||||
```sql
|
||||
-- 805-1: corp_presence is no longer empty after pipeline run
|
||||
SELECT COUNT(*) FROM corp_presence;
|
||||
-- Expected: > 0
|
||||
|
||||
-- 805-2: All corp_presence rows reference valid corp_id
|
||||
SELECT COUNT(*) FROM corp_presence cp
|
||||
LEFT JOIN corporations c ON cp.corp_id = c.corp_id
|
||||
WHERE c.corp_id IS NULL;
|
||||
-- Expected: 0
|
||||
|
||||
-- 805-3: All corp_presence rows reference valid location_id
|
||||
-- (either a body_id or station_id — location_type determines which table)
|
||||
SELECT COUNT(*) FROM corp_presence WHERE location_type = 'body'
|
||||
AND location_id NOT IN (SELECT body_id FROM bodies);
|
||||
-- Expected: 0
|
||||
SELECT COUNT(*) FROM corp_presence WHERE location_type = 'station'
|
||||
AND location_id NOT IN (SELECT station_id FROM stations);
|
||||
-- Expected: 0
|
||||
|
||||
-- 805-4: Chain completeness — every intermediate must have a producing chain
|
||||
SELECT c.commodity_id, c.name
|
||||
FROM commodities c
|
||||
WHERE c.tier = 'intermediate'
|
||||
AND c.commodity_id NOT IN (
|
||||
SELECT output_commodity_id FROM production_chains
|
||||
);
|
||||
-- Expected: 0 rows (all 10 intermediates have a producing chain)
|
||||
|
||||
-- 805-5: Chain completeness — every final good must have a producing chain
|
||||
SELECT c.commodity_id, c.name
|
||||
FROM commodities c
|
||||
WHERE c.tier = 'final'
|
||||
AND c.commodity_id NOT IN (
|
||||
SELECT output_commodity_id FROM production_chains
|
||||
);
|
||||
-- Expected: 0 rows (all 9 finals have a producing chain)
|
||||
|
||||
-- 805-6: Services have NO producing chains (they are demand sinks, not outputs)
|
||||
SELECT c.commodity_id, c.name
|
||||
FROM commodities c
|
||||
WHERE c.tier IN ('service_professional', 'service_luxury')
|
||||
AND c.commodity_id IN (SELECT output_commodity_id FROM production_chains);
|
||||
-- Expected: 0 rows
|
||||
|
||||
-- 805-7: Raw materials have NO producing chains (they are inputs, not outputs)
|
||||
SELECT c.commodity_id, c.name
|
||||
FROM commodities c
|
||||
WHERE c.tier = 'raw'
|
||||
AND c.commodity_id IN (SELECT output_commodity_id FROM production_chains);
|
||||
-- Expected: 0 rows (fusion_fuel is intermediate, not raw — verify separately)
|
||||
|
||||
-- 805-8: Coverage rule — corporations per major commodity type (D-175: 3+ per major type)
|
||||
-- "Major commodity type" = intermediates and finals with demand_model = 'market'
|
||||
-- This requires corp_presence.primary_operation to reference a commodity_id; adjust
|
||||
-- query if the schema uses a different field. Flag if the field is absent.
|
||||
SELECT commodity_id, COUNT(DISTINCT cp.corp_id) AS corp_count
|
||||
FROM corp_presence cp
|
||||
JOIN corporations c ON cp.corp_id = c.corp_id
|
||||
WHERE cp.primary_operation IS NOT NULL
|
||||
GROUP BY cp.primary_operation
|
||||
HAVING corp_count < 3;
|
||||
-- Expected: 0 rows (every commodity with corp presence has 3+ corps)
|
||||
|
||||
-- 805-9: Coverage rule — inhabited systems > 100K pop have at least one corp
|
||||
SELECT ss.system_id, ss.proper_name
|
||||
FROM star_systems ss
|
||||
JOIN system_economy se ON ss.system_id = se.system_id
|
||||
WHERE se.population > 100000
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM corp_presence cp
|
||||
JOIN bodies b ON cp.location_id = b.body_id AND cp.location_type = 'body'
|
||||
WHERE b.system_id = ss.system_id
|
||||
UNION
|
||||
SELECT 1 FROM corp_presence cp
|
||||
JOIN stations s ON cp.location_id = s.station_id AND cp.location_type = 'station'
|
||||
WHERE s.system_id = ss.system_id
|
||||
);
|
||||
-- Expected: 0 rows
|
||||
```
|
||||
|
||||
### Exit code tests
|
||||
|
||||
Run the pipeline with intentional violations and verify non-zero exit:
|
||||
|
||||
**805-E1: Wiki name mismatch causes hard error**
|
||||
Temporarily rename a corporation in the DB to something the wiki doesn't know, re-run pipeline.
|
||||
Expected: non-zero exit with clear error message identifying the mismatch.
|
||||
|
||||
**805-E2: Missing commodity coverage causes hard error**
|
||||
If coverage drops below 3 corps for any major commodity type, pipeline must exit non-zero.
|
||||
Expected: non-zero exit with specific commodity identified.
|
||||
|
||||
**805-E3: Coverage failure for underpopulated system causes hard error**
|
||||
If an inhabited system with >100K pop has zero corp presence, pipeline must exit non-zero.
|
||||
Expected: non-zero exit with system_id identified.
|
||||
|
||||
**805-E4: Dry-run still works**
|
||||
`python3 tooling/economy-db/import_economics.py --dry-run` must:
|
||||
- Not write to corp_presence
|
||||
- Run all validations and report but not halt on coverage gaps (dry-run output is informational)
|
||||
- Exit 0 (dry-run is for inspection, not a gate)
|
||||
|
||||
Wait — check this with Dudley. If dry-run is meant to be a gate too, this should exit non-zero
|
||||
on validation failure. The existing pipeline exits 0 on dry-run. Confirm expected behavior
|
||||
before locking this test.
|
||||
|
||||
### Regression markers
|
||||
|
||||
- BF-1 through BF-7 still pass (new pipeline must not corrupt commodity/chain data)
|
||||
- `corp_presence` table's FK constraint still enforced (BF-6 analog for corps)
|
||||
- Existing `gate_links` bidirectionality (BF-5) unaffected
|
||||
|
||||
---
|
||||
|
||||
## #806 — Skeleton Economy_sim Binary
|
||||
|
||||
**Spec ref:** D-176, D-177, D-178
|
||||
**Assigned to:** Dudley
|
||||
**Status:** backlog (blocked on #805)
|
||||
|
||||
### What was changed
|
||||
|
||||
- New Rust binary at `tooling/econ-sim/`
|
||||
- Reads systems.db: gate_links, commodities, production_chains, chain_inputs, corp_presence
|
||||
- Seeds per-corp-site productivity (5 dimensions, PRNG, log-normal distribution)
|
||||
- Layer 1 Leontief only (no inter-system trade, no currency)
|
||||
- Outputs per-node CSV: node_id, commodity_id, supply, demand, price, tick
|
||||
- `--stability-check` flag compiles (stub, not yet meaningful)
|
||||
|
||||
### Build verification
|
||||
|
||||
```bash
|
||||
cd tooling/econ-sim && cargo build
|
||||
# Expected: exits 0, no compile errors
|
||||
|
||||
tooling/econ-sim --help
|
||||
# Expected: help text with --db, --stability-check, and --output flags visible
|
||||
```
|
||||
|
||||
### CSV output verification
|
||||
|
||||
```bash
|
||||
tooling/econ-sim --db server/data/systems.db --output /tmp/econ_out.csv
|
||||
```
|
||||
|
||||
**806-1:** CSV file is created at the specified path
|
||||
**806-2:** CSV header contains: `node_id,commodity_id,supply,demand,price,tick`
|
||||
**806-3:** Row count is `active_nodes × 36` (760 active nodes × 36 commodities = ~27,360 rows)
|
||||
- Acceptable range: ±10% of 27,360 (active node count may differ slightly from spec estimate)
|
||||
**806-4:** No `price` values are negative or zero for commodity types with non-zero base_price
|
||||
**806-5:** `tick` column is 0 for initial seeding output (first tick)
|
||||
|
||||
### Productivity seeding verification
|
||||
|
||||
**806-6: Standard node range (D-176)**
|
||||
```python
|
||||
# Pseudocode — inspect CSV output
|
||||
import csv, math
|
||||
prices = [float(row['price']) for row in csv.DictReader(open('/tmp/econ_out.csv'))]
|
||||
# For standard commodities, productivity multiplier range is 0.4–1.8x
|
||||
# Price variation relative to base_price should reflect this range
|
||||
base_price_by_id = { ... } # from commodities table
|
||||
multipliers = [price / base_price_by_id[row['commodity_id']] for row in rows]
|
||||
assert all(0.3 <= m <= 2.0 for m in multipliers), "multiplier out of expected range"
|
||||
# Actual range check: most values should fall within 0.4–1.8x (log-normal tails permitted)
|
||||
```
|
||||
|
||||
**806-7: Monopoly-source node range (D-176)**
|
||||
Nodes producing `lattice_grade_material` (production_ubiquity = 'monopolistic') must show
|
||||
tighter multiplier range: 0.7–1.4×. Verify variance is lower than standard nodes.
|
||||
|
||||
**806-8: D-177 constraints — what must NOT vary**
|
||||
Verify the binary never seeds or varies:
|
||||
- Location of production (the set of nodes producing each commodity is fixed from DB data,
|
||||
not random)
|
||||
- `lattice_grade_material` productivity: must stay within 0.7–1.4× (monopolistic ceiling)
|
||||
- Absence of seeded "starting disruptions" (no negative productivity, no corps with zero
|
||||
initial output as a seeded state)
|
||||
|
||||
**806-9: Corridor correlation (D-176 ~0.6)**
|
||||
Nodes in the same geographic corridor should have correlated productivity across runs with
|
||||
similar PRNG seeds. Spot-check: run binary twice with seeds differing by 1; nodes in same
|
||||
corridor should show ~0.6 Pearson correlation on their multipliers.
|
||||
|
||||
### Stub stability check
|
||||
|
||||
```bash
|
||||
tooling/econ-sim --stability-check
|
||||
# Expected: exits with some non-panic output, even if it's "stability tests not yet implemented"
|
||||
# Must NOT crash or segfault
|
||||
```
|
||||
|
||||
### Regression markers
|
||||
|
||||
- Atlas binary still builds: `cargo build --bin atlas`
|
||||
- D-177 lore constraints respected (see 806-8)
|
||||
|
||||
---
|
||||
|
||||
## #807 — Trade Flows and Stability Testing
|
||||
|
||||
**Spec ref:** D-178, D-179
|
||||
**Assigned to:** Dudley
|
||||
**Status:** backlog (blocked on #806)
|
||||
|
||||
**This is the most critical ticket. D-179 defines the exit condition for Phase 2.**
|
||||
|
||||
### Tâtonnement parameters
|
||||
|
||||
Verify from source code:
|
||||
- α = 0.03 (price adjustment speed)
|
||||
- β = 0.4 (damping coefficient)
|
||||
|
||||
If these are configurable via CLI flags, document the defaults. If hardcoded, grep for them:
|
||||
```bash
|
||||
grep -r "0\.03" tooling/econ-sim/src/
|
||||
grep -r "0\.4" tooling/econ-sim/src/
|
||||
```
|
||||
|
||||
### Floyd-Warshall startup performance
|
||||
|
||||
```bash
|
||||
time tooling/econ-sim --stability-check 2>&1 | head -5
|
||||
# Expected: FW initialization completes in < 2s (D-178 spec: ~0.5s, allow 4x margin)
|
||||
# Flag if > 5s: likely iterating over all 3700 nodes instead of the ~760 active subgraph
|
||||
```
|
||||
|
||||
### Market node tiering (D-178)
|
||||
|
||||
**807-1:** Active node count is approximately 760 (inhabited bodies + all stations)
|
||||
|
||||
```sql
|
||||
-- Count active market nodes per D-178 definition
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT body_id AS node_id FROM bodies WHERE inhabited = 1
|
||||
UNION ALL
|
||||
SELECT station_id FROM stations
|
||||
);
|
||||
-- Expected: ~760 (accept 700–820 as the spec estimate may not match actual DB state)
|
||||
```
|
||||
|
||||
**807-2:** Passive producer count is approximately 240
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM bodies WHERE inhabited = 0 AND population > 0;
|
||||
-- Expected: ~240 (bodies with economic activity but no market function)
|
||||
-- Adjust query based on how the sim defines "passive producer"
|
||||
```
|
||||
|
||||
### Transport cost model
|
||||
|
||||
Verify in source or via output that:
|
||||
**807-3:** Gate edges cost 5–12% per hop (inter-system)
|
||||
**807-4:** Orbital edges cost 1–3% (intra-system)
|
||||
**807-5:** Transport costs are applied to commodity prices, not abstracted away
|
||||
|
||||
### D-179 Stability Tests
|
||||
|
||||
```bash
|
||||
tooling/econ-sim --stability-check
|
||||
```
|
||||
|
||||
All four tests are run by this flag (Tests 1–2 in #807, Tests 3–4 in #808). After #807:
|
||||
|
||||
**Test 1: Cold-start convergence (D-179)**
|
||||
- Simulate 100 game-days from cold start
|
||||
- Measure price deviation from equilibrium at tick 100
|
||||
- **Pass criterion:** All active commodity prices within ±5% of equilibrium
|
||||
- **Fail indicators:** oscillation, monotonic drift, any price < 0
|
||||
|
||||
**Test 2: Long-run stability (D-179)**
|
||||
- Simulate 1,000 game-days with zero external events
|
||||
- Measure maximum price drift from tick-0 equilibrium
|
||||
- **Pass criterion:** Zero drift > ±2% over the full 1,000-tick run
|
||||
- **Fail indicators:** slow drift accumulation, oscillation amplitude > 2%, any negative price
|
||||
|
||||
### Stockpile buffer test
|
||||
|
||||
**807-6:** Single-tick supply removal does not cause price explosion
|
||||
```
|
||||
procedure:
|
||||
1. Run sim to equilibrium (100 ticks)
|
||||
2. Inject a single tick of zero supply for one commodity at one node
|
||||
3. Observe price at that node for next 5 ticks
|
||||
Expected: price rises but does not exceed 10× base_price
|
||||
Fail: price goes to infinity, NaN, or negative
|
||||
```
|
||||
|
||||
### Regression markers
|
||||
|
||||
- Test 1 and Test 2 must pass with `--stability-check` before #808 begins
|
||||
- If either test fails: do NOT mark #807 done, do NOT proceed to #808
|
||||
- α/β must be documented (in source comments or README) so future tuning is traceable
|
||||
|
||||
---
|
||||
|
||||
## #808 — Currency Zones and Exchange Rates
|
||||
|
||||
**Spec ref:** D-171, D-172, D-174, D-181, D-186
|
||||
**Assigned to:** Dudley
|
||||
**Status:** backlog (blocked on #807)
|
||||
|
||||
### Currency zone model
|
||||
|
||||
**808-1:** Tractus↔Mark friction = ~3%
|
||||
Verify in cross-zone trade: cost of a commodity transiting from a TRACTUS_PRIMARY to a
|
||||
MARK_PRIMARY node is ~3% higher than same-zone transit at equal hop distance.
|
||||
|
||||
**808-2:** Zero friction within MARK_PRIMARY zones
|
||||
Two nodes both in MARK_PRIMARY zones trading with each other incur no currency conversion cost
|
||||
beyond the standard transport cost.
|
||||
|
||||
**808-3:** Sol is NOT a zone flag
|
||||
```sql
|
||||
SELECT COUNT(*) FROM star_systems WHERE currency_zone = 'SOL_PRIMARY';
|
||||
-- Expected: 0 (Sol is shadow economy only, D-171)
|
||||
```
|
||||
|
||||
**808-4:** Exchange rate is driven by trade balance, not hardcoded
|
||||
The Tractus/Mark exchange rate must change between runs (or across ticks as trade flows change).
|
||||
Hardcoded rates are a test failure.
|
||||
|
||||
### Signal vocabulary (D-181)
|
||||
|
||||
All 7 signals must be present in sim output per active node:
|
||||
|
||||
**808-5:**
|
||||
```
|
||||
1. price_current — present in output
|
||||
2. price_trend — present in output (direction + rate)
|
||||
3. trade_flow_volume — present in output
|
||||
4. corporate_presence — present in output
|
||||
5. stockpile_weeks — present in output
|
||||
6. production_vs_baseline — present in output
|
||||
7. official_coverage_ratio — present in output (derived from shadow_economy_intensity)
|
||||
```
|
||||
|
||||
Edge case: For `official_coverage_ratio`, verify nodes with no shadow economy intensity
|
||||
(TRACTUS_PRIMARY core systems) produce `official_coverage_ratio = 1.0` (formal economy
|
||||
covers 100% of activity), not NULL.
|
||||
|
||||
### gate_energy_connected demand reduction (D-186)
|
||||
|
||||
**808-6:** Nodes with `gate_energy_connected = true` show `fusion_fuel` demand ~0.3× baseline
|
||||
- Run sim on a TRACTUS_PRIMARY system (gate_energy_connected = true)
|
||||
- Run sim on a MARK_PRIMARY system (gate_energy_connected = false)
|
||||
- Compare `fusion_fuel` demand signal: on-grid node demand must be ~30% of off-grid
|
||||
|
||||
**808-7:** Industrial chain inputs are NOT reduced (D-186)
|
||||
- `smelt_ore` still requires `fusion_fuel` at 0.3 coefficient regardless of gate energy
|
||||
- `alloy_fabrication` still requires `fusion_fuel` at 0.2 coefficient
|
||||
- `electronics_fabrication` still requires `fusion_fuel` at 0.2 coefficient
|
||||
|
||||
### D-179 Tests 3–4
|
||||
|
||||
**Test 3: Shock response (D-179)**
|
||||
- Apply a single supply shock to one commodity at one node
|
||||
- **Pass criteria:**
|
||||
- Cascade propagates to dependent commodities (Leontief input scarcity visible)
|
||||
- Recovery to within 10% of pre-shock price within 200 ticks
|
||||
- No price explosions (no value > 100× base_price)
|
||||
- No negative prices
|
||||
- **Fail indicators:** runaway cascade, no recovery, shock isolated (no cascade = broken Leontief)
|
||||
|
||||
**Test 4: Cross-zone trade balance (D-179)**
|
||||
- Increase trade volume across a TRACTUS_PRIMARY / MARK_PRIMARY boundary
|
||||
- **Pass criteria:**
|
||||
- Exchange rate adjusts in response (Tractus/Mark ratio changes)
|
||||
- Rate re-stabilizes within 50 ticks
|
||||
- Friction cost is visible (cross-zone goods 3% more expensive than same-zone equivalent)
|
||||
- **Fail indicators:** no rate adjustment, infinite oscillation, rate diverges
|
||||
|
||||
All four D-179 tests must pass before #809 begins.
|
||||
|
||||
### Regression markers
|
||||
|
||||
- Tests 1 and 2 from #807 must still pass with currency layer active
|
||||
- Tractus prices are still the numeraire (no price expressed in Mark or Sol units)
|
||||
|
||||
---
|
||||
|
||||
## #809 — Corporate Agent Behavior
|
||||
|
||||
**Spec ref:** D-175, D-178, D-180, D-181
|
||||
**Assigned to:** Dudley
|
||||
**Status:** backlog (blocked on #808, #799, #800)
|
||||
|
||||
### Corporate data loading
|
||||
|
||||
**809-1:** Corporations are loaded from DB, not hardcoded
|
||||
```bash
|
||||
grep -r "hardcoded\|\"Gate Corporation\"\|\"Vethara\"" tooling/econ-sim/src/
|
||||
# Expected: corporation names should appear only in test fixtures or SQL queries,
|
||||
# not as string literals in behavioral logic
|
||||
```
|
||||
|
||||
**809-2:** Behavioral archetype template is read from TOML
|
||||
```bash
|
||||
ls wiki/economics/archetypes/behavioral.toml
|
||||
# Expected: file exists (created by copy team per sprint briefing)
|
||||
```
|
||||
|
||||
**809-3:** Each archetype is instantiated per corporation from corp_presence
|
||||
```sql
|
||||
-- Every corporation with corp_presence rows has a behavioral_archetype in DB
|
||||
SELECT COUNT(*) FROM corp_presence cp
|
||||
JOIN corporations c ON cp.corp_id = c.corp_id
|
||||
WHERE c.behavioral_archetype IS NULL;
|
||||
-- Expected: 0 (all corps with presence have an archetype assigned)
|
||||
```
|
||||
|
||||
### Six behavioral archetypes (D-175)
|
||||
|
||||
**809-4:** All 6 archetypes are implemented
|
||||
```bash
|
||||
grep -r "Monopolist\|Distributor\|Producer\|Specialist\|Cooperative\|Intermediary" \
|
||||
tooling/econ-sim/src/
|
||||
# Expected: all 6 appear in behavioral logic, not just data loading
|
||||
```
|
||||
|
||||
**809-5:** Archetypes produce distinguishably different behavior
|
||||
Run stability check with only Monopolist corps vs. only Cooperative corps in a test system.
|
||||
Price signals should differ between the two runs. If all archetypes produce identical output,
|
||||
the behavioral differentiation is not implemented.
|
||||
|
||||
### EconEvent stub (D-180)
|
||||
|
||||
**809-6:** EconEvent struct compiles with all required fields
|
||||
```bash
|
||||
grep -r "EconEvent" tooling/econ-sim/src/
|
||||
# Expected: struct definition with: target, effect, duration, visibility fields
|
||||
```
|
||||
|
||||
**809-7:** Visibility variants are defined
|
||||
```bash
|
||||
grep -r "Global\|Proximate\|Disclosed\|Hidden" tooling/econ-sim/src/
|
||||
# Expected: all 4 visibility variants present in the EconEvent type
|
||||
```
|
||||
|
||||
**809-8:** Event handler is a no-op (not exercised in Phase 2)
|
||||
Any call to `handle_event(EconEvent { ... })` should produce no observable simulation change.
|
||||
The port must compile and accept events without crashing.
|
||||
|
||||
### Signal completeness (D-181)
|
||||
|
||||
**809-9:** All 7 signals produced per active node with agents active
|
||||
Repeat 808-5 checks with corporate agents running. Agent behavior must not suppress or break
|
||||
signal production.
|
||||
|
||||
**809-10:** `production_vs_baseline` reflects agent output vs seeded baseline
|
||||
A Monopolist corp restricting supply should show `production_vs_baseline < 1.0`.
|
||||
A Cooperative corp operating at full capacity should show `production_vs_baseline ≈ 1.0`.
|
||||
|
||||
### D-179 Full Test Suite with Agents Active
|
||||
|
||||
**This is the Phase 2 exit condition.**
|
||||
|
||||
```bash
|
||||
tooling/econ-sim --stability-check
|
||||
```
|
||||
|
||||
**809-11:** All four stability tests pass with corporate agents active:
|
||||
- Test 1: Cold-start convergence ±5% within 100 game-days
|
||||
- Test 2: Long-run stability ±2% over 1,000 game-days
|
||||
- Test 3: Shock response, recovery within 200 ticks, no explosions or negatives
|
||||
- Test 4: Cross-zone balance re-stabilizes within 50 ticks
|
||||
|
||||
If agents CAUSE instability that wasn't present in #808, the agent behavioral parameters need
|
||||
tuning — this is a model bug, not a design decision. Investigate price-setting behavior before
|
||||
concluding the architecture is wrong.
|
||||
|
||||
### Regression markers
|
||||
|
||||
- All prior D-179 tests still pass
|
||||
- EconEvent handler does not crash on any valid input permutation
|
||||
- `behavioral.toml` is a required file — binary must error on missing file with a clear message
|
||||
|
||||
---
|
||||
|
||||
## Checklist: Verification Order
|
||||
|
||||
| Order | Ticket | Gate condition | Who verifies |
|
||||
|-------|--------|----------------|--------------|
|
||||
| 1 | Pre-flight BF-1–7 | DB baseline valid | Hoshe, post #804 |
|
||||
| 2 | #813 | Schema correct, defaults correct | Hoshe, when Tyre delivers |
|
||||
| 3 | #805 | corp_presence populated, coverage valid, exits non-zero on failure | Hoshe, when Dudley delivers |
|
||||
| 4 | #806 | Binary builds, CSV output correct, seeding in range | Hoshe, when Dudley delivers |
|
||||
| 5 | #807 | Tests 1+2 pass `--stability-check` | Hoshe, when Dudley delivers |
|
||||
| 6 | #808 | Tests 3+4 pass, all 7 signals present | Hoshe, when Dudley delivers |
|
||||
| 7 | #809 | All 4 D-179 tests pass with agents active | Hoshe, when Dudley delivers |
|
||||
|
||||
**Phase 2 is complete only when step 7 passes.** Steps 5 through 7 are the formal exit gate
|
||||
per D-179 and D-183.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Quick Reference — D-179 Stability Criteria
|
||||
|
||||
| Test | Condition | Pass threshold | Run at |
|
||||
|------|-----------|---------------|--------|
|
||||
| 1 | Cold-start convergence | ±5% of equilibrium within 100 game-days | #807 |
|
||||
| 2 | Long-run stability | ±2% drift over 1,000 game-days, zero events | #807 |
|
||||
| 3 | Shock response | Recovery within 200 ticks, no explosions, no negatives | #808 |
|
||||
| 4 | Cross-zone trade balance | Re-stabilizes within 50 ticks | #808 |
|
||||
|
||||
All four must pass simultaneously with corporate agents active (#809) for Phase 2 sign-off.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Test Report: PR #122 — Sprint 33 Economics Simulation
|
||||
|
||||
- **Date:** 2026-04-07
|
||||
- **Build:** `sprint-33/server` → commit `b19bfb32` (Layer 3)
|
||||
- **PR:** #122 (`main ← sprint-33/server`)
|
||||
- **Tickets:** #813, #805, #800, #806, #807, #808, #809
|
||||
- **Spec ref:** D-179 (stability criteria), D-180 (event port), D-181 (signals)
|
||||
- **Tests run:** D-179 stability suite + manual verification
|
||||
- **Passed:** D-179 Tests 1, 2, 3 (Test 4 correctly skipped)
|
||||
- **Failed:** 0
|
||||
- **Gaps:** 1 (D-181 signal coverage)
|
||||
|
||||
---
|
||||
|
||||
## D-179 Stability Test Results
|
||||
|
||||
**Command:** `make econ-sim-stability`
|
||||
|
||||
```
|
||||
Loading economy data from server/data/systems.db...
|
||||
36 commodities, 21 production chains, 31 active nodes, 37 corp presences, 668 gate links
|
||||
Seeding per-corporation productivity (run seed: 0)...
|
||||
37 corp×site productivity records seeded
|
||||
48 corporation behavioral archetypes loaded (inferred where not set in DB)
|
||||
301 nodes with gate connections
|
||||
Seeding per-node shadow economy intensity (D-174)...
|
||||
301 nodes seeded, mean intensity 0.50
|
||||
|
||||
Test 1 (cold-start convergence ±5% at tick 100): PASS max_dev=1.05% worst: GJ 144/medical_goods
|
||||
Test 2 (long-run stability ±2% over ticks 900–999): PASS max_dev=0.00% worst: GJ 144/medical_goods
|
||||
Test 3 (shock response — cascade + recovery ≤200 ticks): PASS no explosions (>20× base), no negatives across 1,116,000 records
|
||||
Test 4 (cross-zone balance re-stabilizes ≤50 ticks): PASS SKIP — no MARK_PRIMARY systems in DB
|
||||
|
||||
All stability checks passed.
|
||||
```
|
||||
|
||||
**Test 4 skip is correct.** The implementation checks for MARK_PRIMARY zone data and skips gracefully when none exists (line 275-277, main.rs). Re-run after copy team delivers #820 (Compact zone assignments).
|
||||
|
||||
---
|
||||
|
||||
## Build Verification
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `make econ-sim` | PASS — compiled in 0.94s (release) |
|
||||
| `make econ-sim-run` | PASS — 111,601 rows (100 ticks × 31 nodes × 36 commodities + header) |
|
||||
| CSV header | PASS — `node_id,commodity_id,supply,demand,price,tick,shadow_intensity,tractus_mark_rate` |
|
||||
| Negative prices | PASS — none found across 1,116,000 records |
|
||||
|
||||
---
|
||||
|
||||
## Model Parameter Verification
|
||||
|
||||
| Parameter | Spec (D-178) | Actual | Result |
|
||||
|-----------|-------------|--------|--------|
|
||||
| α (price adjustment rate) | 0.03 | 0.03 (model.rs:25) | ✅ |
|
||||
| β (damping — implicit in tâtonnement) | 0.4 | 0.4 (trade.rs) | ✅ |
|
||||
| Transport cost per gate hop | 5–12% | 8% flat (trade.rs) | ✅ (within range) |
|
||||
| Fusion fuel demand reduction (on-grid) | ~0.3× | 0.3 (model.rs:39) | ✅ |
|
||||
| Initial stockpile buffer | — | 4× baseline demand (model.rs:32) | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Cross-Checks
|
||||
|
||||
**corp_presence location_type:** The import pipeline resolves each corp's HQ to a specific body or station and stores `location_type = 'body'` or `'station'` per schema (import_economics.py:453-491). The sim binary queries accordingly. Consistent with schema intent.
|
||||
|
||||
**gate_energy_connected join:** Model reads gate energy via `JOIN star_systems` (not directly on bodies/stations). Confirmed the GATE_ENERGY_DEMAND_REDUCTION constant (0.3) is applied to fusion_fuel utility demand for on-grid nodes.
|
||||
|
||||
**Active node count = 31:** Expected. Active nodes are limited to systems with corp presence or population > 0 in the DB. The ~760 active node target (D-178) assumes a fully-authored atlas. Stability tests passing at 31 nodes is encouraging; re-run at scale when atlas authoring progresses.
|
||||
|
||||
---
|
||||
|
||||
## Gaps (Non-Blocking for D-179, Required for Phase 2 Complete)
|
||||
|
||||
### Gap 1 — D-181: Only signals 1 and 7 (partial) are produced [MEDIUM]
|
||||
|
||||
D-181 requires all 7 signals per active node. The `TickRecord` struct contains:
|
||||
- Signal 1 (`price_current`) → `price` ✅
|
||||
- Signal 7 proxy (`shadow_intensity`) → present but `official_coverage_ratio` (1 - shadow_intensity) is not computed ⚠️
|
||||
|
||||
**Missing from `TickRecord` and CSV output:**
|
||||
- Signal 2: `price_trend` — direction + rate of change over last N ticks
|
||||
- Signal 3: `trade_flow_volume` — freight volume through node (computed by trade.rs but not emitted)
|
||||
- Signal 4: `corporate_presence` — which corps operate here (static, in DB, not per-tick)
|
||||
- Signal 5: `stockpile_weeks` — `stockpile` IS tracked in `CommodityState` but not in `TickRecord`
|
||||
- Signal 6: `production_vs_baseline` — not computed or tracked
|
||||
|
||||
D-181: "Phase 2 sim must produce all 7 signals. Phase 3 determines how the player accesses them."
|
||||
|
||||
Signals 4 and 7 are reasonable to defer (static data from DB + derivable from shadow_intensity). Signals 2, 3, 5, 6 require additions to `TickRecord` and `output.rs`. Signals 5 (`stockpile_weeks`) is the easiest — `stockpile` is already computed in the model; it just needs to be added to the output struct.
|
||||
|
||||
**Recommendation:** Open a follow-up task for signal completeness. Does not block D-179 tests or PR merge if the team accepts iterative delivery (D-183 allows this). Block merge only if Phase 2 is declared complete.
|
||||
|
||||
### Gap 2 — Test 3: Warm-start proxy, not deliberate injection [LOW]
|
||||
|
||||
D-179 Test 3 spec: "After a single supply shock, cascade propagates realistically; recovery within 200 ticks; no price explosions or negative prices."
|
||||
|
||||
The implementation uses the warm-start disturbance (4× buffer initialization) as the proxy shock and verifies no explosions across 1,000 ticks. This tests the stability envelope but does not test explicit cascade propagation or recovery time measurement. The code comments acknowledge this: "Full shock-response testing will be added when D-180 event port is implemented."
|
||||
|
||||
**Verdict:** Acceptable for this sprint given D-180 port isn't implemented. Test 3 as implemented validates the core stability guarantee. The stricter cascade test follows once the event port lands. Low priority for PR block.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
D-179 passes cleanly. The simulation is stable, builds clean, produces correct output.
|
||||
|
||||
**Recommend PR merge with one follow-up task:**
|
||||
1. Add signals 2, 3, 5, 6 to TickRecord and CSV output (D-181 completeness)
|
||||
|
||||
**Must re-run `make econ-sim-stability` after:**
|
||||
- Copy team delivers #820 (Compact MARK_PRIMARY assignments) — enables Test 4
|
||||
- Atlas authoring reaches higher node counts — validates stability at scale
|
||||
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"qdrant_url": "http://tower-of-joy:6333",
|
||||
"ollama_url": "http://tower-of-joy:11434",
|
||||
"stable_audio_url": "http://tower-of-joy:11500",
|
||||
"trellis_url": "http://tower-of-joy:11510",
|
||||
"collection": "commonwealth",
|
||||
"embed_model": "nomic-embed-text",
|
||||
"embed_dimensions": 768
|
||||
"trellis_url": "http://tower-of-joy:11510"
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Count indexed documents in Qdrant. Whitelistable command.
|
||||
exec python3 "$(dirname "$0")/qdrant_connector.py" count
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Check Qdrant and ollama connectivity. Whitelistable command.
|
||||
exec python3 "$(dirname "$0")/qdrant_connector.py" health
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Index a file into Qdrant. Whitelistable command.
|
||||
# Usage: qdrant-index <filepath>
|
||||
exec python3 "$(dirname "$0")/qdrant_connector.py" index-file "$@"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Search the Qdrant document index. Whitelistable command.
|
||||
# Usage: qdrant-search "query text"
|
||||
exec python3 "$(dirname "$0")/qdrant_connector.py" search "$@"
|
||||
@@ -1,437 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Settled Reach Qdrant + Ollama Connector — mini MCP for vector search.
|
||||
|
||||
Usage:
|
||||
python3 qdrant_connector.py health
|
||||
python3 qdrant_connector.py create-collection
|
||||
python3 qdrant_connector.py search "some query text"
|
||||
python3 qdrant_connector.py index <id> "text to embed" [--metadata key=value ...]
|
||||
python3 qdrant_connector.py index-file <filepath>
|
||||
python3 qdrant_connector.py count
|
||||
python3 qdrant_connector.py --help
|
||||
|
||||
Requires only Python 3 stdlib (no pip dependencies).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from common import ensure_venv # noqa: E402
|
||||
|
||||
ensure_venv()
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths / Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
CONFIG_PATH = SCRIPT_DIR / "config.json"
|
||||
|
||||
|
||||
def load_config():
|
||||
"""Load config.json."""
|
||||
with open(CONFIG_PATH, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP helpers (stdlib only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def http_request(url, method="GET", data=None, headers=None, timeout=30):
|
||||
"""
|
||||
Perform an HTTP request using urllib. Returns (status_code, parsed_json | raw_text).
|
||||
"""
|
||||
hdrs = {"Content-Type": "application/json"}
|
||||
if headers:
|
||||
hdrs.update(headers)
|
||||
|
||||
body = None
|
||||
if data is not None:
|
||||
body = json.dumps(data).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(url, data=body, headers=hdrs, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
try:
|
||||
return resp.status, json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return resp.status, raw
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read().decode("utf-8") if exc.fp else ""
|
||||
try:
|
||||
return exc.code, json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return exc.code, raw
|
||||
except urllib.error.URLError as exc:
|
||||
raise ConnectionError(f"Cannot reach {url}: {exc.reason}") from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Embedding helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def embed_text(cfg, text):
|
||||
"""
|
||||
Call ollama /api/embed to get an embedding vector for the given text.
|
||||
Returns a list of floats.
|
||||
"""
|
||||
url = f"{cfg['ollama_url']}/api/embed"
|
||||
payload = {"model": cfg["embed_model"], "input": text}
|
||||
status, resp = http_request(url, method="POST", data=payload)
|
||||
if status != 200:
|
||||
raise RuntimeError(f"Ollama embed failed (HTTP {status}): {resp}")
|
||||
# ollama returns {"embeddings": [[...]]}
|
||||
embeddings = resp.get("embeddings")
|
||||
if not embeddings or not embeddings[0]:
|
||||
raise RuntimeError(f"Ollama returned empty embeddings: {resp}")
|
||||
return embeddings[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Qdrant helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def qdrant_create_collection(cfg):
|
||||
"""Create (or recreate) the Qdrant collection."""
|
||||
url = f"{cfg['qdrant_url']}/collections/{cfg['collection']}"
|
||||
payload = {
|
||||
"vectors": {
|
||||
"size": cfg["embed_dimensions"],
|
||||
"distance": "Cosine",
|
||||
}
|
||||
}
|
||||
status, resp = http_request(url, method="PUT", data=payload)
|
||||
return status, resp
|
||||
|
||||
|
||||
def qdrant_upsert(cfg, points):
|
||||
"""Upsert a list of points into Qdrant."""
|
||||
url = f"{cfg['qdrant_url']}/collections/{cfg['collection']}/points"
|
||||
payload = {"points": points}
|
||||
status, resp = http_request(url, method="PUT", data=payload)
|
||||
return status, resp
|
||||
|
||||
|
||||
def qdrant_search(cfg, vector, limit=5):
|
||||
"""Search Qdrant by vector."""
|
||||
url = f"{cfg['qdrant_url']}/collections/{cfg['collection']}/points/query"
|
||||
payload = {"query": vector, "limit": limit, "with_payload": True}
|
||||
status, resp = http_request(url, method="POST", data=payload)
|
||||
return status, resp
|
||||
|
||||
|
||||
def qdrant_collection_info(cfg):
|
||||
"""Get collection info (includes point count)."""
|
||||
url = f"{cfg['qdrant_url']}/collections/{cfg['collection']}"
|
||||
status, resp = http_request(url, method="GET")
|
||||
return status, resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chunking helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def chunk_markdown(text, source_file=""):
|
||||
"""
|
||||
Split markdown by headings (# or ##). Returns a list of dicts:
|
||||
{"heading": str, "text": str, "chunk_index": int, "source_file": str}
|
||||
"""
|
||||
# Split on lines that start with one or two hashes
|
||||
pattern = re.compile(r"^(#{1,2})\s+(.+)$", re.MULTILINE)
|
||||
matches = list(pattern.finditer(text))
|
||||
|
||||
chunks = []
|
||||
|
||||
if not matches:
|
||||
# No headings — treat entire file as one chunk
|
||||
stripped = text.strip()
|
||||
if stripped:
|
||||
chunks.append({
|
||||
"heading": Path(source_file).stem if source_file else "untitled",
|
||||
"text": stripped,
|
||||
"chunk_index": 0,
|
||||
"source_file": source_file,
|
||||
})
|
||||
return chunks
|
||||
|
||||
# Text before the first heading
|
||||
preamble = text[: matches[0].start()].strip()
|
||||
if preamble:
|
||||
chunks.append({
|
||||
"heading": "(preamble)",
|
||||
"text": preamble,
|
||||
"chunk_index": 0,
|
||||
"source_file": source_file,
|
||||
})
|
||||
|
||||
for i, match in enumerate(matches):
|
||||
heading = match.group(2).strip()
|
||||
start = match.end()
|
||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||||
body = text[start:end].strip()
|
||||
if body:
|
||||
chunks.append({
|
||||
"heading": heading,
|
||||
"text": body,
|
||||
"chunk_index": len(chunks),
|
||||
"source_file": source_file,
|
||||
})
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def text_to_point_id(text):
|
||||
"""Deterministic integer ID from a string (unsigned 64-bit range for Qdrant)."""
|
||||
h = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
# Qdrant accepts unsigned 64-bit integer IDs
|
||||
return int(h[:16], 16)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_health(cfg):
|
||||
"""Check connectivity to Qdrant and Ollama."""
|
||||
results = {}
|
||||
|
||||
# Qdrant health
|
||||
try:
|
||||
status, resp = http_request(f"{cfg['qdrant_url']}/healthz", method="GET", timeout=5)
|
||||
results["qdrant"] = {"reachable": True, "status": status, "response": resp}
|
||||
except ConnectionError as exc:
|
||||
results["qdrant"] = {"reachable": False, "error": str(exc)}
|
||||
|
||||
# Ollama health
|
||||
try:
|
||||
status, resp = http_request(f"{cfg['ollama_url']}/api/tags", method="GET", timeout=5)
|
||||
results["ollama"] = {"reachable": True, "status": status}
|
||||
# List available models for convenience
|
||||
if isinstance(resp, dict) and "models" in resp:
|
||||
results["ollama"]["models"] = [m.get("name", "?") for m in resp["models"]]
|
||||
except ConnectionError as exc:
|
||||
results["ollama"] = {"reachable": False, "error": str(exc)}
|
||||
|
||||
all_ok = all(v.get("reachable", False) for v in results.values())
|
||||
return {"ok": all_ok, "services": results}
|
||||
|
||||
|
||||
def cmd_create_collection(cfg):
|
||||
"""Create the Qdrant collection."""
|
||||
try:
|
||||
status, resp = qdrant_create_collection(cfg)
|
||||
success = status in (200, 201)
|
||||
return {"ok": success, "status": status, "response": resp}
|
||||
except ConnectionError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
def cmd_search(cfg, query_text):
|
||||
"""Embed query text and search Qdrant."""
|
||||
try:
|
||||
vector = embed_text(cfg, query_text)
|
||||
status, resp = qdrant_search(cfg, vector)
|
||||
if status != 200:
|
||||
return {"ok": False, "status": status, "error": resp}
|
||||
|
||||
# Extract the points from the response
|
||||
points = resp.get("result", {}).get("points", resp.get("result", []))
|
||||
results = []
|
||||
if isinstance(points, list):
|
||||
for pt in points:
|
||||
results.append({
|
||||
"id": pt.get("id"),
|
||||
"score": pt.get("score"),
|
||||
"payload": pt.get("payload", {}),
|
||||
})
|
||||
return {"ok": True, "query": query_text, "count": len(results), "results": results}
|
||||
except (ConnectionError, RuntimeError) as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
def cmd_index(cfg, point_id_str, text, metadata=None):
|
||||
"""Embed text and upsert a single point."""
|
||||
try:
|
||||
vector = embed_text(cfg, text)
|
||||
|
||||
# Build a numeric ID from the provided string
|
||||
try:
|
||||
point_id = int(point_id_str)
|
||||
except ValueError:
|
||||
point_id = text_to_point_id(point_id_str)
|
||||
|
||||
payload = metadata or {}
|
||||
payload["text"] = text
|
||||
|
||||
point = {"id": point_id, "vector": vector, "payload": payload}
|
||||
status, resp = qdrant_upsert(cfg, [point])
|
||||
success = status in (200, 201)
|
||||
return {"ok": success, "status": status, "point_id": point_id, "response": resp}
|
||||
except (ConnectionError, RuntimeError) as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
def cmd_index_file(cfg, filepath):
|
||||
"""Read a markdown file, chunk it, embed each chunk, and upsert all to Qdrant."""
|
||||
fpath = Path(filepath).resolve()
|
||||
if not fpath.exists():
|
||||
return {"ok": False, "error": f"File not found: {fpath}"}
|
||||
|
||||
text = fpath.read_text(encoding="utf-8")
|
||||
source = str(fpath)
|
||||
chunks = chunk_markdown(text, source_file=source)
|
||||
|
||||
if not chunks:
|
||||
return {"ok": False, "error": "No content chunks extracted from file"}
|
||||
|
||||
points = []
|
||||
errors = []
|
||||
for chunk in chunks:
|
||||
chunk_key = f"{source}::{chunk['heading']}::{chunk['chunk_index']}"
|
||||
point_id = text_to_point_id(chunk_key)
|
||||
try:
|
||||
vector = embed_text(cfg, chunk["text"])
|
||||
except (ConnectionError, RuntimeError) as exc:
|
||||
errors.append({"chunk": chunk["heading"], "error": str(exc)})
|
||||
continue
|
||||
|
||||
points.append({
|
||||
"id": point_id,
|
||||
"vector": vector,
|
||||
"payload": {
|
||||
"source_file": chunk["source_file"],
|
||||
"heading": chunk["heading"],
|
||||
"chunk_index": chunk["chunk_index"],
|
||||
"text": chunk["text"],
|
||||
},
|
||||
})
|
||||
|
||||
if not points:
|
||||
return {"ok": False, "error": "All chunks failed to embed", "details": errors}
|
||||
|
||||
try:
|
||||
status, resp = qdrant_upsert(cfg, points)
|
||||
success = status in (200, 201)
|
||||
result = {
|
||||
"ok": success,
|
||||
"status": status,
|
||||
"file": source,
|
||||
"chunks_indexed": len(points),
|
||||
"chunks_failed": len(errors),
|
||||
"response": resp,
|
||||
}
|
||||
if errors:
|
||||
result["errors"] = errors
|
||||
return result
|
||||
except ConnectionError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
def cmd_count(cfg):
|
||||
"""Return the point count in the collection."""
|
||||
try:
|
||||
status, resp = qdrant_collection_info(cfg)
|
||||
if status != 200:
|
||||
return {"ok": False, "status": status, "error": resp}
|
||||
# Qdrant returns {"result": {"points_count": N, ...}}
|
||||
result_data = resp.get("result", {})
|
||||
count = result_data.get("points_count", result_data.get("vectors_count", "unknown"))
|
||||
return {"ok": True, "collection": cfg["collection"], "points_count": count}
|
||||
except ConnectionError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HELP_TEXT = """\
|
||||
Settled Reach Qdrant + Ollama Connector
|
||||
|
||||
Usage:
|
||||
qdrant_connector.py health Check Qdrant & Ollama connectivity
|
||||
qdrant_connector.py create-collection Create the vector collection
|
||||
qdrant_connector.py search "<query text>" Embed query and search Qdrant
|
||||
qdrant_connector.py index <id> "<text>" [--metadata k=v ...]
|
||||
Embed text and upsert one point
|
||||
qdrant_connector.py index-file <filepath> Chunk a markdown file and index all chunks
|
||||
qdrant_connector.py count Show point count in collection
|
||||
qdrant_connector.py --help Show this help message
|
||||
|
||||
All output is JSON on stdout. Uses only Python stdlib (no pip install needed).
|
||||
|
||||
Config: {config}
|
||||
""".format(config=CONFIG_PATH)
|
||||
|
||||
|
||||
def parse_metadata(args):
|
||||
"""Parse --metadata key=value pairs from argument list."""
|
||||
metadata = {}
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == "--metadata" and i + 1 < len(args):
|
||||
i += 1
|
||||
while i < len(args) and "=" in args[i] and not args[i].startswith("--"):
|
||||
key, _, value = args[i].partition("=")
|
||||
metadata[key] = value
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
return metadata
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h", "help"):
|
||||
print(HELP_TEXT)
|
||||
sys.exit(0)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
|
||||
try:
|
||||
cfg = load_config()
|
||||
except (FileNotFoundError, json.JSONDecodeError) as exc:
|
||||
print(json.dumps({"ok": False, "error": f"Config error: {exc}"}, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
if cmd == "health":
|
||||
result = cmd_health(cfg)
|
||||
elif cmd == "create-collection":
|
||||
result = cmd_create_collection(cfg)
|
||||
elif cmd == "search":
|
||||
if len(sys.argv) < 3:
|
||||
result = {"ok": False, "error": "search requires a query text argument"}
|
||||
else:
|
||||
result = cmd_search(cfg, sys.argv[2])
|
||||
elif cmd == "index":
|
||||
if len(sys.argv) < 4:
|
||||
result = {"ok": False, "error": "index requires <id> and <text> arguments"}
|
||||
else:
|
||||
metadata = parse_metadata(sys.argv[4:])
|
||||
result = cmd_index(cfg, sys.argv[2], sys.argv[3], metadata)
|
||||
elif cmd == "index-file":
|
||||
if len(sys.argv) < 3:
|
||||
result = {"ok": False, "error": "index-file requires a <filepath> argument"}
|
||||
else:
|
||||
result = cmd_index_file(cfg, sys.argv[2])
|
||||
elif cmd == "count":
|
||||
result = cmd_count(cfg)
|
||||
else:
|
||||
result = {"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."}
|
||||
|
||||
print(json.dumps(result, indent=2))
|
||||
sys.exit(0 if result.get("ok") else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Generated
+448
@@ -0,0 +1,448 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.59"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "econ-sim"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"rand",
|
||||
"rand_chacha",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.184"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "econ-sim"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Settled Reach economics simulation — Layer 1 Leontief production + price adjustment"
|
||||
|
||||
[lib]
|
||||
name = "econ_sim"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "econ-sim"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
rand = "0.9"
|
||||
rand_chacha = "0.9"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Layer 3: Corporate behavioral agents (D-178).
|
||||
//!
|
||||
//! Six behavioral archetypes from D-175 / Burnelli-Sheldon:
|
||||
//!
|
||||
//! Producer — maximises output, low trade aggression
|
||||
//! Distributor — volume-focused, aggressive trade, thin margin
|
||||
//! Specialist — premium pricing, narrow focus, low trade
|
||||
//! Monopolist — withholds supply to maintain scarcity premium
|
||||
//! Cooperative — fair pricing, community stability orientation
|
||||
//! Intermediary — arbitrage-focused, high trade, lower own production
|
||||
//!
|
||||
//! Archetypes are loaded from `corporations.behavioral_archetype` in the DB.
|
||||
//! If NULL, the archetype is inferred from the `specialization` field text.
|
||||
//!
|
||||
//! Parameters apply to per-corp production in each simulation tick.
|
||||
//! Trade-layer archetype effects (corp-level bid/ask) are deferred to a
|
||||
//! future sprint when the event port (D-180) and IPC bridge are in place.
|
||||
//!
|
||||
//! The D-180 event port stubs previously in this file have been replaced by
|
||||
//! the full implementation in `events.rs`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Archetype enum
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Archetype {
|
||||
Producer,
|
||||
Distributor,
|
||||
Specialist,
|
||||
Monopolist,
|
||||
Cooperative,
|
||||
Intermediary,
|
||||
}
|
||||
|
||||
impl Archetype {
|
||||
/// Parse from DB string (case-insensitive).
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().trim() {
|
||||
"producer" => Some(Archetype::Producer),
|
||||
"distributor" => Some(Archetype::Distributor),
|
||||
"specialist" => Some(Archetype::Specialist),
|
||||
"monopolist" => Some(Archetype::Monopolist),
|
||||
"cooperative" => Some(Archetype::Cooperative),
|
||||
"intermediary" => Some(Archetype::Intermediary),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Infer archetype from `specialization` field free text.
|
||||
///
|
||||
/// Heuristic: look for domain keywords that map to behavioral patterns.
|
||||
/// Falls back to `Producer` (the most neutral, maximises output).
|
||||
pub fn infer_from_specialization(spec: &str) -> Self {
|
||||
let s = spec.to_lowercase();
|
||||
if s.contains("freight")
|
||||
|| s.contains("logistics")
|
||||
|| s.contains("hauler")
|
||||
|| s.contains("cargo")
|
||||
{
|
||||
Archetype::Distributor
|
||||
} else if s.contains("arbitr")
|
||||
|| s.contains("trading company")
|
||||
|| s.contains("brokerage")
|
||||
|| s.contains("intermediar")
|
||||
{
|
||||
Archetype::Intermediary
|
||||
} else if s.contains("cooperative") || s.contains("mutu") || s.contains("negociant") {
|
||||
Archetype::Cooperative
|
||||
} else if s.contains("whisky")
|
||||
|| s.contains("wine")
|
||||
|| s.contains("lager")
|
||||
|| s.contains("precision")
|
||||
|| s.contains("bespoke")
|
||||
|| s.contains("longevity")
|
||||
{
|
||||
Archetype::Specialist
|
||||
} else if s.contains("infrastructure") && (s.contains("gate") || s.contains("span")) {
|
||||
// Gate Corp maintains infrastructure monopoly
|
||||
Archetype::Monopolist
|
||||
} else {
|
||||
Archetype::Producer
|
||||
}
|
||||
}
|
||||
|
||||
/// Behavioral parameters for this archetype.
|
||||
pub fn params(self) -> ArchetypeParams {
|
||||
match self {
|
||||
// Producer: higher output, normal trade participation
|
||||
Archetype::Producer => ArchetypeParams {
|
||||
production_scale: 1.15,
|
||||
supply_withheld: 0.0,
|
||||
price_premium: 0.0,
|
||||
},
|
||||
// Distributor: leaner production, price discount to move volume
|
||||
Archetype::Distributor => ArchetypeParams {
|
||||
production_scale: 0.90,
|
||||
supply_withheld: 0.0,
|
||||
price_premium: -0.03,
|
||||
},
|
||||
// Specialist: normal production, commands a premium
|
||||
Archetype::Specialist => ArchetypeParams {
|
||||
production_scale: 1.0,
|
||||
supply_withheld: 0.0,
|
||||
price_premium: 0.10,
|
||||
},
|
||||
// Monopolist: constrained output, withholds supply, premium
|
||||
Archetype::Monopolist => ArchetypeParams {
|
||||
production_scale: 0.80,
|
||||
supply_withheld: 0.25,
|
||||
price_premium: 0.20,
|
||||
},
|
||||
// Cooperative: normal production, slight discount for community access
|
||||
Archetype::Cooperative => ArchetypeParams {
|
||||
production_scale: 1.0,
|
||||
supply_withheld: 0.0,
|
||||
price_premium: -0.05,
|
||||
},
|
||||
// Intermediary: lower own production, relies on traded goods
|
||||
Archetype::Intermediary => ArchetypeParams {
|
||||
production_scale: 0.70,
|
||||
supply_withheld: 0.0,
|
||||
price_premium: -0.01,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parameter struct
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-tick behavioral parameters for a corporation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ArchetypeParams {
|
||||
/// Multiplier on BASELINE_CAPACITY for this corp's production.
|
||||
pub production_scale: f64,
|
||||
/// Fraction of this tick's output that is withheld from the node's
|
||||
/// stockpile (Monopolist strategy). Range [0.0, 1.0].
|
||||
pub supply_withheld: f64,
|
||||
/// Additive price premium on goods this corp produces.
|
||||
/// Applied to the node price signal for their primary commodity.
|
||||
/// Positive → price pressure up. Negative → price pressure down.
|
||||
pub price_premium: f64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Corpus load
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build archetype map from the raw DB data supplied by the caller.
|
||||
///
|
||||
/// `corp_data`: Vec of (corp_id, behavioral_archetype_opt, specialization_opt)
|
||||
pub fn build_archetype_map(
|
||||
corp_data: Vec<(String, Option<String>, Option<String>)>,
|
||||
) -> BTreeMap<String, Archetype> {
|
||||
corp_data
|
||||
.into_iter()
|
||||
.map(|(corp_id, archetype_str, specialization)| {
|
||||
let archetype = archetype_str
|
||||
.as_deref()
|
||||
.and_then(Archetype::from_str)
|
||||
.unwrap_or_else(|| {
|
||||
specialization
|
||||
.as_deref()
|
||||
.map(Archetype::infer_from_specialization)
|
||||
.unwrap_or(Archetype::Producer)
|
||||
});
|
||||
(corp_id, archetype)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Currency zones, exchange rates, and shadow economy seeding (D-171, D-172, D-174).
|
||||
//!
|
||||
//! Three currencies (D-171):
|
||||
//! Tractus — Reach-wide standard, numeraire for all simulation pricing.
|
||||
//! Mark — Compact of Westphalia, ~3% conversion friction on cross-zone trade.
|
||||
//! Sol — Earth legacy, modeled as shadow commodity (not a numeraire).
|
||||
//!
|
||||
//! Exchange rate: floating Tractus/Mark rate driven by net cross-zone trade balance.
|
||||
//! Initialized at 1.0 (parity). Adjusted each tick by net flow signal × α_fx.
|
||||
//!
|
||||
//! Shadow economy (D-174): per-node intensity (0.0–1.0) seeded from political
|
||||
//! zone, hop distance, gate topology, and currency zone.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use rand::SeedableRng;
|
||||
use rand_chacha::ChaCha8Rng;
|
||||
|
||||
use crate::db::Economy;
|
||||
use crate::prng::{derive_seed, standard_normal};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Cross-zone conversion friction (D-172): applied when trade crosses
|
||||
/// TRACTUS_PRIMARY ↔ MARK_PRIMARY boundaries.
|
||||
pub const ZONE_FRICTION: f64 = 0.03;
|
||||
|
||||
/// Exchange rate adjustment rate per tick: how strongly net cross-zone
|
||||
/// flow imbalance moves the Tractus/Mark rate.
|
||||
const ALPHA_FX: f64 = 0.002;
|
||||
|
||||
/// Exchange rate bounds (D-171): hard clamp to prevent runaway divergence.
|
||||
const FX_RATE_MIN: f64 = 0.5;
|
||||
const FX_RATE_MAX: f64 = 2.0;
|
||||
|
||||
/// Maximum shadow economy intensity for dead-end topology bonus.
|
||||
const DEAD_END_SHADOW_BONUS: f64 = 0.10;
|
||||
|
||||
/// Shadow economy noise standard deviation (log-normal jitter per node).
|
||||
const SHADOW_NOISE_SIGMA: f64 = 0.08;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shadow economy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-node shadow economy intensity (0.0–1.0).
|
||||
///
|
||||
/// Seeds from: political zone, hop distance, gate topology, currency zone.
|
||||
/// Reference bands (D-174): core ~0.0–0.2, mid-reach ~0.3–0.6, frontier ~0.6–0.9.
|
||||
pub struct ShadowEconomy {
|
||||
/// system_id → shadow intensity [0.0, 1.0]
|
||||
pub intensity: BTreeMap<String, f64>,
|
||||
}
|
||||
|
||||
pub fn seed_shadow_economy(economy: &Economy, run_seed: u64) -> ShadowEconomy {
|
||||
let mut intensity = BTreeMap::new();
|
||||
|
||||
for (system_id, sys) in &economy.systems {
|
||||
// Base from hop distance: clamp to [0.0, 0.6] range
|
||||
let hop_base = (sys.hop_distance as f64 / 15.0).clamp(0.0, 0.6);
|
||||
|
||||
// Political zone modifier
|
||||
let zone_mod = match sys.political_zone.as_deref() {
|
||||
Some("institutional_core") => -0.25,
|
||||
Some("earth_sphere") | Some("diplomatic_periphery") => -0.15,
|
||||
Some("commercial_mid_reach") | Some("commercial_periphery") => 0.0,
|
||||
Some("research_periphery") => 0.05,
|
||||
Some("contested_frontier") | Some("deep_reach_isolate") => 0.15,
|
||||
_ => 0.0,
|
||||
};
|
||||
|
||||
// Gate topology: dead-end systems are harder to police
|
||||
let topology_mod = if sys.gate_topology.as_deref() == Some("dead_end") {
|
||||
DEAD_END_SHADOW_BONUS
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Currency zone: Compact friction drives principled shadow economy
|
||||
let currency_mod = if sys.currency_zone == "MARK_PRIMARY" {
|
||||
0.20
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let base = (hop_base + zone_mod + topology_mod + currency_mod).clamp(0.0, 0.95);
|
||||
|
||||
// Per-node PRNG jitter (Box-Muller)
|
||||
let node_seed = derive_seed(run_seed, system_id);
|
||||
let mut rng = ChaCha8Rng::seed_from_u64(node_seed);
|
||||
let noise = standard_normal(&mut rng) * SHADOW_NOISE_SIGMA;
|
||||
|
||||
let final_intensity = (base + noise).clamp(0.0, 1.0);
|
||||
intensity.insert(system_id.clone(), final_intensity);
|
||||
}
|
||||
|
||||
ShadowEconomy { intensity }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exchange rate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Mutable exchange rate state updated each tick.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CurrencyState {
|
||||
/// Tractus/Mark rate: how many Marks 1 Tractus buys.
|
||||
/// 1.0 = parity. >1.0 = Tractus stronger (Mark depreciated).
|
||||
pub tractus_mark_rate: f64,
|
||||
/// Net cross-zone Tractus→Mark commodity flow accumulated this tick.
|
||||
/// Positive = Tractus zone exporting to Mark zone (Mark zone demand >).
|
||||
pub net_cross_zone_flow: f64,
|
||||
}
|
||||
|
||||
impl CurrencyState {
|
||||
pub fn new() -> Self {
|
||||
CurrencyState {
|
||||
tractus_mark_rate: 1.0,
|
||||
net_cross_zone_flow: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjust exchange rate from net cross-zone trade imbalance.
|
||||
///
|
||||
/// If Tractus zone exports more than it imports from the Mark zone,
|
||||
/// demand for Tractus rises → Tractus appreciates (rate increases).
|
||||
pub fn update_rate(&mut self) {
|
||||
// Positive net flow (Tractus→Mark) → Tractus stronger → rate rises
|
||||
let adjustment = ALPHA_FX * self.net_cross_zone_flow;
|
||||
self.tractus_mark_rate =
|
||||
(self.tractus_mark_rate + adjustment).clamp(FX_RATE_MIN, FX_RATE_MAX);
|
||||
self.net_cross_zone_flow = 0.0; // reset accumulator for next tick
|
||||
}
|
||||
|
||||
/// Apply an additive exchange rate delta from an `ExchangeShock` event (D-180).
|
||||
///
|
||||
/// The result is clamped to the hard bounds `[FX_RATE_MIN, FX_RATE_MAX]`.
|
||||
pub fn apply_exchange_shock(&mut self, delta: f64) {
|
||||
if delta != 0.0 {
|
||||
self.tractus_mark_rate =
|
||||
(self.tractus_mark_rate + delta).clamp(FX_RATE_MIN, FX_RATE_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport cost factor from `from_zone` to `to_zone`.
|
||||
///
|
||||
/// Cross-zone (TRACTUS ↔ MARK) incurs an additional 3% friction.
|
||||
/// Sol (GJ 0, MIXED) neither adds nor removes friction.
|
||||
pub fn zone_friction_factor(&self, from_zone: &str, to_zone: &str) -> f64 {
|
||||
let cross_zone = (from_zone == "TRACTUS_PRIMARY" && to_zone == "MARK_PRIMARY")
|
||||
|| (from_zone == "MARK_PRIMARY" && to_zone == "TRACTUS_PRIMARY");
|
||||
if cross_zone {
|
||||
ZONE_FRICTION
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! Database loading — reads economy data from systems.db.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Commodity {
|
||||
pub id: String,
|
||||
// Display name — used in reporting (#807+):
|
||||
#[allow(dead_code)]
|
||||
pub name: String,
|
||||
pub tier: String,
|
||||
pub base_price: f64,
|
||||
// Used by Layer 2+ pricing (#807, #808):
|
||||
#[allow(dead_code)]
|
||||
pub elasticity: String,
|
||||
#[allow(dead_code)]
|
||||
pub production_ubiquity: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
pub demand_model: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChainInput {
|
||||
pub commodity_id: String,
|
||||
pub quantity: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProductionChain {
|
||||
pub chain_id: String,
|
||||
pub output_commodity_id: String,
|
||||
pub output_quantity: f64,
|
||||
// Used by Layer 2+ for location-constrained production (#807):
|
||||
#[allow(dead_code)]
|
||||
pub location_bound: bool,
|
||||
pub inputs: Vec<ChainInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CorpPresence {
|
||||
pub corp_id: String,
|
||||
pub system_id: String,
|
||||
pub primary_operation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SystemInfo {
|
||||
pub system_id: String,
|
||||
// Used for display/reporting in #807+:
|
||||
#[allow(dead_code)]
|
||||
pub proper_name: Option<String>,
|
||||
pub population: i64,
|
||||
pub cultural_corridor: Option<String>,
|
||||
pub gate_energy_connected: bool,
|
||||
/// Currency zone: TRACTUS_PRIMARY | MARK_PRIMARY | MIXED (D-171, D-172)
|
||||
pub currency_zone: String,
|
||||
/// Hop count from the nearest gateway — used for shadow economy seeding (D-174)
|
||||
pub hop_distance: i64,
|
||||
/// Gate topology type — used for shadow economy seeding (D-174)
|
||||
pub gate_topology: Option<String>,
|
||||
/// Political zone — used for shadow economy seeding (D-174)
|
||||
pub political_zone: Option<String>,
|
||||
}
|
||||
|
||||
/// A directed gate link between two systems.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GateLink {
|
||||
pub from_system_id: String,
|
||||
pub to_system_id: String,
|
||||
}
|
||||
|
||||
/// The complete economics dataset loaded from systems.db.
|
||||
pub struct Economy {
|
||||
pub commodities: Vec<Commodity>,
|
||||
pub commodity_map: BTreeMap<String, Commodity>,
|
||||
pub chains: Vec<ProductionChain>,
|
||||
/// Map: output_commodity_id → list of chains that produce it
|
||||
pub chains_by_output: BTreeMap<String, Vec<ProductionChain>>,
|
||||
/// Map: system_id → SystemInfo
|
||||
pub systems: BTreeMap<String, SystemInfo>,
|
||||
pub corp_presences: Vec<CorpPresence>,
|
||||
/// Map: system_id → list of corp presences
|
||||
pub presences_by_system: BTreeMap<String, Vec<CorpPresence>>,
|
||||
/// Bidirectional gate links (transport graph)
|
||||
pub gate_links: Vec<GateLink>,
|
||||
/// Raw corp data for archetype inference: (corp_id, behavioral_archetype?, specialization?)
|
||||
pub corp_archetype_data: Vec<(String, Option<String>, Option<String>)>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn resolve_db_path(explicit: Option<PathBuf>) -> PathBuf {
|
||||
if let Some(p) = explicit {
|
||||
return p;
|
||||
}
|
||||
let mut dir = std::env::current_dir().expect("Cannot determine CWD");
|
||||
loop {
|
||||
let candidate = dir.join("server").join("data").join("systems.db");
|
||||
if candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
if !dir.pop() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
eprintln!("error: cannot find server/data/systems.db — pass --db explicitly");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
pub fn open_db(path: &PathBuf) -> Connection {
|
||||
let conn = Connection::open(path).unwrap_or_else(|e| {
|
||||
eprintln!("error: cannot open {}: {}", path.display(), e);
|
||||
process::exit(1);
|
||||
});
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
|
||||
.expect("PRAGMA setup failed");
|
||||
conn
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loaders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn load_commodities(conn: &Connection) -> Vec<Commodity> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT commodity_id, name, tier, base_price, elasticity,
|
||||
production_ubiquity, demand_model
|
||||
FROM commodities ORDER BY commodity_id",
|
||||
)
|
||||
.expect("prepare commodities");
|
||||
|
||||
stmt.query_map([], |row| {
|
||||
Ok(Commodity {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
tier: row.get(2)?,
|
||||
base_price: row.get(3)?,
|
||||
elasticity: row.get(4)?,
|
||||
production_ubiquity: row.get(5)?,
|
||||
demand_model: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.expect("query commodities")
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn load_chains(conn: &Connection) -> Vec<ProductionChain> {
|
||||
let mut chain_stmt = conn
|
||||
.prepare(
|
||||
"SELECT chain_id, output_commodity_id, output_quantity, location_bound
|
||||
FROM production_chains ORDER BY chain_id",
|
||||
)
|
||||
.expect("prepare chains");
|
||||
|
||||
let mut chains: Vec<ProductionChain> = chain_stmt
|
||||
.query_map([], |row| {
|
||||
Ok(ProductionChain {
|
||||
chain_id: row.get(0)?,
|
||||
output_commodity_id: row.get(1)?,
|
||||
output_quantity: row.get(2)?,
|
||||
location_bound: row.get::<_, i32>(3)? != 0,
|
||||
inputs: Vec::new(),
|
||||
})
|
||||
})
|
||||
.expect("query chains")
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
// Load inputs for each chain
|
||||
let mut input_stmt = conn
|
||||
.prepare(
|
||||
"SELECT chain_id, input_commodity_id, quantity
|
||||
FROM chain_inputs ORDER BY chain_id, input_commodity_id",
|
||||
)
|
||||
.expect("prepare chain_inputs");
|
||||
|
||||
let all_inputs: Vec<(String, String, f64)> = input_stmt
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||
.expect("query chain_inputs")
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
// Build index of chain_id → inputs
|
||||
let mut input_map: BTreeMap<String, Vec<ChainInput>> = BTreeMap::new();
|
||||
for (chain_id, commodity_id, quantity) in all_inputs {
|
||||
input_map.entry(chain_id).or_default().push(ChainInput {
|
||||
commodity_id,
|
||||
quantity,
|
||||
});
|
||||
}
|
||||
|
||||
for chain in &mut chains {
|
||||
if let Some(inputs) = input_map.remove(&chain.chain_id) {
|
||||
chain.inputs = inputs;
|
||||
}
|
||||
}
|
||||
|
||||
chains
|
||||
}
|
||||
|
||||
fn load_systems(conn: &Connection) -> BTreeMap<String, SystemInfo> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT ss.system_id, ss.proper_name, ss.cultural_corridor,
|
||||
ss.gate_energy_connected,
|
||||
COALESCE(se.population, 0) as population,
|
||||
COALESCE(ss.currency_zone, 'TRACTUS_PRIMARY') as currency_zone,
|
||||
COALESCE(sg.hop_distance_from_gateway, 5) as hop_distance,
|
||||
sg.gate_topology,
|
||||
ss.political_zone
|
||||
FROM star_systems ss
|
||||
LEFT JOIN system_economy se ON ss.system_id = se.system_id
|
||||
LEFT JOIN system_gates sg ON ss.system_id = sg.system_id
|
||||
ORDER BY ss.system_id",
|
||||
)
|
||||
.expect("prepare systems");
|
||||
|
||||
stmt.query_map([], |row| {
|
||||
Ok(SystemInfo {
|
||||
system_id: row.get(0)?,
|
||||
proper_name: row.get(1)?,
|
||||
cultural_corridor: row.get(2)?,
|
||||
gate_energy_connected: row.get::<_, Option<i32>>(3)?.unwrap_or(1) != 0,
|
||||
population: row.get(4)?,
|
||||
currency_zone: row
|
||||
.get::<_, Option<String>>(5)?
|
||||
.unwrap_or_else(|| "TRACTUS_PRIMARY".to_string()),
|
||||
hop_distance: row.get::<_, Option<i64>>(6)?.unwrap_or(5),
|
||||
gate_topology: row.get(7)?,
|
||||
political_zone: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.expect("query systems")
|
||||
.filter_map(|r| r.ok())
|
||||
.map(|s| (s.system_id.clone(), s))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn load_corp_archetype_data(conn: &Connection) -> Vec<(String, Option<String>, Option<String>)> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT corp_id, behavioral_archetype, specialization
|
||||
FROM corporations ORDER BY corp_id",
|
||||
)
|
||||
.expect("prepare corp archetype data");
|
||||
|
||||
stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||
.expect("query corp archetype data")
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn load_gate_links(conn: &Connection) -> Vec<GateLink> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT from_system_id, to_system_id FROM gate_links
|
||||
ORDER BY from_system_id, to_system_id",
|
||||
)
|
||||
.expect("prepare gate_links");
|
||||
|
||||
stmt.query_map([], |row| {
|
||||
Ok(GateLink {
|
||||
from_system_id: row.get(0)?,
|
||||
to_system_id: row.get(1)?,
|
||||
})
|
||||
})
|
||||
.expect("query gate_links")
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn load_corp_presences(conn: &Connection) -> Vec<CorpPresence> {
|
||||
// Resolve body/station location_id back to system_id via LEFT JOINs.
|
||||
// corp_presence.location_type is 'body' | 'station' per schema.
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT cp.corp_id,
|
||||
COALESCE(b.system_id, s.system_id) AS system_id,
|
||||
cp.primary_operation
|
||||
FROM corp_presence cp
|
||||
LEFT JOIN bodies b ON cp.location_type = 'body' AND cp.location_id = b.body_id
|
||||
LEFT JOIN stations s ON cp.location_type = 'station' AND cp.location_id = s.station_id
|
||||
WHERE COALESCE(b.system_id, s.system_id) IS NOT NULL
|
||||
ORDER BY system_id, cp.corp_id",
|
||||
)
|
||||
.expect("prepare corp_presence");
|
||||
|
||||
stmt.query_map([], |row| {
|
||||
Ok(CorpPresence {
|
||||
corp_id: row.get(0)?,
|
||||
system_id: row.get(1)?,
|
||||
primary_operation: row.get(2)?,
|
||||
})
|
||||
})
|
||||
.expect("query corp_presence")
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main loader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn load_economy(conn: &Connection) -> Economy {
|
||||
let commodities = load_commodities(conn);
|
||||
let commodity_map: BTreeMap<String, Commodity> = commodities
|
||||
.iter()
|
||||
.map(|c| (c.id.clone(), c.clone()))
|
||||
.collect();
|
||||
|
||||
let chains = load_chains(conn);
|
||||
let mut chains_by_output: BTreeMap<String, Vec<ProductionChain>> = BTreeMap::new();
|
||||
for chain in &chains {
|
||||
chains_by_output
|
||||
.entry(chain.output_commodity_id.clone())
|
||||
.or_default()
|
||||
.push(chain.clone());
|
||||
}
|
||||
|
||||
let systems = load_systems(conn);
|
||||
let corp_presences = load_corp_presences(conn);
|
||||
|
||||
let mut presences_by_system: BTreeMap<String, Vec<CorpPresence>> = BTreeMap::new();
|
||||
for cp in &corp_presences {
|
||||
presences_by_system
|
||||
.entry(cp.system_id.clone())
|
||||
.or_default()
|
||||
.push(cp.clone());
|
||||
}
|
||||
|
||||
let gate_links = load_gate_links(conn);
|
||||
let corp_archetype_data = load_corp_archetype_data(conn);
|
||||
|
||||
Economy {
|
||||
commodities,
|
||||
commodity_map,
|
||||
chains,
|
||||
chains_by_output,
|
||||
systems,
|
||||
corp_presences,
|
||||
presences_by_system,
|
||||
gate_links,
|
||||
corp_archetype_data,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
//! D-180: Event input port for the economics simulation.
|
||||
//!
|
||||
//! External disruptions enter the simulation through `EconEvent` structs
|
||||
//! pushed into an `EventPort`. Active events are applied each tick via
|
||||
//! `compute_modifiers()`, which builds combined multiplier maps consumed
|
||||
//! by the simulation step.
|
||||
//!
|
||||
//! ## Lifecycle
|
||||
//!
|
||||
//! ```text
|
||||
//! port.activate_scheduled(tick) // inject any events due this tick
|
||||
//! let mods = port.compute_modifiers(&economy)
|
||||
//! step_inner(..., &mods) // apply production/demand/capacity mods
|
||||
//! currency.apply_exchange_shock(mods.exchange_shock)
|
||||
//! port.advance_remaining() // decrement and expire finished events
|
||||
//! ```
|
||||
//!
|
||||
//! ## Visibility modes (D-180)
|
||||
//!
|
||||
//! Phase 2 exercises `Global` and `Proximate` only.
|
||||
//! `Hidden` is implemented but not exercised until the player inspect verb
|
||||
//! exists (Phase 3).
|
||||
//!
|
||||
//! ## Economics is a receiver, not an emitter (D-180)
|
||||
//!
|
||||
//! The economics layer accepts events; it does NOT generate them.
|
||||
//! Drama comes from the storyteller, political, or disaster layers.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::db::Economy;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event types (D-180)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The scope of nodes affected by an economic event.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EconEventTarget {
|
||||
/// A single market node (system_id).
|
||||
Node(String),
|
||||
/// An explicit set of market nodes.
|
||||
// Used by debug command handler (#823) and storyteller layer (#821+):
|
||||
#[allow(dead_code)]
|
||||
NodeSet(Vec<String>),
|
||||
/// All systems in a named cultural corridor.
|
||||
// Used by storyteller / disaster layer (#821+):
|
||||
#[allow(dead_code)]
|
||||
Corridor(String),
|
||||
/// Both endpoints of a gate link (directed: from → to).
|
||||
// Used by trade disruption events (#821+):
|
||||
#[allow(dead_code)]
|
||||
TradeRoute { from: String, to: String },
|
||||
/// All systems in a currency zone (`"TRACTUS_PRIMARY"`, `"MARK_PRIMARY"`, `"MIXED"`).
|
||||
// Used by currency-zone events (#821+):
|
||||
#[allow(dead_code)]
|
||||
Currency(String),
|
||||
/// A specific commodity at all active nodes.
|
||||
// Used by supply chain disruption events (#821+):
|
||||
#[allow(dead_code)]
|
||||
Commodity(String),
|
||||
}
|
||||
|
||||
/// The economic effect applied at the targeted nodes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EconEventEffect {
|
||||
/// Multiply per-corp productivity (`prod.for_tier()`) by this factor.
|
||||
/// `< 1.0` = disruption; `> 1.0` = boom.
|
||||
// Used by #823 (debug commands) and storyteller events (#821+):
|
||||
#[allow(dead_code)]
|
||||
ProductivityMultiplier(f64),
|
||||
/// Multiply production capacity (`BASELINE_CAPACITY`) by this factor.
|
||||
/// `< 1.0` = capacity constraint; `> 1.0` = expanded capacity.
|
||||
CapacityMultiplier(f64),
|
||||
/// Multiply consumer demand by this factor at affected nodes.
|
||||
/// `> 1.0` = demand spike; `< 1.0` = demand collapse.
|
||||
// Used by #823 (debug commands) and storyteller events (#821+):
|
||||
#[allow(dead_code)]
|
||||
DemandShock(f64),
|
||||
/// Additive delta applied to the Tractus/Mark exchange rate each tick.
|
||||
/// Positive = Tractus strengthens (Mark weakens).
|
||||
// Used by #823 (debug commands) and currency events (#821+):
|
||||
#[allow(dead_code)]
|
||||
ExchangeShock(f64),
|
||||
}
|
||||
|
||||
/// Who can observe this event (D-180 visibility modes).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EconEventVisibility {
|
||||
/// All actors know immediately.
|
||||
Global,
|
||||
/// Visible to nodes within N gate hops of the target.
|
||||
// Used by Proximate event propagation (#821+):
|
||||
#[allow(dead_code)]
|
||||
Proximate(u32),
|
||||
/// Only the named system IDs are informed.
|
||||
// Used by intel/corporate disclosure events (#821+):
|
||||
#[allow(dead_code)]
|
||||
Disclosed(Vec<String>),
|
||||
/// Creates observable price effects but no knowledge flag.
|
||||
/// No actor knows the cause. Phase 3 only — requires player inspect verb.
|
||||
// Used by hidden disruption events (Phase 3, #831+):
|
||||
#[allow(dead_code)]
|
||||
Hidden,
|
||||
}
|
||||
|
||||
/// A typed economic disruption event (D-180).
|
||||
///
|
||||
/// Push into an `EventPort` via `push()` (immediate) or `push_at()` (scheduled).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EconEvent {
|
||||
pub target: EconEventTarget,
|
||||
pub effect: EconEventEffect,
|
||||
/// Duration in simulation ticks (clamped to ≥ 1 on push).
|
||||
pub duration: u32,
|
||||
/// Who can observe this event. Used by the information boundary system (#822+).
|
||||
#[allow(dead_code)]
|
||||
pub visibility: EconEventVisibility,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EventPort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct ActiveEvent {
|
||||
event: EconEvent,
|
||||
/// Ticks remaining before this event expires.
|
||||
remaining_ticks: u32,
|
||||
}
|
||||
|
||||
struct ScheduledEvent {
|
||||
/// The simulation tick at which to activate this event.
|
||||
inject_at_tick: u64,
|
||||
event: EconEvent,
|
||||
}
|
||||
|
||||
/// The event input port — a typed queue of active and scheduled disruptions.
|
||||
///
|
||||
/// **Usage in the tick loop:**
|
||||
/// 1. Call `activate_scheduled(tick)` at the START of each tick.
|
||||
/// 2. Call `compute_modifiers(&economy)` to get this tick's modifier maps.
|
||||
/// 3. Pass the modifiers to `step_inner`.
|
||||
/// 4. Call `advance_remaining()` at the END of each tick.
|
||||
#[derive(Default)]
|
||||
pub struct EventPort {
|
||||
active: Vec<ActiveEvent>,
|
||||
scheduled: Vec<ScheduledEvent>,
|
||||
}
|
||||
|
||||
impl EventPort {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Inject an event that starts at the current tick.
|
||||
pub fn push(&mut self, event: EconEvent) {
|
||||
let remaining = event.duration.max(1);
|
||||
self.active.push(ActiveEvent {
|
||||
event,
|
||||
remaining_ticks: remaining,
|
||||
});
|
||||
}
|
||||
|
||||
/// Schedule an event to be injected at a specific simulation tick.
|
||||
///
|
||||
/// The event becomes active at the START of `inject_at_tick`, before
|
||||
/// `compute_modifiers` is called for that tick.
|
||||
pub fn push_at(&mut self, inject_at_tick: u64, event: EconEvent) {
|
||||
self.scheduled.push(ScheduledEvent {
|
||||
inject_at_tick,
|
||||
event,
|
||||
});
|
||||
}
|
||||
|
||||
/// Activate any events scheduled for `current_tick`.
|
||||
///
|
||||
/// Call at the START of each tick, before `compute_modifiers`.
|
||||
pub fn activate_scheduled(&mut self, current_tick: u64) {
|
||||
// Stable Rust: partition scheduled list manually (no drain_filter).
|
||||
let mut still_pending = Vec::new();
|
||||
let mut to_activate = Vec::new();
|
||||
for se in self.scheduled.drain(..) {
|
||||
if se.inject_at_tick <= current_tick {
|
||||
to_activate.push(se.event);
|
||||
} else {
|
||||
still_pending.push(se);
|
||||
}
|
||||
}
|
||||
self.scheduled = still_pending;
|
||||
for event in to_activate {
|
||||
self.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrement remaining ticks and remove events that have expired.
|
||||
///
|
||||
/// Call at the END of each tick, after effects have been applied.
|
||||
pub fn advance_remaining(&mut self) {
|
||||
for ae in &mut self.active {
|
||||
ae.remaining_ticks = ae.remaining_ticks.saturating_sub(1);
|
||||
}
|
||||
self.active.retain(|ae| ae.remaining_ticks > 0);
|
||||
}
|
||||
|
||||
/// True when no events are active or scheduled.
|
||||
// Used by tick loop optimization in #821:
|
||||
#[allow(dead_code)]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.active.is_empty() && self.scheduled.is_empty()
|
||||
}
|
||||
|
||||
/// Compute combined modifier maps from all currently active events.
|
||||
///
|
||||
/// Multiple overlapping events compound multiplicatively for `f64` effects.
|
||||
/// Exchange shocks accumulate additively.
|
||||
pub fn compute_modifiers(&self, economy: &Economy) -> EventModifiers {
|
||||
let mut mods = EventModifiers::default();
|
||||
|
||||
for ae in &self.active {
|
||||
let commodity_filter: Option<String> = match &ae.event.target {
|
||||
EconEventTarget::Commodity(cid) => Some(cid.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let affected_nodes = resolve_target_nodes(economy, &ae.event.target);
|
||||
|
||||
match ae.event.effect {
|
||||
EconEventEffect::DemandShock(f) => {
|
||||
for node_id in &affected_nodes {
|
||||
apply_multiplier(
|
||||
&mut mods.demand,
|
||||
node_id,
|
||||
&commodity_filter,
|
||||
economy,
|
||||
f,
|
||||
);
|
||||
}
|
||||
}
|
||||
EconEventEffect::ProductivityMultiplier(f) => {
|
||||
for node_id in &affected_nodes {
|
||||
apply_multiplier(
|
||||
&mut mods.productivity,
|
||||
node_id,
|
||||
&commodity_filter,
|
||||
economy,
|
||||
f,
|
||||
);
|
||||
}
|
||||
}
|
||||
EconEventEffect::CapacityMultiplier(f) => {
|
||||
for node_id in &affected_nodes {
|
||||
apply_multiplier(
|
||||
&mut mods.capacity,
|
||||
node_id,
|
||||
&commodity_filter,
|
||||
economy,
|
||||
f,
|
||||
);
|
||||
}
|
||||
}
|
||||
EconEventEffect::ExchangeShock(delta) => {
|
||||
mods.exchange_shock += delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mods
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EventModifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Combined per-tick modifiers from all currently active events.
|
||||
///
|
||||
/// Missing entries default to `1.0` (multiplicative identity) via the `_for` methods.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EventModifiers {
|
||||
/// `(system_id, commodity_id)` → combined demand multiplier (product of all active shocks).
|
||||
pub demand: BTreeMap<(String, String), f64>,
|
||||
/// `(system_id, commodity_id)` → combined productivity multiplier.
|
||||
pub productivity: BTreeMap<(String, String), f64>,
|
||||
/// `(system_id, commodity_id)` → combined capacity multiplier.
|
||||
pub capacity: BTreeMap<(String, String), f64>,
|
||||
/// Additive delta applied to the Tractus/Mark exchange rate this tick.
|
||||
pub exchange_shock: f64,
|
||||
}
|
||||
|
||||
impl EventModifiers {
|
||||
/// Combined demand multiplier for `(system_id, commodity_id)`.
|
||||
/// Returns `1.0` if no active demand shock targets this pair.
|
||||
/// Guards String allocation: fast-path returns 1.0 when no demand events are active.
|
||||
pub fn demand_for(&self, system_id: &str, commodity_id: &str) -> f64 {
|
||||
if self.demand.is_empty() {
|
||||
return 1.0;
|
||||
}
|
||||
*self
|
||||
.demand
|
||||
.get(&(system_id.to_string(), commodity_id.to_string()))
|
||||
.unwrap_or(&1.0)
|
||||
}
|
||||
|
||||
/// Combined productivity multiplier for `(system_id, commodity_id)`.
|
||||
/// Guards String allocation: fast-path returns 1.0 when no productivity events are active.
|
||||
pub fn productivity_for(&self, system_id: &str, commodity_id: &str) -> f64 {
|
||||
if self.productivity.is_empty() {
|
||||
return 1.0;
|
||||
}
|
||||
*self
|
||||
.productivity
|
||||
.get(&(system_id.to_string(), commodity_id.to_string()))
|
||||
.unwrap_or(&1.0)
|
||||
}
|
||||
|
||||
/// Combined capacity multiplier for `(system_id, commodity_id)`.
|
||||
/// Guards String allocation: fast-path returns 1.0 when no capacity events are active.
|
||||
pub fn capacity_for(&self, system_id: &str, commodity_id: &str) -> f64 {
|
||||
if self.capacity.is_empty() {
|
||||
return 1.0;
|
||||
}
|
||||
*self
|
||||
.capacity
|
||||
.get(&(system_id.to_string(), commodity_id.to_string()))
|
||||
.unwrap_or(&1.0)
|
||||
}
|
||||
|
||||
/// True when no events are affecting this tick (all maps empty, no exchange shock).
|
||||
// Used by tick loop fast path in #821:
|
||||
#[allow(dead_code)]
|
||||
pub fn is_identity(&self) -> bool {
|
||||
self.demand.is_empty()
|
||||
&& self.productivity.is_empty()
|
||||
&& self.capacity.is_empty()
|
||||
&& self.exchange_shock == 0.0
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolve which system IDs are affected by the given event target.
|
||||
fn resolve_target_nodes(economy: &Economy, target: &EconEventTarget) -> Vec<String> {
|
||||
match target {
|
||||
EconEventTarget::Node(id) => vec![id.clone()],
|
||||
EconEventTarget::NodeSet(ids) => ids.clone(),
|
||||
EconEventTarget::Corridor(corridor) => economy
|
||||
.systems
|
||||
.values()
|
||||
.filter(|s| s.cultural_corridor.as_deref() == Some(corridor.as_str()))
|
||||
.map(|s| s.system_id.clone())
|
||||
.collect(),
|
||||
EconEventTarget::TradeRoute { from, to } => vec![from.clone(), to.clone()],
|
||||
EconEventTarget::Currency(zone) => economy
|
||||
.systems
|
||||
.values()
|
||||
.filter(|s| &s.currency_zone == zone)
|
||||
.map(|s| s.system_id.clone())
|
||||
.collect(),
|
||||
// Commodity target: effect applies to this commodity at all active nodes.
|
||||
// The commodity filter is applied during apply_multiplier.
|
||||
EconEventTarget::Commodity(_) => economy.systems.keys().cloned().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a multiplier to all `(node, commodity)` pairs matching the filter.
|
||||
///
|
||||
/// If `commodity_filter` is `None`, applies to ALL commodities at `node_id`.
|
||||
/// Multiple events compound multiplicatively.
|
||||
fn apply_multiplier(
|
||||
map: &mut BTreeMap<(String, String), f64>,
|
||||
node_id: &str,
|
||||
commodity_filter: &Option<String>,
|
||||
economy: &Economy,
|
||||
factor: f64,
|
||||
) {
|
||||
let commodity_ids: Vec<String> = match commodity_filter {
|
||||
Some(cid) => vec![cid.clone()],
|
||||
None => economy.commodities.iter().map(|c| c.id.clone()).collect(),
|
||||
};
|
||||
for cid in commodity_ids {
|
||||
let entry = map
|
||||
.entry((node_id.to_string(), cid))
|
||||
.or_insert(1.0);
|
||||
*entry *= factor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! econ_sim — Settled Reach economics simulation library.
|
||||
//!
|
||||
//! Exposes the Layer 1+2+3 tâtonnement simulation as a reusable library crate.
|
||||
//! The standalone `econ-sim` binary uses the same modules independently.
|
||||
//!
|
||||
//! ## Entry points
|
||||
//!
|
||||
//! - [`Simulation`] — stateful per-tick runner for server integration (#821).
|
||||
//! Initialize once, call `step()` each economy tick.
|
||||
//!
|
||||
//! - [`model::run_with_events`] — batch runner (runs N ticks, returns TickRecords).
|
||||
//! Used by the standalone binary and stability checks.
|
||||
//!
|
||||
//! ## Key decisions
|
||||
//!
|
||||
//! - D-178: Economic model architecture (Leontief + tâtonnement + agents)
|
||||
//! - D-180: Event input port (`EconEvent`, `EventPort`)
|
||||
//! - D-181: 7-signal vocabulary per active node
|
||||
|
||||
pub mod agents;
|
||||
pub mod currency;
|
||||
pub mod db;
|
||||
pub mod events;
|
||||
pub mod model;
|
||||
pub mod prng;
|
||||
pub mod seed;
|
||||
pub mod trade;
|
||||
|
||||
mod sim;
|
||||
pub use sim::Simulation;
|
||||
@@ -0,0 +1,520 @@
|
||||
//! econ-sim: Settled Reach economics simulation binary.
|
||||
//!
|
||||
//! Layer 1: Leontief production + consumption + price adjustment.
|
||||
//! Layer 2: Spatial price equilibrium via damped tâtonnement (D-178).
|
||||
//! Layer 3 (corporate behavioral agents) added in #809.
|
||||
//!
|
||||
//! Usage:
|
||||
//! econ-sim [--db path/to/systems.db] [--ticks 100] [--seed 0] [--output out.csv]
|
||||
//! econ-sim --stability-check # D-179 Tests 1 and 2
|
||||
//!
|
||||
//! Output: CSV with columns: node_id, commodity_id, supply, demand, price, tick
|
||||
//!
|
||||
//! Reference decisions: D-176 (productivity seeding), D-177 (constraints),
|
||||
//! D-178 (model architecture), D-179 (stability criteria), D-180 (event port)
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
mod agents;
|
||||
mod currency;
|
||||
mod db;
|
||||
mod events;
|
||||
mod model;
|
||||
mod output;
|
||||
mod prng;
|
||||
mod seed;
|
||||
mod trade;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "econ-sim",
|
||||
about = "Settled Reach economics simulation — Layer 1 Leontief production"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Path to systems.db (default: auto-detect from working directory)
|
||||
#[arg(long)]
|
||||
db: Option<PathBuf>,
|
||||
|
||||
/// Number of ticks to simulate
|
||||
#[arg(long, default_value_t = 100)]
|
||||
ticks: u32,
|
||||
|
||||
/// PRNG seed for productivity randomization (D-176)
|
||||
#[arg(long, default_value_t = 0)]
|
||||
seed: u64,
|
||||
|
||||
/// Output CSV file (default: stdout)
|
||||
#[arg(long)]
|
||||
output: Option<PathBuf>,
|
||||
|
||||
/// Run stability checks (scaffolded here — exercised in #807 when trade flows added)
|
||||
#[arg(long)]
|
||||
stability_check: bool,
|
||||
|
||||
/// Comma-separated list of system IDs to simulate (default: all active nodes)
|
||||
#[arg(long)]
|
||||
systems: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// --- Load ---
|
||||
let db_path = db::resolve_db_path(cli.db);
|
||||
eprintln!("Loading economy data from {}...", db_path.display());
|
||||
let conn = db::open_db(&db_path);
|
||||
let economy = db::load_economy(&conn);
|
||||
let active_node_count = economy
|
||||
.systems
|
||||
.values()
|
||||
.filter(|s| economy.presences_by_system.contains_key(&s.system_id) || s.population > 0)
|
||||
.count();
|
||||
eprintln!(
|
||||
" {} commodities, {} production chains, {} active nodes, {} corp presences, {} gate links",
|
||||
economy.commodities.len(),
|
||||
economy.chains.len(),
|
||||
active_node_count,
|
||||
economy.corp_presences.len(),
|
||||
economy.gate_links.len(),
|
||||
);
|
||||
|
||||
// --- Seed ---
|
||||
eprintln!(
|
||||
"Seeding per-corporation productivity (run seed: {})...",
|
||||
cli.seed
|
||||
);
|
||||
let productivity = seed::seed_all_productivity(&economy, cli.seed);
|
||||
eprintln!(
|
||||
" {} corp×site productivity records seeded",
|
||||
productivity.len()
|
||||
);
|
||||
|
||||
// --- Behavioral archetypes ---
|
||||
let archetype_map = agents::build_archetype_map(economy.corp_archetype_data.clone());
|
||||
eprintln!(
|
||||
" {} corporation behavioral archetypes loaded (inferred where not set in DB)",
|
||||
archetype_map.len()
|
||||
);
|
||||
|
||||
// --- Gate adjacency ---
|
||||
let adjacency = trade::build_adjacency(&economy);
|
||||
eprintln!(" {} nodes with gate connections", adjacency.len(),);
|
||||
|
||||
// --- Shadow economy seeding ---
|
||||
eprintln!("Seeding per-node shadow economy intensity (D-174)...");
|
||||
let shadow = currency::seed_shadow_economy(&economy, cli.seed);
|
||||
let shadow_mean = if shadow.intensity.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
shadow.intensity.values().sum::<f64>() / shadow.intensity.len() as f64
|
||||
};
|
||||
eprintln!(
|
||||
" {} nodes seeded, mean intensity {:.2}",
|
||||
shadow.intensity.len(),
|
||||
shadow_mean
|
||||
);
|
||||
|
||||
if cli.stability_check {
|
||||
run_stability_checks(&economy, &productivity, &shadow, &adjacency);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Simulate ---
|
||||
eprintln!("Running {} ticks of Layer 1+2 simulation...", cli.ticks);
|
||||
let snapshots = model::run(&economy, &productivity, &shadow, &adjacency, cli.ticks);
|
||||
eprintln!(" {} output records generated", snapshots.len());
|
||||
|
||||
// --- Output ---
|
||||
output::write_csv(&snapshots, cli.output.as_deref()).unwrap_or_else(|e| {
|
||||
eprintln!("error: failed to write output: {}", e);
|
||||
process::exit(1);
|
||||
});
|
||||
|
||||
if cli.output.is_some() {
|
||||
eprintln!(
|
||||
"Done. Written to {}",
|
||||
cli.output.as_deref().unwrap().display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// D-179 Stability Checks (Tests 1 and 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run D-179 stability tests and exit 0 on pass, 1 on failure.
|
||||
///
|
||||
/// Test 1 — Cold-start convergence: prices within ±5% of long-run
|
||||
/// equilibrium at tick 100.
|
||||
///
|
||||
/// Test 2 — Long-run stability: zero drift > ±2% over ticks 900–999.
|
||||
/// Equilibrium is defined as the mean price over ticks 900–999.
|
||||
///
|
||||
/// Test 3 — Shock response: inject a demand shock on one node at tick 200,
|
||||
/// verify prices recover within 200 ticks, no price explosions (>20×base).
|
||||
///
|
||||
/// Test 4 — Cross-zone balance: skipped if no MARK_PRIMARY systems exist.
|
||||
/// Otherwise: after a cross-zone trade imbalance is induced, exchange rate
|
||||
/// must re-stabilize (±2% variance) within 50 ticks.
|
||||
fn run_stability_checks(
|
||||
economy: &db::Economy,
|
||||
productivity: &std::collections::BTreeMap<(String, String), seed::Productivity>,
|
||||
shadow: ¤cy::ShadowEconomy,
|
||||
adjacency: &std::collections::BTreeMap<String, Vec<String>>,
|
||||
) {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const CHECK_TICKS: u32 = 1_000;
|
||||
const CONVERGENCE_TICK: u32 = 100;
|
||||
const STABILITY_START: u32 = 900;
|
||||
const CONVERGENCE_THRESHOLD: f64 = 0.05; // ±5%
|
||||
const STABILITY_THRESHOLD: f64 = 0.02; // ±2%
|
||||
|
||||
eprintln!("Running D-179 stability checks ({CHECK_TICKS} ticks)...");
|
||||
let records = model::run(economy, productivity, shadow, adjacency, CHECK_TICKS);
|
||||
|
||||
// Index records by (node_id, commodity_id) → Vec<(tick, price)>
|
||||
let mut by_key: BTreeMap<(String, String), Vec<(u32, f64)>> = BTreeMap::new();
|
||||
for r in &records {
|
||||
by_key
|
||||
.entry((r.node_id.clone(), r.commodity_id.clone()))
|
||||
.or_default()
|
||||
.push((r.tick, r.price));
|
||||
}
|
||||
|
||||
// Compute per-key equilibrium = mean price over ticks 900–999
|
||||
let mut equilibria: BTreeMap<(String, String), f64> = BTreeMap::new();
|
||||
for (key, ticks) in &by_key {
|
||||
let late: Vec<f64> = ticks
|
||||
.iter()
|
||||
.filter(|(t, _)| *t >= STABILITY_START)
|
||||
.map(|(_, p)| *p)
|
||||
.collect();
|
||||
if late.is_empty() {
|
||||
continue;
|
||||
}
|
||||
equilibria.insert(key.clone(), late.iter().sum::<f64>() / late.len() as f64);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Test 1: cold-start convergence
|
||||
// -----------------------------------------------------------------
|
||||
let mut test1_pass = true;
|
||||
let mut test1_max_dev: f64 = 0.0;
|
||||
let mut test1_worst: Option<(String, String)> = None;
|
||||
|
||||
for (key, eq) in &equilibria {
|
||||
if *eq < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
if let Some(entry) = by_key.get(key) {
|
||||
if let Some((_, price_at_100)) = entry.iter().find(|(t, _)| *t == CONVERGENCE_TICK) {
|
||||
let dev = (price_at_100 - eq).abs() / eq;
|
||||
if dev > test1_max_dev {
|
||||
test1_max_dev = dev;
|
||||
test1_worst = Some((key.0.clone(), key.1.clone()));
|
||||
}
|
||||
if dev > CONVERGENCE_THRESHOLD {
|
||||
test1_pass = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Test 2: long-run stability
|
||||
// -----------------------------------------------------------------
|
||||
let mut test2_pass = true;
|
||||
let mut test2_max_dev: f64 = 0.0;
|
||||
let mut test2_worst: Option<(String, String)> = None;
|
||||
|
||||
for (key, eq) in &equilibria {
|
||||
if *eq < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
if let Some(ticks) = by_key.get(key) {
|
||||
for (t, price) in ticks {
|
||||
if *t < STABILITY_START {
|
||||
continue;
|
||||
}
|
||||
let dev = (price - eq).abs() / eq;
|
||||
if dev > test2_max_dev {
|
||||
test2_max_dev = dev;
|
||||
test2_worst = Some((key.0.clone(), key.1.clone()));
|
||||
}
|
||||
if dev > STABILITY_THRESHOLD {
|
||||
test2_pass = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Test 3: D-179 shock response — inject supply shock, verify cascade
|
||||
// and recovery within 200 ticks (D-179 Test 3, D-180 event port).
|
||||
// -----------------------------------------------------------------
|
||||
let (test3_pass, test3_note) =
|
||||
run_shock_response_test(economy, productivity, shadow, adjacency);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Test 4: cross-zone balance (skip if no MARK_PRIMARY systems)
|
||||
// -----------------------------------------------------------------
|
||||
let has_mark_zone = economy
|
||||
.systems
|
||||
.values()
|
||||
.any(|s| s.currency_zone == "MARK_PRIMARY");
|
||||
|
||||
let (test4_pass, test4_note) = if has_mark_zone {
|
||||
run_cross_zone_test(economy, productivity, shadow, adjacency)
|
||||
} else {
|
||||
(
|
||||
true,
|
||||
"SKIP — no MARK_PRIMARY systems in DB; re-run after Compact zone data is authored"
|
||||
.to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Report
|
||||
// -----------------------------------------------------------------
|
||||
let sym = |p: bool| if p { "PASS" } else { "FAIL" };
|
||||
eprintln!(
|
||||
"Test 1 (cold-start convergence ±5% at tick {CONVERGENCE_TICK}): {} max_dev={:.2}%{}",
|
||||
sym(test1_pass),
|
||||
test1_max_dev * 100.0,
|
||||
test1_worst
|
||||
.as_ref()
|
||||
.map(|(n, c)| format!(" worst: {n}/{c}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
eprintln!(
|
||||
"Test 2 (long-run stability ±2% over ticks {STABILITY_START}–999): {} max_dev={:.2}%{}",
|
||||
sym(test2_pass),
|
||||
test2_max_dev * 100.0,
|
||||
test2_worst
|
||||
.as_ref()
|
||||
.map(|(n, c)| format!(" worst: {n}/{c}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
eprintln!(
|
||||
"Test 3 (shock response — D-180 CapacityMult event, recovery ≤200 ticks): {} {}",
|
||||
sym(test3_pass),
|
||||
test3_note
|
||||
);
|
||||
eprintln!(
|
||||
"Test 4 (cross-zone balance re-stabilizes ≤50 ticks): {} {}",
|
||||
sym(test4_pass),
|
||||
test4_note
|
||||
);
|
||||
|
||||
let all_pass = test1_pass && test2_pass && test3_pass && test4_pass;
|
||||
if all_pass {
|
||||
eprintln!("All stability checks passed.");
|
||||
process::exit(0);
|
||||
} else {
|
||||
eprintln!("Stability check FAILED — see above.");
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// D-179 Test 3: shock response — inject supply disruption, verify cascade and recovery.
|
||||
///
|
||||
/// Protocol:
|
||||
/// 1. Run WARMUP_TICKS with no events to establish a stable price baseline.
|
||||
/// 2. At tick WARMUP_TICKS, inject a `CapacityMultiplier(0.1)` event on the
|
||||
/// most active node for SHOCK_DURATION ticks (90% capacity reduction).
|
||||
/// 3. Continue for RECOVERY_WINDOW ticks after the shock expires.
|
||||
/// 4. Verify: no price explosion (>20× base) at any tick.
|
||||
/// 5. Verify: all prices at end of recovery ≤ ±5% of the pre-shock baseline.
|
||||
///
|
||||
/// A `CapacityMultiplier(0.1)` supply disruption is severe enough to deplete
|
||||
/// stockpiles and propagate price signals to neighboring nodes (cascade),
|
||||
/// while remaining recoverable within the 200-tick window (recovery).
|
||||
fn run_shock_response_test(
|
||||
economy: &db::Economy,
|
||||
productivity: &std::collections::BTreeMap<(String, String), seed::Productivity>,
|
||||
shadow: ¤cy::ShadowEconomy,
|
||||
adjacency: &std::collections::BTreeMap<String, Vec<String>>,
|
||||
) -> (bool, String) {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const WARMUP_TICKS: u32 = 100;
|
||||
const SHOCK_DURATION: u32 = 50;
|
||||
const RECOVERY_WINDOW: u32 = 200;
|
||||
const RECOVERY_THRESHOLD: f64 = 0.05; // ±5% of pre-shock baseline
|
||||
|
||||
// Pick the first active node (has corp presence) as the shock target
|
||||
let shock_node = economy
|
||||
.presences_by_system
|
||||
.keys()
|
||||
.next()
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
economy
|
||||
.systems
|
||||
.values()
|
||||
.find(|s| s.population > 0)
|
||||
.map(|s| s.system_id.clone())
|
||||
});
|
||||
|
||||
let shock_node = match shock_node {
|
||||
Some(n) => n,
|
||||
None => return (true, "SKIP — no active nodes for shock test".to_string()),
|
||||
};
|
||||
|
||||
// Schedule: inject 90% capacity disruption at tick WARMUP_TICKS
|
||||
let mut port = events::EventPort::new();
|
||||
port.push_at(
|
||||
WARMUP_TICKS as u64,
|
||||
events::EconEvent {
|
||||
target: events::EconEventTarget::Node(shock_node.clone()),
|
||||
effect: events::EconEventEffect::CapacityMultiplier(0.1),
|
||||
duration: SHOCK_DURATION,
|
||||
visibility: events::EconEventVisibility::Global,
|
||||
},
|
||||
);
|
||||
|
||||
let total_ticks = WARMUP_TICKS + SHOCK_DURATION + RECOVERY_WINDOW;
|
||||
let records = model::run_with_events(
|
||||
economy,
|
||||
productivity,
|
||||
shadow,
|
||||
adjacency,
|
||||
total_ticks,
|
||||
&mut port,
|
||||
);
|
||||
|
||||
// Index records by (node_id, commodity_id, tick) for lookups
|
||||
let baseline: BTreeMap<(String, String), f64> = records
|
||||
.iter()
|
||||
.filter(|r| r.tick == WARMUP_TICKS - 1)
|
||||
.map(|r| ((r.node_id.clone(), r.commodity_id.clone()), r.price))
|
||||
.collect();
|
||||
|
||||
// Check 1: no price explosions or negatives at any tick
|
||||
const PRICE_EXPLOSION_LIMIT: f64 = 20.0;
|
||||
for r in &records {
|
||||
let base = economy
|
||||
.commodity_map
|
||||
.get(&r.commodity_id)
|
||||
.map_or(1.0, |c| c.base_price);
|
||||
if r.price > base * PRICE_EXPLOSION_LIMIT {
|
||||
return (
|
||||
false,
|
||||
format!(
|
||||
"price explosion at tick {}: {}/{} price={:.1} ({:.0}×base)",
|
||||
r.tick,
|
||||
r.node_id,
|
||||
r.commodity_id,
|
||||
r.price,
|
||||
r.price / base
|
||||
),
|
||||
);
|
||||
}
|
||||
if r.price < 0.0 {
|
||||
return (
|
||||
false,
|
||||
format!(
|
||||
"negative price at tick {}: {}/{} price={:.4}",
|
||||
r.tick, r.node_id, r.commodity_id, r.price
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check 2: prices at end of recovery window are within ±5% of pre-shock baseline
|
||||
let recovery_end_tick = total_ticks - 1;
|
||||
let recovery_prices: BTreeMap<(String, String), f64> = records
|
||||
.iter()
|
||||
.filter(|r| r.tick == recovery_end_tick)
|
||||
.map(|r| ((r.node_id.clone(), r.commodity_id.clone()), r.price))
|
||||
.collect();
|
||||
|
||||
let mut worst_dev: f64 = 0.0;
|
||||
let mut worst_key = String::new();
|
||||
|
||||
for ((node_id, commodity_id), &baseline_price) in &baseline {
|
||||
if baseline_price < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let key = (node_id.clone(), commodity_id.clone());
|
||||
if let Some(&recovery_price) = recovery_prices.get(&key) {
|
||||
let dev = (recovery_price - baseline_price).abs() / baseline_price;
|
||||
if dev > worst_dev {
|
||||
worst_dev = dev;
|
||||
worst_key = format!("{node_id}/{commodity_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pass = worst_dev <= RECOVERY_THRESHOLD;
|
||||
(
|
||||
pass,
|
||||
format!(
|
||||
"CapacityMult(0.1)×{SHOCK_DURATION}t on {shock_node} at t={WARMUP_TICKS}, \
|
||||
max_dev={:.1}% at t={recovery_end_tick} (threshold ±5%){}",
|
||||
worst_dev * 100.0,
|
||||
if !worst_key.is_empty() {
|
||||
format!(" worst: {worst_key}")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Test 4: cross-zone exchange rate stabilizes within 50 ticks.
|
||||
///
|
||||
/// Only runs when MARK_PRIMARY systems exist.
|
||||
fn run_cross_zone_test(
|
||||
economy: &db::Economy,
|
||||
productivity: &std::collections::BTreeMap<(String, String), seed::Productivity>,
|
||||
shadow: ¤cy::ShadowEconomy,
|
||||
adjacency: &std::collections::BTreeMap<String, Vec<String>>,
|
||||
) -> (bool, String) {
|
||||
const TEST_TICKS: u32 = 150;
|
||||
const STABILIZE_BY: u32 = 50;
|
||||
const FX_STABILITY_THRESHOLD: f64 = 0.02; // ±2%
|
||||
|
||||
let records = model::run(economy, productivity, shadow, adjacency, TEST_TICKS);
|
||||
|
||||
// Extract tractus_mark_rate — one value per tick (rate is identical across
|
||||
// all node×commodity records in the same tick; deduplicate to avoid bias).
|
||||
let mut seen: std::collections::BTreeSet<u32> = std::collections::BTreeSet::new();
|
||||
let late_rates: Vec<f64> = records
|
||||
.iter()
|
||||
.filter(|r| r.tick >= STABILIZE_BY && seen.insert(r.tick))
|
||||
.map(|r| r.tractus_mark_rate)
|
||||
.collect();
|
||||
|
||||
if late_rates.is_empty() {
|
||||
return (true, "no data".to_string());
|
||||
}
|
||||
|
||||
let mean_rate = late_rates.iter().sum::<f64>() / late_rates.len() as f64;
|
||||
let max_dev = late_rates
|
||||
.iter()
|
||||
.map(|&r| (r - mean_rate).abs() / mean_rate)
|
||||
.fold(0.0_f64, f64::max);
|
||||
|
||||
let pass = max_dev <= FX_STABILITY_THRESHOLD;
|
||||
(
|
||||
pass,
|
||||
format!(
|
||||
"fx_rate mean={:.4} max_dev={:.2}% (threshold ±2%)",
|
||||
mean_rate,
|
||||
max_dev * 100.0
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
//! Layer 1: Leontief production + consumption + price adjustment.
|
||||
//! Layer 2: Spatial price equilibrium via damped tâtonnement (D-178).
|
||||
//! Layer 3: Corporate behavioral agents (D-178) — added in #809.
|
||||
//!
|
||||
//! Each system with economic activity (corp presence or population > 0)
|
||||
//! is an active market node. Goods flow along gate links when price
|
||||
//! differentials exceed transport costs (α=0.03, β=0.4).
|
||||
//!
|
||||
//! Event port (D-180) — added in #810:
|
||||
//! External disruptions enter via `EventPort` passed to `run_with_events`.
|
||||
//! `run()` is the no-event fast path (delegates to `run_with_events`).
|
||||
//!
|
||||
//! Reference: D-178 (Economic Model Architecture), D-180 (Event Input Port)
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::agents;
|
||||
use crate::currency::{CurrencyState, ShadowEconomy};
|
||||
use crate::db::Economy;
|
||||
use crate::events::{EventModifiers, EventPort};
|
||||
use crate::seed::Productivity;
|
||||
use crate::trade;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Price adjustment rate per tick (α=0.03, D-178 Layer 2).
|
||||
/// Exposed as pub so `Simulation` can default to it and `SetEconParam` can reset to it (#823).
|
||||
pub const ALPHA: f64 = 0.03;
|
||||
|
||||
/// Baseline production capacity per corp per tick (units/tick).
|
||||
const BASELINE_CAPACITY: f64 = 10.0;
|
||||
|
||||
/// Initial stockpile buffer (in ticks of baseline demand).
|
||||
const INITIAL_STOCKPILE_BUFFER: f64 = 4.0;
|
||||
|
||||
/// Per-capita demand coefficient for final goods (units/tick per person).
|
||||
const DEMAND_PER_CAPITA_FINAL: f64 = 1.0e-6;
|
||||
/// Per-capita demand coefficient for services (units/tick per person).
|
||||
const DEMAND_PER_CAPITA_SERVICE: f64 = 0.5e-6;
|
||||
|
||||
/// Fusion fuel utility demand reduction for gate-energy-connected nodes (D-186, D-188).
|
||||
const GATE_ENERGY_DEMAND_REDUCTION: f64 = 0.3;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommodityState {
|
||||
pub supply: f64,
|
||||
pub demand: f64,
|
||||
pub price: f64,
|
||||
pub stockpile: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NodeState {
|
||||
pub system_id: String,
|
||||
/// commodity_id → state
|
||||
pub commodities: BTreeMap<String, CommodityState>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tick snapshot (output record)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TickRecord {
|
||||
pub tick: u32,
|
||||
pub node_id: String,
|
||||
pub commodity_id: String,
|
||||
pub supply: f64,
|
||||
pub demand: f64,
|
||||
pub price: f64,
|
||||
/// Node-level shadow economy intensity [0.0, 1.0] (D-174, Signal 7).
|
||||
/// Same value for all commodities at this node/tick.
|
||||
pub shadow_intensity: f64,
|
||||
/// Tractus/Mark exchange rate at this tick (1.0 = parity, D-171).
|
||||
pub tractus_mark_rate: f64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run the Layer 1+2+3 simulation for `ticks` ticks (no external events).
|
||||
///
|
||||
/// Fast path: delegates to `run_with_events` with an empty `EventPort`.
|
||||
/// Use `run_with_events` when event injection is required (D-180 tests, debug).
|
||||
///
|
||||
/// Returns a flat list of TickRecords (one per active node×commodity×tick).
|
||||
pub fn run(
|
||||
economy: &Economy,
|
||||
productivity: &BTreeMap<(String, String), Productivity>,
|
||||
shadow: &ShadowEconomy,
|
||||
adjacency: &BTreeMap<String, Vec<String>>,
|
||||
ticks: u32,
|
||||
) -> Vec<TickRecord> {
|
||||
let mut port = EventPort::new();
|
||||
run_with_events(economy, productivity, shadow, adjacency, ticks, &mut port)
|
||||
}
|
||||
|
||||
/// Run the Layer 1+2+3 simulation with D-180 event injection.
|
||||
///
|
||||
/// Layer 1: Leontief production + consumption + stockpile update.
|
||||
/// Layer 2: Damped tâtonnement trade flows along gate links (D-178).
|
||||
/// Currency zone friction and exchange rate adjustment (D-171, D-172).
|
||||
/// Layer 3: Corporate behavioral archetypes (D-178).
|
||||
/// Events: external disruptions applied each tick (D-180).
|
||||
///
|
||||
/// Tick loop invariant:
|
||||
/// 1. `events.activate_scheduled(tick)` — inject events due this tick.
|
||||
/// 2. `events.compute_modifiers()` → modifier maps for this tick.
|
||||
/// 3. `step_inner` — production + demand + price adjustment with modifiers.
|
||||
/// 4. `currency.apply_exchange_shock` — apply any exchange shock from events.
|
||||
/// 5. `trade_step` — inter-node trade flows.
|
||||
/// 6. `currency.update_rate` — FX adjustment from net cross-zone flow.
|
||||
/// 7. `events.advance_remaining` — decrement and expire finished events.
|
||||
///
|
||||
/// Returns a flat list of TickRecords (one per active node×commodity×tick).
|
||||
pub fn run_with_events(
|
||||
economy: &Economy,
|
||||
productivity: &BTreeMap<(String, String), Productivity>,
|
||||
shadow: &ShadowEconomy,
|
||||
adjacency: &BTreeMap<String, Vec<String>>,
|
||||
ticks: u32,
|
||||
events: &mut EventPort,
|
||||
) -> Vec<TickRecord> {
|
||||
let archetypes = agents::build_archetype_map(economy.corp_archetype_data.clone());
|
||||
let mut nodes = init_nodes(economy);
|
||||
let mut currency = CurrencyState::new();
|
||||
let mut records = Vec::new();
|
||||
|
||||
for tick in 0..ticks {
|
||||
// Activate any events scheduled for this tick (D-180)
|
||||
events.activate_scheduled(tick as u64);
|
||||
|
||||
let mods = events.compute_modifiers(economy);
|
||||
step_inner(economy, productivity, shadow, &archetypes, &mut nodes, &mods, ALPHA);
|
||||
currency.apply_exchange_shock(mods.exchange_shock);
|
||||
trade::trade_step(economy, &mut nodes, adjacency, &mut currency, trade::BETA);
|
||||
currency.update_rate();
|
||||
|
||||
// Expire events that have completed their duration
|
||||
events.advance_remaining();
|
||||
|
||||
let fx_rate = currency.tractus_mark_rate;
|
||||
for node in nodes.values() {
|
||||
let node_shadow = shadow
|
||||
.intensity
|
||||
.get(&node.system_id)
|
||||
.copied()
|
||||
.unwrap_or(0.0);
|
||||
for (commodity_id, state) in &node.commodities {
|
||||
records.push(TickRecord {
|
||||
tick,
|
||||
node_id: node.system_id.clone(),
|
||||
commodity_id: commodity_id.clone(),
|
||||
supply: state.supply,
|
||||
demand: state.demand,
|
||||
price: state.price,
|
||||
shadow_intensity: node_shadow,
|
||||
tractus_mark_rate: fx_rate,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Initialize node states for all active systems (corp presence or population > 0).
|
||||
///
|
||||
/// Public for use by [`crate::sim::Simulation`] and external callers that need
|
||||
/// a stateful simulation runner rather than the batch `run_with_events` API.
|
||||
pub fn init_nodes(economy: &Economy) -> BTreeMap<String, NodeState> {
|
||||
let mut nodes: BTreeMap<String, NodeState> = BTreeMap::new();
|
||||
|
||||
// Activate nodes that have corp presence or non-zero population
|
||||
for (system_id, system) in &economy.systems {
|
||||
let has_corps = economy.presences_by_system.contains_key(system_id);
|
||||
let has_population = system.population > 0;
|
||||
if !has_corps && !has_population {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut commodity_states: BTreeMap<String, CommodityState> = BTreeMap::new();
|
||||
for commodity in &economy.commodities {
|
||||
let base_price = commodity.base_price;
|
||||
let base_demand = base_population_demand(system.population, &commodity.tier);
|
||||
// Warm start: all commodities get a baseline inventory so production
|
||||
// chains can run from tick 0. This represents the "economy already
|
||||
// operating" state rather than a cold start from empty warehouses.
|
||||
let stockpile = BASELINE_CAPACITY * INITIAL_STOCKPILE_BUFFER;
|
||||
commodity_states.insert(
|
||||
commodity.id.clone(),
|
||||
CommodityState {
|
||||
supply: 0.0,
|
||||
demand: base_demand,
|
||||
price: base_price,
|
||||
stockpile,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
nodes.insert(
|
||||
system_id.clone(),
|
||||
NodeState {
|
||||
system_id: system_id.clone(),
|
||||
commodities: commodity_states,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
nodes
|
||||
}
|
||||
|
||||
/// Baseline population-driven demand for direct consumption.
|
||||
///
|
||||
/// Raw and intermediate commodities have zero direct population demand —
|
||||
/// they are consumed through production chains only.
|
||||
fn base_population_demand(population: i64, tier: &str) -> f64 {
|
||||
let pop = population as f64;
|
||||
match tier {
|
||||
"final" => pop * DEMAND_PER_CAPITA_FINAL,
|
||||
"service_professional" | "service_luxury" => pop * DEMAND_PER_CAPITA_SERVICE,
|
||||
_ => 0.0, // raw and intermediate: demand comes from production chain inputs only
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulation step
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fraction of formal demand that shadow economy can satisfy at intensity=1.0.
|
||||
///
|
||||
/// Shadow goods circulate outside formal channels, reducing stockpile
|
||||
/// consumption by formal-sector demand. At 0% intensity, no shadow goods.
|
||||
/// At 100% intensity, shadow goods meet up to this fraction of demand.
|
||||
const SHADOW_DEMAND_COVERAGE: f64 = 0.30;
|
||||
|
||||
/// Single simulation tick: Layer 1 production + demand + price adjustment.
|
||||
///
|
||||
/// `event_mods` carries per-(node, commodity) multipliers from active D-180 events.
|
||||
/// Pass `&EventModifiers::default()` when no events are active.
|
||||
///
|
||||
/// Public for use by [`crate::sim::Simulation`] and external stateful runners.
|
||||
pub fn step_inner(
|
||||
economy: &Economy,
|
||||
productivity: &BTreeMap<(String, String), Productivity>,
|
||||
shadow: &ShadowEconomy,
|
||||
archetypes: &BTreeMap<String, agents::Archetype>,
|
||||
nodes: &mut BTreeMap<String, NodeState>,
|
||||
event_mods: &EventModifiers,
|
||||
alpha: f64,
|
||||
) {
|
||||
// Process each active node independently (Layer 1: no inter-system trade)
|
||||
let system_ids: Vec<String> = nodes.keys().cloned().collect();
|
||||
|
||||
for system_id in &system_ids {
|
||||
let node = nodes.get_mut(system_id).unwrap();
|
||||
let system_info = match economy.systems.get(system_id) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Reset per-tick supply
|
||||
for state in node.commodities.values_mut() {
|
||||
state.supply = 0.0;
|
||||
}
|
||||
|
||||
// --- Production step ---
|
||||
// For each corp present at this node, run the production chains
|
||||
// that produce their primary_operation commodity.
|
||||
let corps = economy
|
||||
.presences_by_system
|
||||
.get(system_id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
for corp_presence in &corps {
|
||||
let prod = match productivity.get(&(corp_presence.corp_id.clone(), system_id.clone())) {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let primary_op = match &corp_presence.primary_operation {
|
||||
Some(op) => op.clone(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Layer 3: behavioral archetype parameters for this corporation
|
||||
let arch_params = archetypes
|
||||
.get(&corp_presence.corp_id)
|
||||
.map(|a| a.params())
|
||||
.unwrap_or_else(|| agents::Archetype::Producer.params());
|
||||
|
||||
// D-180: capacity multiplier from active events (1.0 if no event)
|
||||
let cap_mult = event_mods.capacity_for(system_id.as_str(), &primary_op);
|
||||
// Effective baseline = BASELINE_CAPACITY scaled by archetype and event
|
||||
let effective_capacity = BASELINE_CAPACITY * arch_params.production_scale * cap_mult;
|
||||
|
||||
// Determine the tier of the primary_operation commodity
|
||||
let tier = economy
|
||||
.commodity_map
|
||||
.get(&primary_op)
|
||||
.map(|c| c.tier.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if tier == "raw" {
|
||||
// Raw materials: direct extraction — no chain inputs required (D-177).
|
||||
// D-180: productivity multiplier from active events (1.0 if no event)
|
||||
let prod_mult_event =
|
||||
event_mods.productivity_for(system_id.as_str(), &primary_op);
|
||||
let gross_output = effective_capacity * prod.extraction_rate * prod_mult_event;
|
||||
// Monopolist withholds a fraction of output
|
||||
let net_output = gross_output * (1.0 - arch_params.supply_withheld);
|
||||
if let Some(state) = node.commodities.get_mut(&primary_op) {
|
||||
state.supply += net_output;
|
||||
}
|
||||
} else {
|
||||
// Intermediate / final goods: run production chain with Leontief inputs.
|
||||
let chains = match economy.chains_by_output.get(&primary_op) {
|
||||
Some(c) => c.clone(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
for chain in &chains {
|
||||
// Leontief constraint: minimum input availability fraction
|
||||
let mut capacity_fraction = 1.0_f64;
|
||||
for input in &chain.inputs {
|
||||
if let Some(state) = node.commodities.get(&input.commodity_id) {
|
||||
let available = state.stockpile;
|
||||
let required = input.quantity * effective_capacity;
|
||||
if required > 0.0 {
|
||||
capacity_fraction =
|
||||
capacity_fraction.min(available / required).clamp(0.0, 1.0);
|
||||
}
|
||||
} else {
|
||||
capacity_fraction = 0.0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply productivity multipliers (seeded + event)
|
||||
let prod_mult = prod.for_tier(&chain_output_tier(economy, chain));
|
||||
let prod_mult_event = event_mods
|
||||
.productivity_for(system_id.as_str(), &chain.output_commodity_id);
|
||||
let gross_output = effective_capacity
|
||||
* chain.output_quantity
|
||||
* capacity_fraction
|
||||
* prod_mult
|
||||
* prod_mult_event;
|
||||
let net_output = gross_output * (1.0 - arch_params.supply_withheld);
|
||||
|
||||
// Consume inputs (Leontief: fixed-coefficient deduction)
|
||||
for input in &chain.inputs {
|
||||
if let Some(state) = node.commodities.get_mut(&input.commodity_id) {
|
||||
let consumed = input.quantity * effective_capacity * capacity_fraction;
|
||||
state.stockpile = (state.stockpile - consumed).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Add net output to supply
|
||||
if let Some(state) = node.commodities.get_mut(&chain.output_commodity_id) {
|
||||
state.supply += net_output;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Price premium: apply archetype price signal to primary commodity at this node.
|
||||
// Positive premium pushes price up; negative discounts it.
|
||||
// Applied as a small additive tâtonnement nudge capped to avoid instability.
|
||||
if arch_params.price_premium.abs() > 1e-6 {
|
||||
if let Some(state) = node.commodities.get_mut(&primary_op) {
|
||||
let base_price = economy
|
||||
.commodity_map
|
||||
.get(&primary_op)
|
||||
.map_or(1.0, |c| c.base_price);
|
||||
let nudge = base_price * arch_params.price_premium * alpha;
|
||||
state.price = (state.price + nudge).clamp(base_price * 0.05, base_price * 20.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Demand step ---
|
||||
// Population demand for final goods and services.
|
||||
// Industrial demand (chain inputs) was already deducted during production.
|
||||
//
|
||||
// Shadow economy (D-174): shadow goods satisfy a fraction of formal demand,
|
||||
// reducing formal-sector stockpile consumption proportionally.
|
||||
//
|
||||
// D-180: DemandShock events multiply demand further (or compress it).
|
||||
let shadow_intensity = shadow.intensity.get(system_id).copied().unwrap_or(0.0);
|
||||
let shadow_coverage = shadow_intensity * SHADOW_DEMAND_COVERAGE;
|
||||
|
||||
for commodity in &economy.commodities {
|
||||
let base_demand = base_population_demand(system_info.population, &commodity.tier);
|
||||
|
||||
// D-186/D-188: reduce fusion_fuel utility demand if gate energy is connected
|
||||
let gate_reduced = if commodity.id == "fusion_fuel"
|
||||
&& system_info.gate_energy_connected
|
||||
&& commodity.tier != "raw"
|
||||
{
|
||||
base_demand * GATE_ENERGY_DEMAND_REDUCTION
|
||||
} else {
|
||||
base_demand
|
||||
};
|
||||
|
||||
// D-180: demand shock multiplier from active events (1.0 if no event)
|
||||
let demand_mult = event_mods.demand_for(system_id.as_str(), &commodity.id);
|
||||
|
||||
// Shadow economy reduces formal-sector consumption (some demand met off-books)
|
||||
let demand = gate_reduced * demand_mult * (1.0 - shadow_coverage);
|
||||
|
||||
if let Some(state) = node.commodities.get_mut(&commodity.id) {
|
||||
state.demand = demand;
|
||||
// Domestic consumption from stockpile
|
||||
state.stockpile = (state.stockpile - demand).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Stockpile update ---
|
||||
// Add this tick's supply to stockpile
|
||||
for state in node.commodities.values_mut() {
|
||||
state.stockpile += state.supply;
|
||||
}
|
||||
|
||||
// --- Price adjustment (tâtonnement, Layer 1 local) ---
|
||||
// Adjust based on stockpile level relative to demand.
|
||||
// At equilibrium, stockpile ≈ INITIAL_STOCKPILE_BUFFER × demand.
|
||||
for (commodity_id, state) in &mut node.commodities {
|
||||
let equilibrium_stock = state.demand * INITIAL_STOCKPILE_BUFFER;
|
||||
let base_price = economy
|
||||
.commodity_map
|
||||
.get(commodity_id)
|
||||
.map_or(1.0, |c| c.base_price);
|
||||
|
||||
// Positive excess → price falls; negative excess → price rises
|
||||
let excess = if equilibrium_stock > 0.0 {
|
||||
(state.stockpile - equilibrium_stock) / equilibrium_stock
|
||||
} else if state.supply > 0.0 {
|
||||
1.0 // over-supplied vs zero demand
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
state.price =
|
||||
(state.price * (1.0 - alpha * excess)).clamp(base_price * 0.05, base_price * 20.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up the tier of the output commodity for a given chain.
|
||||
fn chain_output_tier(economy: &Economy, chain: &crate::db::ProductionChain) -> String {
|
||||
economy
|
||||
.commodity_map
|
||||
.get(&chain.output_commodity_id)
|
||||
.map(|c| c.tier.clone())
|
||||
.unwrap_or_else(|| "intermediate".to_string())
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! CSV output for simulation snapshots.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{self, BufWriter, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::model::TickRecord;
|
||||
|
||||
/// Write records to CSV. If `path` is None, writes to stdout.
|
||||
///
|
||||
/// Columns: node_id, commodity_id, supply, demand, price, tick,
|
||||
/// shadow_intensity, tractus_mark_rate
|
||||
pub fn write_csv(records: &[TickRecord], path: Option<&Path>) -> io::Result<()> {
|
||||
let header =
|
||||
"node_id,commodity_id,supply,demand,price,tick,shadow_intensity,tractus_mark_rate\n";
|
||||
|
||||
let write_record = |w: &mut dyn Write, r: &TickRecord| -> io::Result<()> {
|
||||
writeln!(
|
||||
w,
|
||||
"{},{},{:.4},{:.4},{:.4},{},{:.4},{:.6}",
|
||||
r.node_id,
|
||||
r.commodity_id,
|
||||
r.supply,
|
||||
r.demand,
|
||||
r.price,
|
||||
r.tick,
|
||||
r.shadow_intensity,
|
||||
r.tractus_mark_rate,
|
||||
)
|
||||
};
|
||||
|
||||
match path {
|
||||
Some(p) => {
|
||||
let file = File::create(p)?;
|
||||
let mut w = BufWriter::new(file);
|
||||
write!(w, "{}", header)?;
|
||||
for r in records {
|
||||
write_record(&mut w, r)?;
|
||||
}
|
||||
w.flush()
|
||||
}
|
||||
None => {
|
||||
let stdout = io::stdout();
|
||||
let mut w = BufWriter::new(stdout.lock());
|
||||
write!(w, "{}", header)?;
|
||||
for r in records {
|
||||
write_record(&mut w, r)?;
|
||||
}
|
||||
w.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Shared PRNG helpers for deterministic seeding (D-176, D-174).
|
||||
//!
|
||||
//! Both seed.rs and currency.rs use the same FNV-1a mix + Box-Muller transform.
|
||||
//! Centralised here to guarantee identical derivation chains across modules.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use rand::Rng;
|
||||
use rand_chacha::ChaCha8Rng;
|
||||
|
||||
/// FNV-1a 64-bit offset basis.
|
||||
const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
|
||||
|
||||
/// FNV-1a 64-bit prime.
|
||||
const FNV_PRIME: u64 = 0x100000001b3;
|
||||
|
||||
/// Deterministic per-key seed: FNV-1a of `key` mixed with `run_seed`.
|
||||
///
|
||||
/// Starting from `run_seed + FNV_OFFSET_BASIS` provides per-run variation
|
||||
/// while preserving the FNV avalanche properties across keys.
|
||||
pub fn derive_seed(run_seed: u64, key: &str) -> u64 {
|
||||
let mut h = run_seed.wrapping_add(FNV_OFFSET_BASIS);
|
||||
for byte in key.bytes() {
|
||||
h ^= byte as u64;
|
||||
h = h.wrapping_mul(FNV_PRIME);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Box-Muller transform: standard normal variate from a ChaCha8 stream.
|
||||
pub fn standard_normal(rng: &mut ChaCha8Rng) -> f64 {
|
||||
let u1: f64 = 1.0 - rng.random::<f64>(); // avoid ln(0)
|
||||
let u2: f64 = rng.random::<f64>();
|
||||
(-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Productivity seeding — D-176.
|
||||
//!
|
||||
//! Per-run PRNG seeding of corporation×site productivity on five dimensions.
|
||||
//! Log-normal distribution with corridor correlation ~0.6.
|
||||
//!
|
||||
//! What CANNOT be seeded (D-177): location of production, biological monopoly
|
||||
//! ceilings, aging pipeline contents, gate topology.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use rand::SeedableRng;
|
||||
use rand_chacha::ChaCha8Rng;
|
||||
|
||||
use crate::db::Economy;
|
||||
use crate::prng::{derive_seed, standard_normal};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Productivity record (D-176)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Productivity {
|
||||
/// Output per unit time from mines, wells, fisheries
|
||||
pub extraction_rate: f64,
|
||||
/// Units processed per tick in manufacturing and refineries
|
||||
pub processing_throughput: f64,
|
||||
/// Freight volume per gate crossing for logistics operators — used in #807 (trade flows)
|
||||
#[allow(dead_code)]
|
||||
pub transit_capacity: f64,
|
||||
/// Clients served per tick for service firms
|
||||
pub service_throughput: f64,
|
||||
/// Maximum concurrent engagements for service firms — used in #809 (agents)
|
||||
#[allow(dead_code)]
|
||||
pub service_capacity: f64,
|
||||
}
|
||||
|
||||
impl Productivity {
|
||||
/// Multiplier appropriate for a given commodity tier.
|
||||
pub fn for_tier(&self, tier: &str) -> f64 {
|
||||
match tier {
|
||||
"raw" => self.extraction_rate,
|
||||
"intermediate" => self.processing_throughput,
|
||||
"final" => self.processing_throughput,
|
||||
"service_professional" | "service_luxury" => self.service_throughput,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seeding entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Seed productivity for all corp×system pairs.
|
||||
///
|
||||
/// Returns a map keyed by (corp_id, system_id) → Productivity.
|
||||
pub fn seed_all_productivity(
|
||||
economy: &Economy,
|
||||
run_seed: u64,
|
||||
) -> BTreeMap<(String, String), Productivity> {
|
||||
// σ for standard nodes: chosen so that exp(±2σ) ≈ [0.4, 1.8] at 95%
|
||||
// Geometric mean of [0.4, 1.8] ≈ 0.849. μ = ln(0.849) ≈ −0.164.
|
||||
// We use μ=0 (geometric mean = 1) and wider σ; the clamp enforces the range.
|
||||
let sigma_total: f64 = 0.38;
|
||||
|
||||
// Corridor-shared variance fraction: ρ = 0.6 (D-176)
|
||||
let rho: f64 = 0.6;
|
||||
let sigma_shared = (rho).sqrt() * sigma_total;
|
||||
let sigma_individual = (1.0 - rho).sqrt() * sigma_total;
|
||||
|
||||
// Pre-compute corridor Z values (shared across all corps in the same corridor)
|
||||
let mut corridor_z: BTreeMap<String, f64> = BTreeMap::new();
|
||||
|
||||
let mut result = BTreeMap::new();
|
||||
|
||||
for cp in &economy.corp_presences {
|
||||
let system = match economy.systems.get(&cp.system_id) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Corridor shared factor
|
||||
let corridor_contribution = if let Some(corr) = &system.cultural_corridor {
|
||||
let z = *corridor_z.entry(corr.clone()).or_insert_with(|| {
|
||||
let seed = derive_seed(run_seed, corr);
|
||||
let mut rng = ChaCha8Rng::seed_from_u64(seed);
|
||||
standard_normal(&mut rng)
|
||||
});
|
||||
sigma_shared * z
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Individual factor per corp×site
|
||||
let key = format!("{}:{}", cp.corp_id, cp.system_id);
|
||||
let site_seed = derive_seed(run_seed, &key);
|
||||
let mut rng = ChaCha8Rng::seed_from_u64(site_seed);
|
||||
|
||||
let sample = |rng: &mut ChaCha8Rng| -> f64 {
|
||||
let individual_z = standard_normal(rng);
|
||||
let combined = corridor_contribution + sigma_individual * individual_z;
|
||||
combined.exp().clamp(0.4, 1.8)
|
||||
};
|
||||
|
||||
let prod = Productivity {
|
||||
extraction_rate: sample(&mut rng),
|
||||
processing_throughput: sample(&mut rng),
|
||||
transit_capacity: sample(&mut rng),
|
||||
service_throughput: sample(&mut rng),
|
||||
service_capacity: sample(&mut rng),
|
||||
};
|
||||
|
||||
result.insert((cp.corp_id.clone(), cp.system_id.clone()), prod);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Stateful simulation runner for server integration (#821).
|
||||
//!
|
||||
//! [`Simulation`] wraps all simulation state (economy data, node states,
|
||||
//! currency, events) and exposes a per-tick `step()` method. This is the
|
||||
//! entry point for the game server's economy system, which advances one
|
||||
//! economy tick per ECON_TICK_RATE game ticks (D-031).
|
||||
//!
|
||||
//! The batch `model::run_with_events` is retained for the CLI binary and
|
||||
//! stability checks. Both share the same underlying `model::step_inner`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{agents, currency, db, events, model, seed, trade};
|
||||
|
||||
/// Stateful Settled Reach economics simulation.
|
||||
///
|
||||
/// Initialize with [`Simulation::load`] once at server startup.
|
||||
/// Call [`Simulation::step`] once per economy tick.
|
||||
pub struct Simulation {
|
||||
pub economy: db::Economy,
|
||||
productivity: BTreeMap<(String, String), seed::Productivity>,
|
||||
shadow: currency::ShadowEconomy,
|
||||
adjacency: BTreeMap<String, Vec<String>>,
|
||||
archetypes: BTreeMap<String, agents::Archetype>,
|
||||
pub nodes: BTreeMap<String, model::NodeState>,
|
||||
currency_state: currency::CurrencyState,
|
||||
/// The event input port (D-180). Push events here; they are consumed
|
||||
/// on the next `step()` call.
|
||||
pub events: events::EventPort,
|
||||
/// Number of economy ticks processed so far.
|
||||
tick: u64,
|
||||
/// Tâtonnement step size (α). Runtime-tunable via SetEconParam (#823).
|
||||
/// Default: `model::ALPHA` (0.03).
|
||||
pub alpha: f64,
|
||||
/// Trade flow damping factor (β). Runtime-tunable via SetEconParam (#823).
|
||||
/// Default: `trade::BETA` (0.4).
|
||||
pub beta: f64,
|
||||
}
|
||||
|
||||
impl Simulation {
|
||||
/// Load economy data from `db_path` and initialize the simulation.
|
||||
///
|
||||
/// `run_seed` is the per-run PRNG seed for productivity seeding (D-176).
|
||||
/// This is typically the game's world seed from `StartupMessage`.
|
||||
///
|
||||
/// The DB is opened once and the loaded data stored in memory.
|
||||
/// Do NOT call this per tick.
|
||||
pub fn load(db_path: &Path, run_seed: u64) -> Result<Self, String> {
|
||||
let db_pathbuf = db_path.to_path_buf();
|
||||
if !db_path.exists() {
|
||||
return Err(format!("economy DB not found: {}", db_path.display()));
|
||||
}
|
||||
|
||||
let conn = db::open_db(&db_pathbuf);
|
||||
let economy = db::load_economy(&conn);
|
||||
let productivity = seed::seed_all_productivity(&economy, run_seed);
|
||||
let shadow = currency::seed_shadow_economy(&economy, run_seed);
|
||||
let adjacency = trade::build_adjacency(&economy);
|
||||
let archetypes = agents::build_archetype_map(economy.corp_archetype_data.clone());
|
||||
let nodes = model::init_nodes(&economy);
|
||||
|
||||
Ok(Simulation {
|
||||
economy,
|
||||
productivity,
|
||||
shadow,
|
||||
adjacency,
|
||||
archetypes,
|
||||
nodes,
|
||||
currency_state: currency::CurrencyState::new(),
|
||||
events: events::EventPort::new(),
|
||||
tick: 0,
|
||||
alpha: model::ALPHA,
|
||||
beta: trade::BETA,
|
||||
})
|
||||
}
|
||||
|
||||
/// Try to load from the auto-detected DB path (same search as the CLI binary).
|
||||
///
|
||||
/// Searches up from CWD for `server/data/systems.db`.
|
||||
pub fn load_auto(run_seed: u64) -> Result<Self, String> {
|
||||
let mut dir = std::env::current_dir().map_err(|e| e.to_string())?;
|
||||
loop {
|
||||
let candidate = dir.join("server").join("data").join("systems.db");
|
||||
if candidate.exists() {
|
||||
return Self::load(&candidate, run_seed);
|
||||
}
|
||||
if !dir.pop() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Also check adjacent `data/` directory (when running from within server/)
|
||||
let candidate = std::path::PathBuf::from("data").join("systems.db");
|
||||
if candidate.exists() {
|
||||
return Self::load(&candidate, run_seed);
|
||||
}
|
||||
Err("cannot find server/data/systems.db — pass path explicitly or run from project root".to_string())
|
||||
}
|
||||
|
||||
/// Advance the simulation by one economy tick.
|
||||
///
|
||||
/// Applies active events, runs the Layer 1+2+3 step, and advances the
|
||||
/// event port. Call once per economy tick (every ECON_TICK_RATE game ticks).
|
||||
pub fn step(&mut self) {
|
||||
// Activate any events scheduled for this tick (D-180)
|
||||
self.events.activate_scheduled(self.tick);
|
||||
|
||||
let mods = self.events.compute_modifiers(&self.economy);
|
||||
|
||||
model::step_inner(
|
||||
&self.economy,
|
||||
&self.productivity,
|
||||
&self.shadow,
|
||||
&self.archetypes,
|
||||
&mut self.nodes,
|
||||
&mods,
|
||||
self.alpha,
|
||||
);
|
||||
|
||||
self.currency_state.apply_exchange_shock(mods.exchange_shock);
|
||||
trade::trade_step(
|
||||
&self.economy,
|
||||
&mut self.nodes,
|
||||
&self.adjacency,
|
||||
&mut self.currency_state,
|
||||
self.beta,
|
||||
);
|
||||
self.currency_state.update_rate();
|
||||
|
||||
// Expire finished events
|
||||
self.events.advance_remaining();
|
||||
|
||||
self.tick += 1;
|
||||
}
|
||||
|
||||
/// Number of economy ticks processed so far.
|
||||
pub fn tick(&self) -> u64 {
|
||||
self.tick
|
||||
}
|
||||
|
||||
/// Current Tractus/Mark exchange rate.
|
||||
pub fn tractus_mark_rate(&self) -> f64 {
|
||||
self.currency_state.tractus_mark_rate
|
||||
}
|
||||
|
||||
/// Read-only access to the loaded economy data.
|
||||
pub fn economy(&self) -> &db::Economy {
|
||||
&self.economy
|
||||
}
|
||||
|
||||
/// Read-only access to the per-node shadow economy intensities.
|
||||
pub fn shadow(&self) -> ¤cy::ShadowEconomy {
|
||||
&self.shadow
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Layer 2: Spatial price equilibrium via damped tâtonnement (D-178).
|
||||
//!
|
||||
//! Goods flow along direct gate links when price differentials exceed
|
||||
//! transport costs. Multi-hop propagation occurs over multiple ticks as
|
||||
//! direct-neighbor flows compound. β=0.4 dampens flows to prevent cobweb
|
||||
//! oscillation.
|
||||
//!
|
||||
//! Currency zone friction (D-172): cross-zone (TRACTUS ↔ MARK) trade incurs
|
||||
//! an additional 3% cost. Net cross-zone flow drives the floating exchange
|
||||
//! rate adjustment (D-171).
|
||||
//!
|
||||
//! Gate links are bidirectional in the DB; `build_adjacency` builds the
|
||||
//! full adjacency map directly from them.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::currency::CurrencyState;
|
||||
use crate::db::Economy;
|
||||
use crate::model::NodeState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants (D-178)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Transport cost per gate hop (midpoint of 5–12% range from D-178).
|
||||
const GATE_COST_PER_HOP: f64 = 0.08;
|
||||
|
||||
/// Damping factor β (D-178): fraction of potential flow that actually moves
|
||||
/// per tick. Prevents cobweb oscillation.
|
||||
/// Exposed as pub so `Simulation` can default to it and `SetEconParam` can reset to it (#823).
|
||||
pub const BETA: f64 = 0.4;
|
||||
|
||||
/// Maximum fraction of a node's stockpile exported per tick via a single link.
|
||||
/// Limits shock propagation speed.
|
||||
const MAX_EXPORT_FRACTION: f64 = 0.15;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adjacency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a direct-neighbor map from the gate link list.
|
||||
///
|
||||
/// DB stores links bidirectionally (A→B and B→A both present), so we
|
||||
/// collect them as-is without adding reverse edges. The resulting map
|
||||
/// covers all active market nodes that have at least one gate connection.
|
||||
pub fn build_adjacency(economy: &Economy) -> BTreeMap<String, Vec<String>> {
|
||||
let mut adj: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
for link in &economy.gate_links {
|
||||
adj.entry(link.from_system_id.clone())
|
||||
.or_default()
|
||||
.push(link.to_system_id.clone());
|
||||
}
|
||||
adj
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trade step
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Apply one tick of inter-node trade flows along direct gate links.
|
||||
///
|
||||
/// For each directed gate link (A → B): if the price of a commodity in A,
|
||||
/// after paying transport and currency costs, is still below the price in B,
|
||||
/// goods flow from A to B. Cross-zone (TRACTUS ↔ MARK) links incur an
|
||||
/// additional 3% conversion friction (D-172).
|
||||
///
|
||||
/// Net cross-zone flow is accumulated in `currency` to drive exchange rate
|
||||
/// adjustment each tick (D-171).
|
||||
///
|
||||
/// All flows are computed from the pre-step state and applied atomically
|
||||
/// to avoid order-dependent artifacts.
|
||||
pub fn trade_step(
|
||||
economy: &Economy,
|
||||
nodes: &mut BTreeMap<String, NodeState>,
|
||||
adjacency: &BTreeMap<String, Vec<String>>,
|
||||
currency: &mut CurrencyState,
|
||||
beta: f64,
|
||||
) {
|
||||
// Collect pending flows before mutating (snapshot prices/stockpiles first)
|
||||
// (from_system, to_system, commodity_id, amount, cross_zone_tractus_to_mark)
|
||||
let mut flows: Vec<(String, String, String, f64, f64)> = Vec::new();
|
||||
|
||||
for (from_id, neighbors) in adjacency {
|
||||
let from_node = match nodes.get(from_id.as_str()) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
let from_zone = economy
|
||||
.systems
|
||||
.get(from_id.as_str())
|
||||
.map(|s| s.currency_zone.as_str())
|
||||
.unwrap_or("TRACTUS_PRIMARY");
|
||||
|
||||
for to_id in neighbors {
|
||||
let to_node = match nodes.get(to_id.as_str()) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
let to_zone = economy
|
||||
.systems
|
||||
.get(to_id.as_str())
|
||||
.map(|s| s.currency_zone.as_str())
|
||||
.unwrap_or("TRACTUS_PRIMARY");
|
||||
|
||||
let gate_cost = 1.0 + GATE_COST_PER_HOP;
|
||||
// zone_cost is a raw fraction (0.0 or 0.03); combine multiplicatively
|
||||
let zone_cost = currency.zone_friction_factor(from_zone, to_zone);
|
||||
let cost_factor = gate_cost * (1.0 + zone_cost);
|
||||
|
||||
// Sign: positive = Tractus zone exporting to Mark zone
|
||||
let cross_zone_sign = if from_zone == "TRACTUS_PRIMARY" && to_zone == "MARK_PRIMARY" {
|
||||
1.0_f64
|
||||
} else if from_zone == "MARK_PRIMARY" && to_zone == "TRACTUS_PRIMARY" {
|
||||
-1.0_f64
|
||||
} else {
|
||||
0.0_f64
|
||||
};
|
||||
|
||||
for (commodity_id, from_state) in &from_node.commodities {
|
||||
let to_state = match to_node.commodities.get(commodity_id) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Only trade if profitable after full cost
|
||||
let effective_price = from_state.price * cost_factor;
|
||||
if effective_price >= to_state.price {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalised price differential ∈ (0, 1) drives flow magnitude
|
||||
let price_ratio = (to_state.price - effective_price) / to_state.price;
|
||||
|
||||
// Damped flow capped at MAX_EXPORT_FRACTION of exporter's stockpile
|
||||
let max_export = from_state.stockpile * MAX_EXPORT_FRACTION;
|
||||
let flow = beta * price_ratio * max_export;
|
||||
|
||||
if flow > 1e-6 {
|
||||
flows.push((
|
||||
from_id.clone(),
|
||||
to_id.clone(),
|
||||
commodity_id.clone(),
|
||||
flow,
|
||||
cross_zone_sign * flow,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply flows and accumulate cross-zone net flow for exchange rate
|
||||
for (from_id, to_id, commodity_id, amount, cross_zone_contrib) in flows {
|
||||
if let Some(from_node) = nodes.get_mut(&from_id) {
|
||||
if let Some(state) = from_node.commodities.get_mut(&commodity_id) {
|
||||
state.stockpile = (state.stockpile - amount).max(0.0);
|
||||
}
|
||||
}
|
||||
if let Some(to_node) = nodes.get_mut(&to_id) {
|
||||
if let Some(state) = to_node.commodities.get_mut(&commodity_id) {
|
||||
state.stockpile += amount;
|
||||
}
|
||||
}
|
||||
currency.net_cross_zone_flow += cross_zone_contrib;
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,19 @@
|
||||
Import economics data into systems.db.
|
||||
|
||||
Reads TOML/JSON source files and populates the economics tables:
|
||||
- gate_links from docs/design/star-map.json (335 edges, bidirectional)
|
||||
- commodities from wiki/economics/commodities.toml (36 types)
|
||||
- gate_links from docs/design/star-map.json (335 edges, bidirectional)
|
||||
- commodities from wiki/economics/commodities.toml (36 types)
|
||||
- production_chains + chain_inputs from wiki/economics/production_chains.toml
|
||||
- currency_zone on star_systems (default TRACTUS_PRIMARY)
|
||||
- currency_zone on star_systems (default TRACTUS_PRIMARY)
|
||||
- gate_energy_connected on star_systems (D-186: false for MARK_PRIMARY zones)
|
||||
- corporations from wiki/corporations/*.md (sync + insert new records)
|
||||
- corp_presence from wiki/corporations/*.md (headquarters location data)
|
||||
|
||||
Does NOT populate corp_presence — that's a future pipeline step.
|
||||
Validation (hard errors, non-zero exit on any failure):
|
||||
- Wiki corporation names must match DB proper_name records (D-182 sync constraint)
|
||||
- Chain completeness: every intermediate commodity has at least one production chain
|
||||
- Commodity coverage: 3+ corporations per major commodity type (D-175)
|
||||
- System coverage: 1+ corporation per inhabited system with population > 100K (D-175)
|
||||
|
||||
Usage:
|
||||
python3 tooling/economy-db/import_economics.py
|
||||
@@ -18,6 +25,7 @@ Usage:
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import tomllib
|
||||
@@ -30,6 +38,7 @@ 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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -102,6 +111,7 @@ CREATE INDEX IF NOT EXISTS idx_corp_presence_location ON corp_presence(location_
|
||||
# Columns to add to existing tables (ALTER TABLE is idempotent via try/except)
|
||||
COLUMN_MIGRATIONS = [
|
||||
("star_systems", "currency_zone", "TEXT DEFAULT 'TRACTUS_PRIMARY'"),
|
||||
("star_systems", "gate_energy_connected", "INTEGER DEFAULT 1"),
|
||||
("corporations", "behavioral_archetype", "TEXT"),
|
||||
("corporations", "supply_chain_role", "TEXT"),
|
||||
("corporations", "shadow_economy_access", "INTEGER DEFAULT 0"),
|
||||
@@ -244,17 +254,44 @@ def import_chains(conn: sqlite3.Connection, dry_run: bool) -> tuple[int, int]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def set_currency_zones(conn: sqlite3.Connection, dry_run: bool) -> dict:
|
||||
"""Set currency_zone on star_systems. Default TRACTUS_PRIMARY, Sol = MIXED."""
|
||||
"""Set currency_zone on star_systems from wiki/economics/currency_zones.toml.
|
||||
|
||||
Default: TRACTUS_PRIMARY. Sol (GJ 0): MIXED (set before file is read).
|
||||
MARK_PRIMARY and MIXED assignments come from the TOML file (D-172).
|
||||
"""
|
||||
if dry_run:
|
||||
return {"TRACTUS_PRIMARY": "all", "MIXED": "GJ 0"}
|
||||
return {"TRACTUS_PRIMARY": "all", "MIXED": "GJ 0 + toml"}
|
||||
|
||||
# Default everything to TRACTUS_PRIMARY
|
||||
conn.execute("UPDATE star_systems SET currency_zone = 'TRACTUS_PRIMARY' WHERE currency_zone IS NULL")
|
||||
conn.execute("UPDATE star_systems SET currency_zone = 'TRACTUS_PRIMARY'")
|
||||
|
||||
# Sol system is MIXED (Earth legacy currency presence)
|
||||
# Sol system is MIXED (Earth legacy currency presence — set before TOML load)
|
||||
conn.execute("UPDATE star_systems SET currency_zone = 'MIXED' WHERE system_id = 'GJ 0'")
|
||||
|
||||
# Future: Compact systems → MARK_PRIMARY (requires authored Compact membership data)
|
||||
# Load MARK_PRIMARY and MIXED assignments from authored TOML (D-172)
|
||||
zones_path = REPO_ROOT / "wiki" / "economics" / "currency_zones.toml"
|
||||
if zones_path.exists():
|
||||
import tomllib # Python 3.11+
|
||||
|
||||
with open(zones_path, "rb") as f:
|
||||
zones = tomllib.load(f)
|
||||
|
||||
mark_ids = [entry["system_id"] for entry in zones.get("mark_primary", [])]
|
||||
mixed_ids = [entry["system_id"] for entry in zones.get("mixed", [])]
|
||||
|
||||
for sid in mark_ids:
|
||||
conn.execute(
|
||||
"UPDATE star_systems SET currency_zone = 'MARK_PRIMARY' WHERE system_id = ?",
|
||||
(sid,),
|
||||
)
|
||||
for sid in mixed_ids:
|
||||
conn.execute(
|
||||
"UPDATE star_systems SET currency_zone = 'MIXED' WHERE system_id = ?",
|
||||
(sid,),
|
||||
)
|
||||
else:
|
||||
print(" warning: wiki/economics/currency_zones.toml not found — "
|
||||
"all systems default to TRACTUS_PRIMARY / Sol to MIXED")
|
||||
|
||||
counts = {}
|
||||
for row in conn.execute("SELECT currency_zone, COUNT(*) FROM star_systems GROUP BY currency_zone"):
|
||||
@@ -263,11 +300,277 @@ def set_currency_zones(conn: sqlite3.Connection, dry_run: bool) -> dict:
|
||||
return counts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate energy connectivity (D-186)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def set_gate_energy(conn: sqlite3.Connection, dry_run: bool) -> dict:
|
||||
"""Set gate_energy_connected on star_systems based on currency_zone.
|
||||
|
||||
MARK_PRIMARY zones default to false (Compact refused Gate Corp dependency).
|
||||
All other zones default to true.
|
||||
"""
|
||||
if dry_run:
|
||||
return {"on_grid": "non-MARK_PRIMARY", "off_grid": "MARK_PRIMARY"}
|
||||
|
||||
# Default: all systems on-grid
|
||||
conn.execute("UPDATE star_systems SET gate_energy_connected = 1 WHERE gate_energy_connected IS NULL")
|
||||
|
||||
# MARK_PRIMARY zones are off-grid (Compact energy sovereignty)
|
||||
conn.execute("UPDATE star_systems SET gate_energy_connected = 0 WHERE currency_zone = 'MARK_PRIMARY'")
|
||||
|
||||
counts = {}
|
||||
for row in conn.execute(
|
||||
"SELECT gate_energy_connected, COUNT(*) FROM star_systems GROUP BY gate_energy_connected"
|
||||
):
|
||||
label = "on_grid" if row[0] == 1 else "off_grid"
|
||||
counts[label] = row[1]
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Corporation wiki parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_corp_frontmatter(path: Path) -> dict | None:
|
||||
"""Parse YAML frontmatter from a wiki corporation markdown file."""
|
||||
text = path.read_text()
|
||||
lines = text.split("\n")
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return None
|
||||
end_idx = None
|
||||
for i, line in enumerate(lines[1:], 1):
|
||||
if line.strip() == "---":
|
||||
end_idx = i
|
||||
break
|
||||
if end_idx is None:
|
||||
return None
|
||||
fm: dict = {}
|
||||
for line in lines[1:end_idx]:
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, _, val = line.partition(":")
|
||||
key = key.strip()
|
||||
val = val.strip()
|
||||
if val.startswith("[") and val.endswith("]"):
|
||||
items = [x.strip().strip('"').strip("'") for x in val[1:-1].split(",")]
|
||||
fm[key] = [item for item in items if item]
|
||||
else:
|
||||
fm[key] = val.strip('"').strip("'")
|
||||
return fm
|
||||
|
||||
|
||||
def load_wiki_corps() -> list[dict]:
|
||||
"""Load all wiki corporation files. Returns list of parsed corp records."""
|
||||
corps = []
|
||||
for md_file in sorted(CORPORATIONS_DIR.glob("*.md")):
|
||||
if md_file.name == "index.md":
|
||||
continue
|
||||
fm = _parse_corp_frontmatter(md_file)
|
||||
if not fm or not fm.get("slug") or not fm.get("title"):
|
||||
continue
|
||||
hq = fm.get("headquarters", "")
|
||||
m = re.search(r"\(([^)]+)\)", hq)
|
||||
system_id = m.group(1) if m else None
|
||||
corps.append({
|
||||
"corp_id": fm["slug"],
|
||||
"proper_name": fm["title"],
|
||||
"system_id": system_id,
|
||||
"tags": fm.get("tags", []),
|
||||
"scope": fm.get("scope", ""),
|
||||
})
|
||||
return corps
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Corporation sync (D-182: wiki is source of truth)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def sync_corporations(
|
||||
conn: sqlite3.Connection, wiki_corps: list[dict], dry_run: bool
|
||||
) -> list[str]:
|
||||
"""Sync wiki corps to DB. Hard error on proper_name divergence (D-182).
|
||||
|
||||
Returns list of error strings. Inserts corps that exist in wiki but not DB.
|
||||
Corps that exist only in DB (legacy records) are left untouched.
|
||||
headquarters_system is only written if the system_id exists in star_systems
|
||||
(to avoid FK violations when atlas hasn't yet registered the system).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
existing = {
|
||||
r[0]: r[1]
|
||||
for r in conn.execute("SELECT corp_id, proper_name FROM corporations").fetchall()
|
||||
}
|
||||
valid_systems = {
|
||||
r[0] for r in conn.execute("SELECT system_id FROM star_systems").fetchall()
|
||||
}
|
||||
|
||||
to_insert = []
|
||||
for corp in wiki_corps:
|
||||
corp_id = corp["corp_id"]
|
||||
proper_name = corp["proper_name"]
|
||||
if corp_id in existing:
|
||||
if existing[corp_id] != proper_name:
|
||||
errors.append(
|
||||
f"name divergence: corp_id='{corp_id}' "
|
||||
f"wiki='{proper_name}' db='{existing[corp_id]}'"
|
||||
)
|
||||
else:
|
||||
system_id = corp.get("system_id")
|
||||
hq_system = system_id if system_id and system_id in valid_systems else None
|
||||
if system_id and system_id not in valid_systems:
|
||||
print(f" warning: {corp_id} HQ system '{system_id}' not in DB, "
|
||||
f"headquarters_system set to NULL")
|
||||
to_insert.append((
|
||||
corp_id,
|
||||
proper_name,
|
||||
"corporation",
|
||||
corp.get("scope") or None,
|
||||
hq_system,
|
||||
))
|
||||
|
||||
if not dry_run and not errors:
|
||||
conn.executemany(
|
||||
"""INSERT OR IGNORE INTO corporations
|
||||
(corp_id, proper_name, corp_type, scope, headquarters_system)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
to_insert,
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Corp presence population
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_hq_location(
|
||||
conn: sqlite3.Connection,
|
||||
system_id: str,
|
||||
headquarters_body: str | None,
|
||||
) -> tuple[str, str] | None:
|
||||
"""Resolve a corp's HQ to a (location_id, location_type) pair.
|
||||
|
||||
Resolution order:
|
||||
1. Use headquarters_body from corporations table if set (body or station).
|
||||
2. Most-populated body in the system.
|
||||
3. Any body in the system.
|
||||
4. Any station in the system.
|
||||
Returns None if no body or station found.
|
||||
"""
|
||||
if headquarters_body:
|
||||
# Determine whether it's a body or station
|
||||
body = conn.execute(
|
||||
"SELECT body_id FROM bodies WHERE body_id = ?", (headquarters_body,)
|
||||
).fetchone()
|
||||
if body:
|
||||
return (headquarters_body, "body")
|
||||
station = conn.execute(
|
||||
"SELECT station_id FROM stations WHERE station_id = ?",
|
||||
(headquarters_body,),
|
||||
).fetchone()
|
||||
if station:
|
||||
return (headquarters_body, "station")
|
||||
|
||||
# Most-populated body
|
||||
body = conn.execute(
|
||||
"""SELECT body_id FROM bodies WHERE system_id = ?
|
||||
ORDER BY population DESC LIMIT 1""",
|
||||
(system_id,),
|
||||
).fetchone()
|
||||
if body:
|
||||
return (body[0], "body")
|
||||
|
||||
# Any station
|
||||
station = conn.execute(
|
||||
"SELECT station_id FROM stations WHERE system_id = ? LIMIT 1",
|
||||
(system_id,),
|
||||
).fetchone()
|
||||
if station:
|
||||
return (station[0], "station")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def import_corp_presence(
|
||||
conn: sqlite3.Connection,
|
||||
wiki_corps: list[dict],
|
||||
commodity_ids: set[str],
|
||||
dry_run: bool,
|
||||
) -> int:
|
||||
"""Populate corp_presence from wiki headquarters data.
|
||||
|
||||
Each corporation gets one presence row at its headquarters body or station.
|
||||
location_type is 'body' or 'station' per schema (D-182).
|
||||
primary_operation is set to the first commodity tag matching a known commodity ID.
|
||||
"""
|
||||
valid_systems = {
|
||||
r[0] for r in conn.execute("SELECT system_id FROM star_systems").fetchall()
|
||||
}
|
||||
|
||||
# Load headquarters_body from corporations table (set during import)
|
||||
hq_body_map: dict[str, str | None] = {
|
||||
r[0]: r[1]
|
||||
for r in conn.execute(
|
||||
"SELECT corp_id, headquarters_body FROM corporations"
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
rows = []
|
||||
skipped = []
|
||||
for corp in wiki_corps:
|
||||
system_id = corp.get("system_id")
|
||||
if not system_id:
|
||||
skipped.append(f"{corp['corp_id']} (no headquarters system parsed)")
|
||||
continue
|
||||
if system_id not in valid_systems:
|
||||
skipped.append(f"{corp['corp_id']} (system '{system_id}' not in DB)")
|
||||
continue
|
||||
|
||||
hq_body = hq_body_map.get(corp["corp_id"])
|
||||
location = _resolve_hq_location(conn, system_id, hq_body)
|
||||
if not location:
|
||||
skipped.append(
|
||||
f"{corp['corp_id']} (no body/station found in system '{system_id}')"
|
||||
)
|
||||
continue
|
||||
|
||||
location_id, location_type = location
|
||||
primary_op = next(
|
||||
(tag for tag in corp.get("tags", []) if tag in commodity_ids), None
|
||||
)
|
||||
rows.append((corp["corp_id"], location_id, location_type, primary_op))
|
||||
|
||||
if skipped:
|
||||
for s in skipped:
|
||||
print(f" warning: skipped corp_presence for {s}")
|
||||
|
||||
if not dry_run:
|
||||
conn.execute("DELETE FROM corp_presence")
|
||||
conn.executemany(
|
||||
"""INSERT OR IGNORE INTO corp_presence
|
||||
(corp_id, location_id, location_type, primary_operation)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
rows,
|
||||
)
|
||||
|
||||
return len(rows)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def validate(conn: sqlite3.Connection) -> list[str]:
|
||||
"""Validate structural integrity of imported data.
|
||||
|
||||
Checks FK integrity, chain commodity references, and chain completeness.
|
||||
These are hard blockers — broken data must not be committed.
|
||||
|
||||
Coverage validation (commodity/system thresholds) is separate and runs
|
||||
after commit via _validate_commodity_coverage() and _validate_system_coverage().
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# FK integrity
|
||||
@@ -297,9 +600,75 @@ 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}'")
|
||||
|
||||
# Chain completeness: every intermediate commodity must have at least one producer
|
||||
missing_chains = conn.execute("""
|
||||
SELECT c.commodity_id, c.name
|
||||
FROM commodities c
|
||||
WHERE c.tier = 'intermediate'
|
||||
AND c.commodity_id NOT IN (SELECT output_commodity_id FROM production_chains)
|
||||
ORDER BY c.commodity_id
|
||||
""").fetchall()
|
||||
for cid, name in missing_chains:
|
||||
errors.append(f"chain completeness: no production chain produces intermediate '{cid}' ({name})")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_commodity_coverage(
|
||||
conn: sqlite3.Connection, wiki_corps: list[dict], commodity_ids: set[str]
|
||||
) -> list[str]:
|
||||
"""3+ corporations per major commodity type (raw + intermediate). D-175."""
|
||||
errors: list[str] = []
|
||||
major = [
|
||||
r[0]
|
||||
for r in conn.execute(
|
||||
"SELECT commodity_id FROM commodities "
|
||||
"WHERE tier IN ('raw', 'intermediate') ORDER BY commodity_id"
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
# Build commodity → corp set from wiki tags filtered to known commodity IDs
|
||||
coverage: dict[str, set[str]] = {cid: set() for cid in major}
|
||||
for corp in wiki_corps:
|
||||
for tag in corp.get("tags", []):
|
||||
if tag in coverage:
|
||||
coverage[tag].add(corp["corp_id"])
|
||||
|
||||
for cid in major:
|
||||
n = len(coverage[cid])
|
||||
if n < 3:
|
||||
corp_list = sorted(coverage[cid]) if coverage[cid] else ["none"]
|
||||
errors.append(
|
||||
f"commodity coverage: '{cid}' has {n}/3 corp(s) — {corp_list}"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_system_coverage(
|
||||
conn: sqlite3.Connection, wiki_corps: list[dict]
|
||||
) -> list[str]:
|
||||
"""1+ corporation per inhabited system with population > 100K. D-175.
|
||||
|
||||
Uses wiki_corps headquarters data (not DB corp_presence) so this check
|
||||
is accurate in both dry-run and real-run modes.
|
||||
"""
|
||||
covered = {c["system_id"] for c in wiki_corps if c.get("system_id")}
|
||||
populated = conn.execute("""
|
||||
SELECT se.system_id, ss.proper_name, se.population
|
||||
FROM system_economy se
|
||||
JOIN star_systems ss ON se.system_id = ss.system_id
|
||||
WHERE se.population > 100000
|
||||
ORDER BY se.system_id
|
||||
""").fetchall()
|
||||
|
||||
return [
|
||||
f"system coverage: no corp presence in '{sid}' ({name}, pop={pop:,})"
|
||||
for sid, name, pop in populated
|
||||
if sid not in covered
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -321,66 +690,126 @@ def main():
|
||||
print(f" Mode: DRY RUN")
|
||||
print()
|
||||
|
||||
# Load wiki corps before opening DB — allows early exit on parse failures
|
||||
print(" Loading wiki corporations...")
|
||||
wiki_corps = load_wiki_corps()
|
||||
print(f" {len(wiki_corps)} corporation files parsed")
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
# 1. Migrate schema
|
||||
print(" [1/5] Schema migration...")
|
||||
print(" [1/8] Schema migration...")
|
||||
for table, col, col_type in COLUMN_MIGRATIONS:
|
||||
_add_column(conn, table, col, col_type)
|
||||
conn.executescript(MIGRATION_SQL)
|
||||
print(" tables and columns ready")
|
||||
|
||||
# Clear economics tables in FK-safe order (children before parents)
|
||||
# corp_presence cleared here; corporations table is append-only (never cleared)
|
||||
if not args.dry_run:
|
||||
conn.execute("DELETE FROM corp_presence")
|
||||
conn.execute("DELETE FROM chain_inputs")
|
||||
conn.execute("DELETE FROM production_chains")
|
||||
conn.execute("DELETE FROM commodities")
|
||||
conn.execute("DELETE FROM gate_links")
|
||||
|
||||
# 2. Gate links
|
||||
print(" [2/5] Importing gate links...")
|
||||
print(" [2/8] Importing gate links...")
|
||||
n_links = import_gate_links(conn, args.dry_run)
|
||||
print(f" {n_links} rows (bidirectional)")
|
||||
|
||||
# 3. Commodities
|
||||
print(" [3/5] Importing commodities...")
|
||||
print(" [3/8] Importing commodities...")
|
||||
n_commodities = import_commodities(conn, args.dry_run)
|
||||
print(f" {n_commodities} commodities")
|
||||
|
||||
# 4. Production chains
|
||||
print(" [4/5] Importing production chains...")
|
||||
print(" [4/8] Importing production chains...")
|
||||
n_chains, n_inputs = import_chains(conn, args.dry_run)
|
||||
print(f" {n_chains} chains, {n_inputs} inputs")
|
||||
|
||||
# 5. Currency zones
|
||||
print(" [5/5] Setting currency zones...")
|
||||
print(" [5/8] Setting currency zones...")
|
||||
zones = set_currency_zones(conn, args.dry_run)
|
||||
for zone, count in sorted(zones.items()):
|
||||
print(f" {zone}: {count}")
|
||||
|
||||
# Validate
|
||||
print("\n Validating...")
|
||||
errors = validate(conn)
|
||||
if errors:
|
||||
print(f" ERRORS ({len(errors)}):")
|
||||
for e in errors:
|
||||
# 6. Gate energy connectivity (D-186) — must run after currency zones
|
||||
print(" [6/8] Setting gate energy connectivity...")
|
||||
energy = set_gate_energy(conn, args.dry_run)
|
||||
for label, count in sorted(energy.items()):
|
||||
print(f" {label}: {count}")
|
||||
|
||||
# 7. Sync corporations from wiki (D-182: hard error on name divergence)
|
||||
print(" [7/8] Syncing corporations...")
|
||||
corp_errors = sync_corporations(conn, wiki_corps, args.dry_run)
|
||||
if corp_errors:
|
||||
print(f" ERRORS ({len(corp_errors)}) — name divergence detected (D-182):")
|
||||
for e in corp_errors:
|
||||
print(f" - {e}")
|
||||
print(" Fix: update wiki title or DB proper_name to match, then re-run.")
|
||||
conn.close()
|
||||
sys.exit(1)
|
||||
n_db_corps = conn.execute("SELECT COUNT(*) FROM corporations").fetchone()[0]
|
||||
print(f" {n_db_corps} corporations in DB ({len(wiki_corps)} from wiki)")
|
||||
|
||||
# 8. Corp presence from wiki headquarters data
|
||||
print(" [8/8] Importing corp presence...")
|
||||
commodity_ids = {
|
||||
r[0] for r in conn.execute("SELECT commodity_id FROM commodities").fetchall()
|
||||
}
|
||||
n_presence = import_corp_presence(conn, wiki_corps, commodity_ids, args.dry_run)
|
||||
print(f" {n_presence} corp_presence rows")
|
||||
|
||||
# Validate structural integrity (FK, chain refs, chain completeness).
|
||||
# These errors indicate broken imported data — do NOT commit.
|
||||
print("\n Validating structural integrity...")
|
||||
struct_errors = validate(conn)
|
||||
if struct_errors:
|
||||
print(f" STRUCTURAL ERRORS ({len(struct_errors)}) — rolling back:")
|
||||
for e in struct_errors:
|
||||
print(f" - {e}")
|
||||
conn.close()
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(" FK integrity OK")
|
||||
print(" FK integrity and chain completeness OK")
|
||||
|
||||
# Commit all imported data (corps, presence, etc.) before coverage check.
|
||||
# Coverage validation is a Phase 2 gate (D-175) — data should be persisted
|
||||
# so tools can query it and report gaps clearly.
|
||||
if not args.dry_run:
|
||||
conn.commit()
|
||||
print("\n Committed.")
|
||||
print(" Data committed.")
|
||||
else:
|
||||
print("\n Dry run — no changes written.")
|
||||
print(" Dry run — no changes written.")
|
||||
|
||||
# Validate coverage (hard errors per D-175, but after commit so data is usable).
|
||||
print("\n Validating coverage (D-175 Phase 2 gate)...")
|
||||
coverage_errors: list[str] = []
|
||||
commodity_ids_for_coverage = {
|
||||
r[0] for r in conn.execute("SELECT commodity_id FROM commodities").fetchall()
|
||||
}
|
||||
coverage_errors.extend(
|
||||
_validate_commodity_coverage(conn, wiki_corps, commodity_ids_for_coverage)
|
||||
)
|
||||
coverage_errors.extend(_validate_system_coverage(conn, wiki_corps))
|
||||
|
||||
if coverage_errors:
|
||||
print(f" COVERAGE ERRORS ({len(coverage_errors)}) — Phase 2 gate not met:")
|
||||
for e in coverage_errors:
|
||||
print(f" - {e}")
|
||||
print("\n Data committed but Phase 2 gate is NOT met. "
|
||||
"Add corporations to meet coverage thresholds and re-run.")
|
||||
conn.close()
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(" All coverage thresholds met — Phase 2 gate PASSED.")
|
||||
|
||||
conn.close()
|
||||
|
||||
print(f"\n Done: {n_links} gate_links, {n_commodities} commodities, "
|
||||
f"{n_chains} chains, {n_inputs} inputs\n")
|
||||
f"{n_chains} chains, {n_inputs} inputs, {n_presence} corp_presence\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generate Tier-3 corporations for the Settled Reach economy.
|
||||
#
|
||||
# Usage:
|
||||
# tooling/generate-corporations
|
||||
# tooling/generate-corporations --seed 42 --min-corps 5000
|
||||
# tooling/generate-corporations --output path/to/output.toml
|
||||
#
|
||||
# Builds on first run if binary doesn't exist.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
BIN="$ROOT_DIR/server/target/debug/generate_corporations"
|
||||
|
||||
# Build if needed
|
||||
if [ ! -f "$BIN" ]; then
|
||||
echo "Building generate_corporations..." >&2
|
||||
(cd "$ROOT_DIR/server" && cargo build --bin generate_corporations 2>&1 | tail -3) >&2
|
||||
fi
|
||||
|
||||
exec "$BIN" "$@"
|
||||
@@ -7,8 +7,8 @@ Run from any directory — paths are resolved relative to this script's location
|
||||
|
||||
Sources:
|
||||
docs/design/star-map.json — graph topology (nodes + edges)
|
||||
server/server/data/systems.db — proper names, geographic sectors
|
||||
wiki/star-systems/ — star type, bodies, population, GTTR excerpt
|
||||
server/data/systems.db — proper names, geographic sectors, bodies, GDP tier
|
||||
wiki/star-systems/ — star type, GTTR excerpt (bodies/population from systems.db)
|
||||
|
||||
Output:
|
||||
client/data/star_map_data.json — self-contained client data for the star map UI
|
||||
@@ -21,16 +21,11 @@ import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Resolve project root from this script's location: tooling/ is one level below root.
|
||||
# Resolve project root from this script's location.
|
||||
# Works regardless of cwd — no fragile relative path guessing.
|
||||
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_PROJECT_ROOT = os.path.dirname(_SCRIPT_DIR)
|
||||
|
||||
# Worktree layout: settled-reach/{client,server,main}/
|
||||
# This script lives in client/tooling/, so _PROJECT_ROOT = client/.
|
||||
# The parent of _PROJECT_ROOT is the worktree parent where sibling dirs live.
|
||||
_WORKTREE_PARENT = os.path.dirname(_PROJECT_ROOT)
|
||||
|
||||
STAR_MAP_PATH = os.path.join(_PROJECT_ROOT, "docs", "design", "star-map.json")
|
||||
SYSTEMS_DB_PATH = os.path.join(_PROJECT_ROOT, "server", "data", "systems.db")
|
||||
WIKI_PATH = os.path.join(_PROJECT_ROOT, "wiki", "star-systems")
|
||||
@@ -43,13 +38,14 @@ def system_id_to_wiki_slug(system_id: str) -> str:
|
||||
|
||||
|
||||
def parse_wiki_index(system_id: str) -> dict:
|
||||
"""Extract star type, bodies summary, and population from index.md.
|
||||
"""Extract star type from index.md.
|
||||
|
||||
Returns dict with keys: star_type, bodies, population (all strings, may be empty).
|
||||
Bodies and population are authoritative from systems.db — not read from wiki.
|
||||
Returns dict with key: star_type (string, may be empty).
|
||||
"""
|
||||
slug = system_id_to_wiki_slug(system_id)
|
||||
path = os.path.join(WIKI_PATH, slug, "index.md")
|
||||
result = {"star_type": "", "bodies": "", "population": ""}
|
||||
result = {"star_type": ""}
|
||||
if not os.path.exists(path):
|
||||
return result
|
||||
with open(path, encoding="utf-8") as f:
|
||||
@@ -60,18 +56,7 @@ def parse_wiki_index(system_id: str) -> dict:
|
||||
if m:
|
||||
raw = m.group(1).strip()
|
||||
# Extract spectral class — everything before " ·" or end of string
|
||||
star_type = raw.split("·")[0].strip()
|
||||
result["star_type"] = star_type
|
||||
|
||||
# Bodies row: | **Bodies** | 2 habitable · 3 inhabited |
|
||||
m = re.search(r"\|\s*\*\*Bodies\*\*\s*\|\s*([^|]+?)\s*\|", content)
|
||||
if m:
|
||||
result["bodies"] = m.group(1).strip()
|
||||
|
||||
# Population row: | **Population** | 1,200,000,000 |
|
||||
m = re.search(r"\|\s*\*\*Population\*\*\s*\|\s*([^|]+?)\s*\|", content)
|
||||
if m:
|
||||
result["population"] = m.group(1).strip()
|
||||
result["star_type"] = raw.split("·")[0].strip()
|
||||
|
||||
return result
|
||||
|
||||
@@ -106,6 +91,45 @@ def parse_gttr_excerpt(system_id: str) -> str:
|
||||
return " ".join(paragraph_lines)
|
||||
|
||||
|
||||
GDP_PER_CAPITA: dict = {
|
||||
5: 75_000,
|
||||
4: 40_000,
|
||||
3: 15_000,
|
||||
2: 5_000,
|
||||
1: 2_000,
|
||||
0: 500,
|
||||
}
|
||||
|
||||
|
||||
def infer_tier_from_population(pop: int) -> int:
|
||||
"""Infer an economic tier from total population when no explicit tier is set."""
|
||||
if pop >= 5_000_000_000:
|
||||
return 5
|
||||
if pop >= 1_000_000_000:
|
||||
return 4
|
||||
if pop >= 200_000_000:
|
||||
return 3
|
||||
if pop >= 10_000_000:
|
||||
return 2
|
||||
return 1
|
||||
|
||||
|
||||
def compute_gdp(total_pop: int, economic_tier: int | None) -> str:
|
||||
"""Return a formatted GDP string in Tractus, or empty string if no population."""
|
||||
if total_pop == 0:
|
||||
return ""
|
||||
tier = economic_tier if economic_tier is not None else infer_tier_from_population(total_pop)
|
||||
per_cap = GDP_PER_CAPITA.get(tier, GDP_PER_CAPITA[1])
|
||||
value = total_pop * per_cap
|
||||
if value < 1_000_000_000:
|
||||
return f"{value / 1_000_000:.1f} MTr"
|
||||
if value < 1_000_000_000_000:
|
||||
return f"{value / 1_000_000_000:.1f} BTr"
|
||||
if value < 1_000_000_000_000_000:
|
||||
return f"{value / 1_000_000_000_000:.1f} TTr"
|
||||
return f"{value / 1_000_000_000_000_000:.1f} QTr"
|
||||
|
||||
|
||||
def build_adjacency(edges: list) -> dict:
|
||||
"""Build a map from system_id to list of adjacent system_ids from edges."""
|
||||
adj: dict = {}
|
||||
@@ -134,35 +158,45 @@ def generate() -> dict:
|
||||
with open(STAR_MAP_PATH) as f:
|
||||
star_map = json.load(f)
|
||||
|
||||
conn = sqlite3.connect(SYSTEMS_DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT system_id, proper_name, geographic_sector, geographic_band "
|
||||
"FROM star_systems"
|
||||
)
|
||||
db_lookup = {row["system_id"]: dict(row) for row in cur.fetchall()}
|
||||
try:
|
||||
conn = sqlite3.connect(SYSTEMS_DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
|
||||
# Aggregate body data per system from the bodies table
|
||||
cur.execute("""
|
||||
SELECT system_id,
|
||||
SUM(CASE WHEN atmosphere IN ('breathable','standard') AND body_type IN ('planet','moon') THEN 1 ELSE 0 END) AS habitable,
|
||||
SUM(CASE WHEN inhabited = 1 THEN 1 ELSE 0 END) AS inhabited,
|
||||
SUM(CASE WHEN inhabited = 1 THEN COALESCE(population, 0) ELSE 0 END) AS total_pop
|
||||
FROM bodies
|
||||
GROUP BY system_id
|
||||
""")
|
||||
body_stats = {row["system_id"]: dict(row) for row in cur.fetchall()}
|
||||
cur.execute(
|
||||
"SELECT system_id, proper_name, geographic_sector, geographic_band "
|
||||
"FROM star_systems"
|
||||
)
|
||||
db_lookup = {row["system_id"]: dict(row) for row in cur.fetchall()}
|
||||
|
||||
# Also sum station populations
|
||||
cur.execute("""
|
||||
SELECT system_id,
|
||||
SUM(COALESCE(population, 0)) AS station_pop
|
||||
FROM stations
|
||||
GROUP BY system_id
|
||||
""")
|
||||
station_stats = {row["system_id"]: dict(row) for row in cur.fetchall()}
|
||||
conn.close()
|
||||
# Aggregate body data per system from the bodies table
|
||||
cur.execute("""
|
||||
SELECT system_id,
|
||||
SUM(CASE WHEN atmosphere IN ('breathable','standard') AND body_type IN ('planet','moon') THEN 1 ELSE 0 END) AS habitable,
|
||||
SUM(CASE WHEN inhabited = 1 THEN 1 ELSE 0 END) AS inhabited,
|
||||
SUM(CASE WHEN inhabited = 1 THEN COALESCE(population, 0) ELSE 0 END) AS total_pop
|
||||
FROM bodies
|
||||
GROUP BY system_id
|
||||
""")
|
||||
body_stats = {row["system_id"]: dict(row) for row in cur.fetchall()}
|
||||
|
||||
# Also sum station populations
|
||||
cur.execute("""
|
||||
SELECT system_id,
|
||||
SUM(COALESCE(population, 0)) AS station_pop
|
||||
FROM stations
|
||||
GROUP BY system_id
|
||||
""")
|
||||
station_stats = {row["system_id"]: dict(row) for row in cur.fetchall()}
|
||||
|
||||
# Economic tier for GDP calculation
|
||||
cur.execute("SELECT system_id, economic_tier FROM system_economy")
|
||||
econ_tiers = {row["system_id"]: row["economic_tier"] for row in cur.fetchall()}
|
||||
except sqlite3.Error as e:
|
||||
print(f"ERROR: systems.db query failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
adjacency = build_adjacency(star_map["edges"])
|
||||
|
||||
@@ -204,6 +238,9 @@ def generate() -> dict:
|
||||
|
||||
entry["bodies"] = "%d habitable · %d inhabited" % (hab, inh)
|
||||
entry["population"] = "{:,}".format(total_pop)
|
||||
gdp_str = compute_gdp(total_pop, econ_tiers.get(sid))
|
||||
if gdp_str:
|
||||
entry["gdp"] = gdp_str
|
||||
if gttr:
|
||||
entry["gttr_excerpt"] = gttr
|
||||
if n.get("is_gateway"):
|
||||
|
||||
@@ -232,6 +232,11 @@ boreal = [245, 278] # cold forest / taiga
|
||||
29 = { name = "warm_dust", cartographic = [210, 175, 120], photographic = [188, 155, 105] }
|
||||
30 = { name = "cold_rock", cartographic = [160, 140, 115], photographic = [135, 118, 95] }
|
||||
|
||||
# Ferric terrain (iron oxide — Mars, arid iron-rich worlds)
|
||||
34 = { name = "ferric_dust", cartographic = [185, 110, 65], photographic = [158, 88, 48] }
|
||||
35 = { name = "ferric_highland", cartographic = [165, 100, 60], photographic = [138, 78, 42] }
|
||||
36 = { name = "ferric_lowland", cartographic = [200, 130, 75], photographic = [172, 105, 58] }
|
||||
|
||||
# Lunar terrain (grey rock)
|
||||
31 = { name = "lunar_highland", cartographic = [165, 165, 162], photographic = [138, 138, 135] }
|
||||
32 = { name = "lunar_mare", cartographic = [120, 120, 118], photographic = [100, 100, 98] }
|
||||
|
||||
@@ -189,7 +189,10 @@ def _raytrace(size: int, r: float = 1.0, oblateness: float = 0.0):
|
||||
nx /= nm; ny /= nm; nz /= nm
|
||||
|
||||
# UV from undistorted hit point
|
||||
u = (np.arctan2(hz, hx) / (2.0 * math.pi)) % 1.0
|
||||
# arctan2(hx, hz) so longitude increases eastward (right on screen).
|
||||
# +0.5 offset centers the view on 0° longitude (Greenwich) instead of
|
||||
# 180° (dateline), keeping the seam on the back of the sphere.
|
||||
u = (np.arctan2(hx, hz) / (2.0 * math.pi) + 0.5) % 1.0
|
||||
v = np.arcsin(np.clip(hy / np.where(hit, np.sqrt(hx**2 + hy**2 + hz**2), 1.0), -1.0, 1.0)) / math.pi + 0.5
|
||||
|
||||
return hit, nx.astype(np.float32), ny.astype(np.float32), nz.astype(np.float32), u.astype(np.float32), v.astype(np.float32)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Sol system real-world data importers
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Caching downloader for planetary science datasets.
|
||||
|
||||
Downloads are stored in sol_data/.cache/ and reused on subsequent runs.
|
||||
Supports resume for large files and optional SHA-256 verification.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
CACHE_DIR = Path(__file__).resolve().parent / ".cache"
|
||||
|
||||
|
||||
def _progress_hook(block_num, block_size, total_size):
|
||||
"""Print download progress."""
|
||||
downloaded = block_num * block_size
|
||||
if total_size > 0:
|
||||
pct = min(100.0, downloaded * 100.0 / total_size)
|
||||
mb = downloaded / (1024 * 1024)
|
||||
total_mb = total_size / (1024 * 1024)
|
||||
sys.stdout.write(f"\r downloading: {mb:.1f}/{total_mb:.1f} MB ({pct:.0f}%)")
|
||||
else:
|
||||
mb = downloaded / (1024 * 1024)
|
||||
sys.stdout.write(f"\r downloading: {mb:.1f} MB")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def ensure_cached(url: str, filename: str, sha256: str = None) -> Path:
|
||||
"""
|
||||
Download a file if not already cached. Returns path to cached file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
url : download URL
|
||||
filename : local filename within the cache directory
|
||||
sha256 : optional hex digest for verification
|
||||
"""
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
local_path = CACHE_DIR / filename
|
||||
|
||||
if local_path.exists():
|
||||
if sha256:
|
||||
actual = _sha256(local_path)
|
||||
if actual != sha256:
|
||||
print(f" WARNING: checksum mismatch for {filename}, re-downloading")
|
||||
local_path.unlink()
|
||||
else:
|
||||
return local_path
|
||||
else:
|
||||
return local_path
|
||||
|
||||
print(f" fetching {filename} from {url[:80]}...")
|
||||
tmp_path = local_path.with_suffix(".tmp")
|
||||
|
||||
try:
|
||||
# Many government data servers (USGS, NOAA) require a User-Agent
|
||||
opener = urllib.request.build_opener()
|
||||
opener.addheaders = [
|
||||
("User-Agent", "SettledReach-PlanetGen/1.0 (terrain pipeline)"),
|
||||
]
|
||||
urllib.request.install_opener(opener)
|
||||
urllib.request.urlretrieve(url, str(tmp_path), reporthook=_progress_hook)
|
||||
print() # newline after progress
|
||||
except Exception as e:
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
raise RuntimeError(f"Download failed for {filename}: {e}") from e
|
||||
|
||||
if sha256:
|
||||
actual = _sha256(tmp_path)
|
||||
if actual != sha256:
|
||||
tmp_path.unlink()
|
||||
raise RuntimeError(
|
||||
f"Checksum mismatch for {filename}: "
|
||||
f"expected {sha256[:16]}..., got {actual[:16]}..."
|
||||
)
|
||||
|
||||
tmp_path.rename(local_path)
|
||||
return local_path
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
@@ -0,0 +1,391 @@
|
||||
"""
|
||||
Earth (GJ0d) terrain builder.
|
||||
|
||||
Data sources:
|
||||
- Elevation: ETOPO 2022 60 arc-second (NOAA) — GeoTIFF
|
||||
- Temperature: WorldClim v2.1 annual mean (10 arc-min) — GeoTIFF
|
||||
- Precipitation: WorldClim v2.1 annual total (10 arc-min) — GeoTIFF
|
||||
- Rivers: Natural Earth 10m rivers — GeoJSON
|
||||
|
||||
All sources are equirectangular with col 0 = 180°W. ETOPO and WorldClim
|
||||
use col 0 = 180°W natively. Natural Earth uses -180 to 180 longitude.
|
||||
"""
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
|
||||
from sol_data.download import ensure_cached
|
||||
from sol_data.shared import (
|
||||
GRID_W, GRID_H,
|
||||
load_tiff_as_array,
|
||||
resample_to_grid, normalize_01, compute_sea_level,
|
||||
compute_hillshade, assemble_terrain,
|
||||
)
|
||||
|
||||
# ─── Data source URLs ───────────────────────────────────────────────────────
|
||||
|
||||
# ETOPO 2022 60 arc-second — surface elevation (ice surface, not bedrock)
|
||||
# ~130 MB GeoTIFF, 21600 x 10800, int16 metres
|
||||
ETOPO_URL = "https://www.ngdc.noaa.gov/mgg/global/relief/ETOPO2022/data/60s/60s_surface_elev_gtif/ETOPO_2022_v1_60s_N90W180_surface.tif"
|
||||
ETOPO_FILE = "ETOPO_2022_v1_60s_N90W180_surface.tif"
|
||||
|
||||
# WorldClim v2.1 — 10 arc-minute resolution (migrated to geodata.ucdavis.edu)
|
||||
# Temperature: mean annual, °C × 10 (int16), in a zip
|
||||
WCLIM_TEMP_URL = "https://geodata.ucdavis.edu/climate/worldclim/2_1/base/wc2.1_10m_tavg.zip"
|
||||
WCLIM_TEMP_FILE = "wc2.1_10m_tavg.zip"
|
||||
|
||||
# Precipitation: annual total mm (int16), in a zip
|
||||
WCLIM_PREC_URL = "https://geodata.ucdavis.edu/climate/worldclim/2_1/base/wc2.1_10m_prec.zip"
|
||||
WCLIM_PREC_FILE = "wc2.1_10m_prec.zip"
|
||||
|
||||
# Natural Earth 10m rivers — GeoJSON from GitHub
|
||||
RIVERS_URL = "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_rivers_lake_centerlines.geojson"
|
||||
RIVERS_FILE = "ne_10m_rivers_lake_centerlines.geojson"
|
||||
|
||||
# Earth physical constants
|
||||
EARTH_OCEAN_FRACTION = 0.71
|
||||
EARTH_MIN_ELEV_M = -10994.0 # Mariana Trench
|
||||
EARTH_MAX_ELEV_M = 8849.0 # Everest
|
||||
|
||||
|
||||
# ─── River filtering ────────────────────────────────────────────────────────
|
||||
|
||||
# Rivers to include (smart scatter: 1-2 per continent + Rhine)
|
||||
INCLUDED_RIVERS = {
|
||||
# Europe
|
||||
"Danube", "Volga", "Rhine",
|
||||
# North America
|
||||
"Mississippi", "St. Lawrence",
|
||||
# South America
|
||||
"Amazon", "Paraná",
|
||||
# Africa
|
||||
"Nile", "Congo",
|
||||
# West Asia
|
||||
"Tigris",
|
||||
# East/South Asia
|
||||
"Yangtze", "Ganges", "Mekong",
|
||||
# Australia
|
||||
"Murray",
|
||||
}
|
||||
|
||||
# Fuzzy matching — some NE names differ slightly
|
||||
RIVER_NAME_ALIASES = {
|
||||
"Parana": "Paraná",
|
||||
"Chang Jiang": "Yangtze",
|
||||
"Huang He": "Yellow",
|
||||
"Ganga": "Ganges",
|
||||
"Nil": "Nile",
|
||||
"Danau": "Danube",
|
||||
"Donau": "Danube",
|
||||
"Rhin": "Rhine",
|
||||
"Rhein": "Rhine",
|
||||
"Saint Lawrence": "St. Lawrence",
|
||||
"St Lawrence": "St. Lawrence",
|
||||
"Río Paraná": "Paraná",
|
||||
"Rio Parana": "Paraná",
|
||||
}
|
||||
|
||||
|
||||
def _match_river_name(feature_name: str) -> str:
|
||||
"""Check if a Natural Earth river name matches our included set."""
|
||||
if not feature_name:
|
||||
return None
|
||||
name = feature_name.strip()
|
||||
# Direct match
|
||||
if name in INCLUDED_RIVERS:
|
||||
return name
|
||||
# Alias match
|
||||
if name in RIVER_NAME_ALIASES:
|
||||
alias = RIVER_NAME_ALIASES[name]
|
||||
if alias in INCLUDED_RIVERS:
|
||||
return alias
|
||||
# Substring match (e.g. "Mississippi River" contains "Mississippi")
|
||||
for included in INCLUDED_RIVERS:
|
||||
if included.lower() in name.lower() or name.lower() in included.lower():
|
||||
return included
|
||||
return None
|
||||
|
||||
|
||||
# ─── Data loaders ───────────────────────────────────────────────────────────
|
||||
|
||||
def _load_etopo() -> np.ndarray:
|
||||
"""Load ETOPO 2022 elevation data, return raw metres array."""
|
||||
path = ensure_cached(ETOPO_URL, ETOPO_FILE)
|
||||
print(f" loading ETOPO: {path}")
|
||||
try:
|
||||
arr = load_tiff_as_array(str(path))
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"Failed to load ETOPO GeoTIFF: {e}\n"
|
||||
f"If PIL can't read this TIFF, install Pillow with TIFF support "
|
||||
f"or convert to raw binary."
|
||||
) from e
|
||||
print(f" ETOPO shape: {arr.shape}, range: [{arr.min():.0f}, {arr.max():.0f}] m")
|
||||
return arr
|
||||
|
||||
|
||||
def _load_worldclim_temperature() -> np.ndarray:
|
||||
"""
|
||||
Load WorldClim v2.1 annual mean temperature.
|
||||
Returns temperature in Kelvin at native resolution.
|
||||
"""
|
||||
zip_path = ensure_cached(WCLIM_TEMP_URL, WCLIM_TEMP_FILE)
|
||||
print(f" loading WorldClim temperature: {zip_path}")
|
||||
|
||||
# The zip contains monthly TIFFs (tavg_01.tif to tavg_12.tif).
|
||||
# Compute annual mean from all 12 months.
|
||||
cache_dir = zip_path.parent
|
||||
monthly_sum = None
|
||||
count = 0
|
||||
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
tif_names = sorted([n for n in zf.namelist() if n.endswith(".tif")])
|
||||
for tif_name in tif_names:
|
||||
extracted = cache_dir / Path(tif_name).name
|
||||
if not extracted.exists():
|
||||
zf.extract(tif_name, cache_dir)
|
||||
# Handle nested paths in zip
|
||||
nested = cache_dir / tif_name
|
||||
if nested != extracted and nested.exists():
|
||||
nested.rename(extracted)
|
||||
try:
|
||||
arr = load_tiff_as_array(str(extracted))
|
||||
except Exception:
|
||||
# Try the nested path
|
||||
nested = cache_dir / tif_name
|
||||
if nested.exists():
|
||||
arr = load_tiff_as_array(str(nested))
|
||||
else:
|
||||
continue
|
||||
# Replace nodata with NaN
|
||||
arr[arr < -999] = np.nan
|
||||
if monthly_sum is None:
|
||||
monthly_sum = arr.copy()
|
||||
else:
|
||||
monthly_sum += arr
|
||||
count += 1
|
||||
|
||||
if count == 0:
|
||||
raise RuntimeError("No temperature TIFFs found in WorldClim archive")
|
||||
|
||||
# Annual mean (WorldClim tavg is °C × 10)
|
||||
temp_C = (monthly_sum / count) / 10.0
|
||||
# Convert to Kelvin
|
||||
temp_K = temp_C + 273.15
|
||||
# Replace NaN (ocean/nodata) with a reasonable ocean temperature
|
||||
temp_K = np.nan_to_num(temp_K, nan=288.0)
|
||||
|
||||
print(f" WorldClim temp shape: {temp_K.shape}, "
|
||||
f"range: [{np.nanmin(temp_K):.0f}, {np.nanmax(temp_K):.0f}] K")
|
||||
return temp_K
|
||||
|
||||
|
||||
def _load_worldclim_precipitation() -> np.ndarray:
|
||||
"""
|
||||
Load WorldClim v2.1 annual precipitation (sum of 12 months).
|
||||
Returns precipitation in mm/year at native resolution.
|
||||
"""
|
||||
zip_path = ensure_cached(WCLIM_PREC_URL, WCLIM_PREC_FILE)
|
||||
print(f" loading WorldClim precipitation: {zip_path}")
|
||||
|
||||
cache_dir = zip_path.parent
|
||||
annual_sum = None
|
||||
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
tif_names = sorted([n for n in zf.namelist() if n.endswith(".tif")])
|
||||
for tif_name in tif_names:
|
||||
extracted = cache_dir / Path(tif_name).name
|
||||
if not extracted.exists():
|
||||
zf.extract(tif_name, cache_dir)
|
||||
nested = cache_dir / tif_name
|
||||
if nested != extracted and nested.exists():
|
||||
nested.rename(extracted)
|
||||
try:
|
||||
arr = load_tiff_as_array(str(extracted))
|
||||
except Exception:
|
||||
nested = cache_dir / tif_name
|
||||
if nested.exists():
|
||||
arr = load_tiff_as_array(str(nested))
|
||||
else:
|
||||
continue
|
||||
arr[arr < -999] = 0.0
|
||||
if annual_sum is None:
|
||||
annual_sum = arr.copy()
|
||||
else:
|
||||
annual_sum += arr
|
||||
|
||||
if annual_sum is None:
|
||||
raise RuntimeError("No precipitation TIFFs found in WorldClim archive")
|
||||
|
||||
print(f" WorldClim precip shape: {annual_sum.shape}, "
|
||||
f"range: [{annual_sum.min():.0f}, {annual_sum.max():.0f}] mm/yr")
|
||||
return annual_sum
|
||||
|
||||
|
||||
def _load_rivers_geojson() -> list:
|
||||
"""
|
||||
Load Natural Earth rivers GeoJSON and extract polylines for included rivers.
|
||||
Returns list of (name, [(row, col), ...]) in grid coordinates.
|
||||
"""
|
||||
path = ensure_cached(RIVERS_URL, RIVERS_FILE)
|
||||
print(f" loading rivers: {path}")
|
||||
|
||||
with open(path) as f:
|
||||
geojson = json.load(f)
|
||||
|
||||
rivers = []
|
||||
for feature in geojson.get("features", []):
|
||||
props = feature.get("properties", {})
|
||||
fname = props.get("name") or props.get("name_en") or ""
|
||||
matched = _match_river_name(fname)
|
||||
if not matched:
|
||||
continue
|
||||
|
||||
geom = feature.get("geometry", {})
|
||||
geom_type = geom.get("type", "")
|
||||
coords_list = []
|
||||
|
||||
if geom_type == "LineString":
|
||||
coords_list = [geom["coordinates"]]
|
||||
elif geom_type == "MultiLineString":
|
||||
coords_list = geom["coordinates"]
|
||||
else:
|
||||
continue
|
||||
|
||||
for coords in coords_list:
|
||||
path_grid = []
|
||||
for lon, lat in coords:
|
||||
# Convert lon/lat to grid coordinates
|
||||
# Grid: row 0 = 90°N, row 255 = 90°S
|
||||
# col 0 = 180°W, col 511 = 180°E
|
||||
row = int((90.0 - lat) / 180.0 * GRID_H)
|
||||
col = int((lon + 180.0) / 360.0 * GRID_W)
|
||||
row = max(0, min(GRID_H - 1, row))
|
||||
col = max(0, min(GRID_W - 1, col))
|
||||
# Deduplicate: skip if same grid cell as previous point.
|
||||
# Natural Earth has hundreds of lon/lat points per river,
|
||||
# many of which land on the same 512x256 cell. Without
|
||||
# dedup, the renderer sees len(path)=300 and draws width 6.
|
||||
if path_grid and path_grid[-1] == (row, col):
|
||||
continue
|
||||
path_grid.append((row, col))
|
||||
if len(path_grid) >= 2:
|
||||
rivers.append((matched, path_grid))
|
||||
|
||||
# Deduplicate: keep longest segment per river name
|
||||
by_name = {}
|
||||
for name, path in rivers:
|
||||
if name not in by_name or len(path) > len(by_name[name]):
|
||||
by_name[name] = path
|
||||
|
||||
print(f" matched {len(by_name)} rivers: {', '.join(sorted(by_name.keys()))}")
|
||||
return [(name, path) for name, path in by_name.items()]
|
||||
|
||||
|
||||
# ─── Main builder ───────────────────────────────────────────────────────────
|
||||
|
||||
def build_terrain(body_def: dict) -> dict:
|
||||
"""
|
||||
Build Earth terrain dict from real-world data.
|
||||
|
||||
Returns the same dict format as planet_simulation.simulate().
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from planet_simulation import compute_biome
|
||||
|
||||
print(" Earth: loading real-world data...")
|
||||
|
||||
# ── 1. Elevation ────────────────────────────────────────────────────
|
||||
etopo_raw = _load_etopo()
|
||||
|
||||
# ETOPO 2022 N90W180 is already col 0 = 180°W — no shift needed
|
||||
# Resample to grid
|
||||
elevation_m = resample_to_grid(etopo_raw, GRID_H, GRID_W, order=1)
|
||||
|
||||
# Normalise to [0, 1]
|
||||
elevation = normalize_01(elevation_m, EARTH_MIN_ELEV_M, EARTH_MAX_ELEV_M)
|
||||
|
||||
# Sea level: Earth's ocean fraction is ~0.71
|
||||
sea_level = compute_sea_level(elevation, EARTH_OCEAN_FRACTION)
|
||||
surface_water = elevation < sea_level
|
||||
|
||||
print(f" elevation: sea_level={sea_level:.4f}, "
|
||||
f"ocean={surface_water.sum()}/{GRID_H*GRID_W} cells")
|
||||
|
||||
# ── 2. Temperature ──────────────────────────────────────────────────
|
||||
temp_raw_K = _load_worldclim_temperature()
|
||||
|
||||
# WorldClim uses col 0 = 180°W — no shift needed
|
||||
temperature_K = resample_to_grid(temp_raw_K, GRID_H, GRID_W, order=1)
|
||||
|
||||
# Fill ocean areas with latitude-dependent ocean temperature
|
||||
v = np.linspace(0, 1, GRID_H, dtype=np.float32)
|
||||
lat_abs = np.abs(v - 0.5) * 2.0 # 0 at equator, 1 at poles
|
||||
ocean_temp = 301.0 - lat_abs[:, np.newaxis] * 30.0 # ~28°C equator, ~-2°C poles
|
||||
temperature_K = np.where(surface_water, ocean_temp, temperature_K)
|
||||
|
||||
print(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
|
||||
|
||||
# ── 3. Moisture ─────────────────────────────────────────────────────
|
||||
precip_raw = _load_worldclim_precipitation()
|
||||
|
||||
# WorldClim uses col 0 = 180°W — no shift needed
|
||||
precip = resample_to_grid(precip_raw, GRID_H, GRID_W, order=1)
|
||||
|
||||
# Normalise to [0, 1] — global max is ~10000 mm/yr (tropical rainforest)
|
||||
moisture = normalize_01(precip, 0.0, 6000.0)
|
||||
# Ocean moisture = high (drives adjacent land humidity)
|
||||
moisture = np.where(surface_water, 0.9, moisture)
|
||||
|
||||
print(f" moisture: [{moisture.min():.2f}, {moisture.max():.2f}]")
|
||||
|
||||
# ── 4. Biome classification ─────────────────────────────────────────
|
||||
# Use the existing Whittaker table with real temperature and moisture
|
||||
biome = compute_biome(body_def, elevation, sea_level, surface_water,
|
||||
temperature_K, moisture)
|
||||
n_biomes = len(np.unique(biome))
|
||||
print(f" biomes: {n_biomes} classes present")
|
||||
|
||||
# ── 5. Hillshade ────────────────────────────────────────────────────
|
||||
hillshade = compute_hillshade(elevation)
|
||||
|
||||
# ── 6. Rivers ───────────────────────────────────────────────────────
|
||||
named_rivers = _load_rivers_geojson()
|
||||
|
||||
# Clip rivers: stop each path when it hits surface water.
|
||||
# Rivers like the Amazon/Nile/Rhine otherwise draw through seas.
|
||||
clipped = []
|
||||
for name, path in named_rivers:
|
||||
clipped_path = []
|
||||
for r, c in path:
|
||||
if surface_water[r, c]:
|
||||
break
|
||||
clipped_path.append((r, c))
|
||||
if len(clipped_path) >= 2:
|
||||
clipped.append((name, clipped_path))
|
||||
|
||||
n_orig = len(named_rivers)
|
||||
n_kept = len(clipped)
|
||||
print(f" rivers: {n_kept}/{n_orig} kept after water clipping")
|
||||
named_rivers = clipped
|
||||
rivers = [path for _, path in named_rivers]
|
||||
|
||||
# ── 7. Assemble ─────────────────────────────────────────────────────
|
||||
terrain = assemble_terrain(
|
||||
elevation=elevation,
|
||||
temperature_K=temperature_K,
|
||||
moisture=moisture,
|
||||
biome=biome,
|
||||
surface_water=surface_water,
|
||||
hillshade=hillshade,
|
||||
rivers=rivers,
|
||||
sea_level=sea_level,
|
||||
)
|
||||
|
||||
# Store river names for the marker overlay
|
||||
terrain["_river_names"] = {i: name for i, (name, _) in enumerate(named_rivers)}
|
||||
|
||||
return terrain
|
||||
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Gas giant body definition helpers for Jupiter, Saturn, Uranus, Neptune.
|
||||
|
||||
Gas giants have no solid surface — the existing planet_renderer._render_gas_giant()
|
||||
handles band patterns procedurally. This module only provides configuration
|
||||
validation and body_def enhancement. No terrain dict is produced.
|
||||
|
||||
The actual overrides are in sol_overrides.json and applied by the body
|
||||
definition parser. This module exists for future enhancement (ring tuning,
|
||||
storm placement, etc).
|
||||
"""
|
||||
|
||||
|
||||
def validate_gas_giant_def(body_def: dict) -> bool:
|
||||
"""Check that a gas giant body_def has required fields for rendering."""
|
||||
pc = body_def.get("planet_class", "")
|
||||
if "gas_giant" not in pc and pc not in ("gas_giant",):
|
||||
return False
|
||||
|
||||
gg = body_def.get("gas_giant", {})
|
||||
if not gg.get("band_palette"):
|
||||
print(f" WARNING: {body_def['id']} missing gas_giant.band_palette")
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Ice moon terrain builder — Europa, Ganymede, Callisto, Enceladus.
|
||||
|
||||
These bodies lack high-quality global DEMs. We use available mosaics
|
||||
(albedo/reflectance) to derive synthetic elevation:
|
||||
- Bright = ice ridges/highlands (high)
|
||||
- Dark = mare/chaos terrain/craters (low)
|
||||
|
||||
Each moon gets specific temperature and appearance tuning.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from scipy.ndimage import gaussian_filter
|
||||
|
||||
from sol_data.download import ensure_cached
|
||||
from sol_data.shared import (
|
||||
GRID_W, GRID_H,
|
||||
load_image_as_elevation, resample_to_grid, normalize_01,
|
||||
compute_hillshade, assemble_terrain,
|
||||
temperature_grid_analytical,
|
||||
)
|
||||
|
||||
# ─── Per-moon configuration ─────────────────────────────────────────────────
|
||||
|
||||
MOON_CONFIG = {
|
||||
"GJ0f-2": { # Europa
|
||||
"name": "Europa",
|
||||
"mosaic_url": "https://astrogeology.usgs.gov/cache/images/3c79b3867c0dc5ec2ea33e485a079e58_europa_voyager_galileo_ssi_global_mosaic_500m.jpg",
|
||||
"mosaic_file": "europa_galileo_mosaic.jpg",
|
||||
"base_temp_K": 102.0,
|
||||
"lat_gradient_K": 10.0,
|
||||
"sigma": 2.0, # smooth albedo → elevation
|
||||
"invert_albedo": False, # bright = ridges (high)
|
||||
},
|
||||
"GJ0f-3": { # Ganymede
|
||||
"name": "Ganymede",
|
||||
"mosaic_url": "https://astrogeology.usgs.gov/cache/images/f60b3c06c92f59834f2d4cf9b46cb8f7_ganymede_voyager_galileo_global_mosaic_1km.jpg",
|
||||
"mosaic_file": "ganymede_galileo_mosaic.jpg",
|
||||
"base_temp_K": 110.0,
|
||||
"lat_gradient_K": 15.0,
|
||||
"sigma": 3.0,
|
||||
"invert_albedo": False,
|
||||
},
|
||||
"GJ0f-4": { # Callisto
|
||||
"name": "Callisto",
|
||||
"mosaic_url": "https://astrogeology.usgs.gov/cache/images/26b4e80eeb35d46c53d56cded56deeef_callisto_voyager_galileo_global_mosaic_1km.jpg",
|
||||
"mosaic_file": "callisto_galileo_mosaic.jpg",
|
||||
"base_temp_K": 115.0,
|
||||
"lat_gradient_K": 12.0,
|
||||
"sigma": 4.0,
|
||||
"invert_albedo": False,
|
||||
},
|
||||
"GJ0g-2": { # Enceladus
|
||||
"name": "Enceladus",
|
||||
"mosaic_url": "https://astrogeology.usgs.gov/cache/images/1e9fede316c8c47fdc0b96f4c09e4915_enceladus_cassini_iss_global_mosaic_100m.jpg",
|
||||
"mosaic_file": "enceladus_cassini_mosaic.jpg",
|
||||
"base_temp_K": 75.0,
|
||||
"lat_gradient_K": 8.0,
|
||||
"sigma": 2.0,
|
||||
"invert_albedo": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _load_mosaic_as_elevation(config: dict) -> np.ndarray:
|
||||
"""Load a global mosaic and convert to synthetic elevation."""
|
||||
try:
|
||||
path = ensure_cached(config["mosaic_url"], config["mosaic_file"])
|
||||
print(f" loading {config['name']} mosaic: {path}")
|
||||
albedo = load_image_as_elevation(str(path),
|
||||
invert=config.get("invert_albedo", False))
|
||||
albedo = resample_to_grid(albedo, GRID_H, GRID_W, order=1)
|
||||
except Exception as e:
|
||||
print(f" WARNING: {config['name']} mosaic unavailable ({e}), synthetic")
|
||||
albedo = _synthetic_ice_terrain(config["name"])
|
||||
|
||||
# Smooth albedo to create plausible topography
|
||||
sigma = config.get("sigma", 3.0)
|
||||
elevation = gaussian_filter(albedo, sigma=sigma)
|
||||
return normalize_01(elevation)
|
||||
|
||||
|
||||
def _synthetic_ice_terrain(name: str) -> np.ndarray:
|
||||
"""Generate synthetic ice moon terrain if mosaic unavailable."""
|
||||
seed = hash(name) & 0xFFFFFFFF
|
||||
rng = np.random.default_rng(seed)
|
||||
base = rng.random((GRID_H, GRID_W)).astype(np.float32)
|
||||
base = gaussian_filter(base, sigma=6.0)
|
||||
# Add craters
|
||||
for _ in range(20):
|
||||
cy, cx = rng.integers(0, GRID_H), rng.integers(0, GRID_W)
|
||||
r = rng.integers(5, 20)
|
||||
y, x = np.ogrid[-cy:GRID_H-cy, -cx:GRID_W-cx]
|
||||
mask = x*x + y*y <= r*r
|
||||
base[mask] *= 0.5
|
||||
return normalize_01(base)
|
||||
|
||||
|
||||
def build_terrain(body_def: dict) -> dict:
|
||||
"""Build ice moon terrain dict from mosaic data."""
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from planet_simulation import compute_biome
|
||||
|
||||
body_id = body_def["id"]
|
||||
config = MOON_CONFIG.get(body_id)
|
||||
|
||||
if config is None:
|
||||
raise ValueError(f"No ice moon config for {body_id}")
|
||||
|
||||
print(f" {config['name']}: loading data...")
|
||||
|
||||
# ── 1. Elevation ────────────────────────────────────────────────────
|
||||
elevation = _load_mosaic_as_elevation(config)
|
||||
sea_level = 0.0
|
||||
surface_water = np.zeros((GRID_H, GRID_W), dtype=bool)
|
||||
|
||||
# ── 2. Temperature ──────────────────────────────────────────────────
|
||||
temperature_K = temperature_grid_analytical(
|
||||
base_T_K=config["base_temp_K"],
|
||||
elevation=elevation,
|
||||
lapse_rate_K_per_unit=5.0,
|
||||
lat_gradient_K=config["lat_gradient_K"],
|
||||
)
|
||||
temperature_K = np.maximum(temperature_K, 40.0)
|
||||
|
||||
# ── 3. Moisture ─────────────────────────────────────────────────────
|
||||
moisture = np.zeros((GRID_H, GRID_W), dtype=np.float32)
|
||||
|
||||
# ── 4. Biome ────────────────────────────────────────────────────────
|
||||
biome = compute_biome(body_def, elevation, sea_level, surface_water,
|
||||
temperature_K, moisture)
|
||||
|
||||
# ── 5. Hillshade ────────────────────────────────────────────────────
|
||||
hillshade = compute_hillshade(elevation)
|
||||
|
||||
return assemble_terrain(
|
||||
elevation=elevation, temperature_K=temperature_K,
|
||||
moisture=moisture, biome=biome,
|
||||
surface_water=surface_water, hillshade=hillshade,
|
||||
rivers=[], sea_level=sea_level,
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Io (GJ0f-1) terrain builder.
|
||||
|
||||
Io is the most volcanically active body in the solar system due to
|
||||
tidal heating from Jupiter. Surface is covered in sulfur and volcanic
|
||||
deposits. No published global DEM exists at useful resolution — we use
|
||||
the Galileo/Voyager global mosaic (albedo) to derive synthetic elevation.
|
||||
|
||||
Data source:
|
||||
- Surface: USGS Io Galileo/Voyager global mosaic
|
||||
- Elevation: synthetic from albedo (dark = caldera/lava, bright = sulfur)
|
||||
|
||||
Properties:
|
||||
- Surface temp: ~130K background, 400-1800K at volcanic hotspots
|
||||
- planet_class: "volcanic", atmosphere: "none"
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from scipy.ndimage import gaussian_filter
|
||||
|
||||
from sol_data.download import ensure_cached
|
||||
from sol_data.shared import (
|
||||
GRID_W, GRID_H,
|
||||
load_image_as_elevation, resample_to_grid, normalize_01,
|
||||
compute_hillshade, assemble_terrain,
|
||||
temperature_grid_analytical,
|
||||
)
|
||||
|
||||
# Io global mosaic (Galileo SSI + Voyager) — JPEG from USGS
|
||||
# If direct download isn't available, fall back to procedural
|
||||
IO_MOSAIC_URL = "https://astrogeology.usgs.gov/cache/images/bf08a5b6fa0c2ed73117dc1b6c516fa8_io_galileo_voyager_global_mosaic_1km.jpg"
|
||||
IO_MOSAIC_FILE = "io_galileo_mosaic.jpg"
|
||||
|
||||
IO_BACKGROUND_TEMP_K = 130.0
|
||||
IO_HOTSPOT_TEMP_K = 600.0
|
||||
|
||||
|
||||
def _load_io_mosaic() -> np.ndarray:
|
||||
"""Load Io global mosaic and convert to synthetic elevation."""
|
||||
try:
|
||||
path = ensure_cached(IO_MOSAIC_URL, IO_MOSAIC_FILE)
|
||||
print(f" loading Io mosaic: {path}")
|
||||
albedo = load_image_as_elevation(str(path), invert=False)
|
||||
except Exception as e:
|
||||
print(f" WARNING: Io mosaic unavailable ({e}), generating synthetic")
|
||||
return _synthetic_io_terrain()
|
||||
|
||||
# Resample to grid
|
||||
albedo = resample_to_grid(albedo, GRID_H, GRID_W, order=1)
|
||||
|
||||
# Convert albedo to elevation:
|
||||
# Dark regions (low albedo) = calderas/lava flows = low elevation
|
||||
# Bright regions (high albedo) = sulfur deposits = high elevation
|
||||
# Smooth to create plausible topography
|
||||
elevation = gaussian_filter(albedo, sigma=3.0)
|
||||
elevation = normalize_01(elevation)
|
||||
|
||||
return elevation
|
||||
|
||||
|
||||
def _synthetic_io_terrain() -> np.ndarray:
|
||||
"""Generate synthetic Io-like terrain if mosaic unavailable."""
|
||||
rng = np.random.default_rng(42)
|
||||
base = rng.random((GRID_H, GRID_W)).astype(np.float32)
|
||||
base = gaussian_filter(base, sigma=8.0)
|
||||
# Add volcanic calderas (circular depressions)
|
||||
for _ in range(30):
|
||||
cy, cx = rng.integers(0, GRID_H), rng.integers(0, GRID_W)
|
||||
r = rng.integers(3, 15)
|
||||
y, x = np.ogrid[-cy:GRID_H-cy, -cx:GRID_W-cx]
|
||||
mask = x*x + y*y <= r*r
|
||||
base[mask] *= 0.3
|
||||
return normalize_01(base)
|
||||
|
||||
|
||||
def build_terrain(body_def: dict) -> dict:
|
||||
"""Build Io terrain dict."""
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from planet_simulation import compute_biome
|
||||
|
||||
print(" Io: loading data...")
|
||||
|
||||
# ── 1. Elevation ────────────────────────────────────────────────────
|
||||
elevation = _load_io_mosaic()
|
||||
sea_level = 0.0
|
||||
surface_water = np.zeros((GRID_H, GRID_W), dtype=bool)
|
||||
|
||||
# ── 2. Temperature ──────────────────────────────────────────────────
|
||||
# Background ~130K, volcanic hotspots much hotter
|
||||
temperature_K = temperature_grid_analytical(
|
||||
base_T_K=IO_BACKGROUND_TEMP_K,
|
||||
elevation=elevation,
|
||||
lapse_rate_K_per_unit=-200.0, # low elevation = hot (lava)
|
||||
lat_gradient_K=10.0,
|
||||
)
|
||||
# Volcanic hotspots: low-elevation areas are hot
|
||||
hotspot_mask = elevation < 0.25
|
||||
temperature_K[hotspot_mask] += 300.0
|
||||
|
||||
# ── 3. Moisture ─────────────────────────────────────────────────────
|
||||
moisture = np.zeros((GRID_H, GRID_W), dtype=np.float32)
|
||||
|
||||
# ── 4. Biome ────────────────────────────────────────────────────────
|
||||
biome = compute_biome(body_def, elevation, sea_level, surface_water,
|
||||
temperature_K, moisture)
|
||||
|
||||
# ── 5. Hillshade ────────────────────────────────────────────────────
|
||||
hillshade = compute_hillshade(elevation)
|
||||
|
||||
return assemble_terrain(
|
||||
elevation=elevation, temperature_K=temperature_K,
|
||||
moisture=moisture, biome=biome,
|
||||
surface_water=surface_water, hillshade=hillshade,
|
||||
rivers=[], sea_level=sea_level,
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Luna (GJ0d-1) terrain builder.
|
||||
|
||||
Data source:
|
||||
- Elevation: LOLA (Lunar Orbiter Laser Altimeter) DEM
|
||||
Available at various resolutions from USGS Astrogeology.
|
||||
We use the 4ppd (1440×720) or 16ppd version.
|
||||
|
||||
Luna properties:
|
||||
- Min elevation: ~-9100 m (South Pole-Aitken basin)
|
||||
- Max elevation: ~10786 m (near Engel'gardt crater rim)
|
||||
- No atmosphere, no water
|
||||
- body_type: "moon" → uses lunar biome palette (classes 31/32/33)
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
|
||||
from sol_data.download import ensure_cached
|
||||
from sol_data.shared import (
|
||||
GRID_W, GRID_H,
|
||||
load_raw_binary,
|
||||
resample_to_grid, normalize_01,
|
||||
compute_hillshade, assemble_terrain,
|
||||
temperature_grid_analytical,
|
||||
)
|
||||
|
||||
# LOLA GDR — available as PDS IMG files
|
||||
# 4ppd (1440 × 720) — compact version
|
||||
LOLA_4PPD_URL = "https://pds-geosciences.wustl.edu/lro/lro-l-lola-3-rdr-v1/lrolol_1xxx/data/lola_gdr/cylindrical/img/ldem_4.img"
|
||||
LOLA_4PPD_FILE = "lola_gdr_4ppd.img"
|
||||
LOLA_4PPD_W = 1440
|
||||
LOLA_4PPD_H = 720
|
||||
|
||||
# 16ppd (5760 × 2880) — higher quality
|
||||
LOLA_16PPD_URL = "https://pds-geosciences.wustl.edu/lro/lro-l-lola-3-rdr-v1/lrolol_1xxx/data/lola_gdr/cylindrical/img/ldem_16.img"
|
||||
LOLA_16PPD_FILE = "lola_gdr_16ppd.img"
|
||||
LOLA_16PPD_W = 5760
|
||||
LOLA_16PPD_H = 2880
|
||||
|
||||
# Luna physical constants
|
||||
LUNA_MIN_ELEV_M = -9100.0
|
||||
LUNA_MAX_ELEV_M = 10786.0
|
||||
LUNA_EQUATORIAL_TEMP_K = 220.0 # mean dayside ~220K
|
||||
LUNA_POLAR_TEMP_K = 100.0 # permanently shadowed craters ~40K, average ~100K
|
||||
|
||||
|
||||
def _load_lola(use_16ppd: bool = False) -> np.ndarray:
|
||||
"""Load LOLA DEM, return elevation in metres."""
|
||||
if use_16ppd:
|
||||
url, filename, w, h = LOLA_16PPD_URL, LOLA_16PPD_FILE, LOLA_16PPD_W, LOLA_16PPD_H
|
||||
else:
|
||||
url, filename, w, h = LOLA_4PPD_URL, LOLA_4PPD_FILE, LOLA_4PPD_W, LOLA_4PPD_H
|
||||
|
||||
path = ensure_cached(url, filename)
|
||||
print(f" loading LOLA: {path} ({w}x{h})")
|
||||
|
||||
# LOLA GDR: little-endian int16 (LSB_INTEGER per PDS label)
|
||||
# with a scaling factor of 0.5 metres.
|
||||
try:
|
||||
arr = load_raw_binary(str(path), w, h, dtype="<i2", offset=0)
|
||||
# LOLA int16 values are in units of 0.5m (scale factor 0.5)
|
||||
arr = arr * 0.5
|
||||
except ValueError:
|
||||
# If int16 doesn't work, try float32
|
||||
arr = load_raw_binary(str(path), w, h, dtype="<f4", offset=0)
|
||||
|
||||
# Handle nodata
|
||||
arr[arr > 20000] = 0.0
|
||||
arr[arr < -20000] = 0.0
|
||||
|
||||
print(f" LOLA range: [{arr.min():.0f}, {arr.max():.0f}] m")
|
||||
return arr
|
||||
|
||||
|
||||
def build_terrain(body_def: dict) -> dict:
|
||||
"""Build Luna terrain dict from LOLA data."""
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from planet_simulation import compute_biome
|
||||
|
||||
print(" Luna: loading LOLA data...")
|
||||
|
||||
# ── 1. Elevation ────────────────────────────────────────────────────
|
||||
lola_raw = _load_lola(use_16ppd=False)
|
||||
|
||||
# LOLA cylindrical: col 0 = 0° longitude — shift to 180°W
|
||||
from sol_data.shared import greenwich_to_dateline
|
||||
lola_shifted = greenwich_to_dateline(lola_raw)
|
||||
|
||||
elevation_m = resample_to_grid(lola_shifted, GRID_H, GRID_W, order=1)
|
||||
elevation = normalize_01(elevation_m, LUNA_MIN_ELEV_M, LUNA_MAX_ELEV_M)
|
||||
|
||||
# No liquid — sea level at 0
|
||||
sea_level = 0.0
|
||||
surface_water = np.zeros((GRID_H, GRID_W), dtype=bool)
|
||||
|
||||
print(f" elevation normalised")
|
||||
|
||||
# ── 2. Temperature ──────────────────────────────────────────────────
|
||||
temperature_K = temperature_grid_analytical(
|
||||
base_T_K=LUNA_EQUATORIAL_TEMP_K,
|
||||
elevation=elevation,
|
||||
lapse_rate_K_per_unit=10.0,
|
||||
lat_gradient_K=120.0, # huge contrast equator to poles
|
||||
)
|
||||
# Clamp minimum
|
||||
temperature_K = np.maximum(temperature_K, 40.0)
|
||||
|
||||
print(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
|
||||
|
||||
# ── 3. Moisture ─────────────────────────────────────────────────────
|
||||
moisture = np.zeros((GRID_H, GRID_W), dtype=np.float32)
|
||||
|
||||
# ── 4. Biome ────────────────────────────────────────────────────────
|
||||
# body_type: "moon" + atmosphere: "none" → lunar palette (31/32/33)
|
||||
biome = compute_biome(body_def, elevation, sea_level, surface_water,
|
||||
temperature_K, moisture)
|
||||
print(f" biomes: {len(np.unique(biome))} classes")
|
||||
|
||||
# ── 5. Hillshade ────────────────────────────────────────────────────
|
||||
hillshade = compute_hillshade(elevation)
|
||||
|
||||
# ── 6. Assemble ─────────────────────────────────────────────────────
|
||||
return assemble_terrain(
|
||||
elevation=elevation,
|
||||
temperature_K=temperature_K,
|
||||
moisture=moisture,
|
||||
biome=biome,
|
||||
surface_water=surface_water,
|
||||
hillshade=hillshade,
|
||||
rivers=[],
|
||||
sea_level=sea_level,
|
||||
)
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Mars (GJ0e) terrain builder.
|
||||
|
||||
Data source:
|
||||
- Elevation: MOLA MEGDR (Mars Orbiter Laser Altimeter)
|
||||
PDS format, big-endian int16, metres relative to areoid.
|
||||
Available at multiple resolutions. We use 4ppd (1440×720)
|
||||
or 16ppd (5760×2880) — both small enough to download quickly.
|
||||
|
||||
Mars properties:
|
||||
- Min elevation: ~-8200 m (Hellas Basin)
|
||||
- Max elevation: ~21229 m (Olympus Mons)
|
||||
- Polar ice caps: CO2 + water ice
|
||||
- Thin atmosphere (6 mbar) — classified as "thin" in body_def
|
||||
- Almost no liquid water (hydrosphere: "ice")
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from sol_data.download import ensure_cached
|
||||
from sol_data.shared import (
|
||||
GRID_W, GRID_H,
|
||||
load_raw_binary, resample_to_grid, normalize_01,
|
||||
compute_hillshade, assemble_terrain,
|
||||
temperature_grid_analytical,
|
||||
)
|
||||
|
||||
# MOLA MEGDR — 4 pixels per degree (1440 × 720), big-endian int16
|
||||
# Each pixel = metres relative to Mars areoid
|
||||
# PDS binary with no header (data starts at byte 0 for .img files)
|
||||
MOLA_4PPD_URL = "https://pds-geosciences.wustl.edu/mgs/mgs-m-mola-5-megdr-l3-v1/mgsl_300x/meg004/megt90n000cb.img"
|
||||
MOLA_4PPD_FILE = "mola_megdr_4ppd.img"
|
||||
MOLA_4PPD_W = 1440
|
||||
MOLA_4PPD_H = 720
|
||||
|
||||
# Alternative: 16ppd (5760 × 2880) for higher quality
|
||||
MOLA_16PPD_URL = "https://pds-geosciences.wustl.edu/mgs/mgs-m-mola-5-megdr-l3-v1/mgsl_300x/meg016/megt90n000eb.img"
|
||||
MOLA_16PPD_FILE = "mola_megdr_16ppd.img"
|
||||
MOLA_16PPD_W = 5760
|
||||
MOLA_16PPD_H = 2880
|
||||
|
||||
# Mars physical constants
|
||||
MARS_MIN_ELEV_M = -8200.0 # Hellas Basin
|
||||
MARS_MAX_ELEV_M = 21229.0 # Olympus Mons summit
|
||||
# Real Mars temperatures — we don't fudge these. Mars colour comes from
|
||||
# ferric biome classes (34/35/36) applied based on iron oxide substrate.
|
||||
MARS_EQUATORIAL_TEMP_K = 215.0 # daytime average near equator
|
||||
MARS_POLAR_TEMP_K = 150.0
|
||||
MARS_OCEAN_FRACTION = 0.0 # no liquid water (ice only)
|
||||
|
||||
# Ferric biome class IDs (from biomes.toml)
|
||||
FERRIC_DUST = 34
|
||||
FERRIC_HIGHLAND = 35
|
||||
FERRIC_LOWLAND = 36
|
||||
|
||||
|
||||
def _load_mola(use_16ppd: bool = False) -> np.ndarray:
|
||||
"""Load MOLA DEM, return elevation in metres."""
|
||||
if use_16ppd:
|
||||
url, filename, w, h = MOLA_16PPD_URL, MOLA_16PPD_FILE, MOLA_16PPD_W, MOLA_16PPD_H
|
||||
else:
|
||||
url, filename, w, h = MOLA_4PPD_URL, MOLA_4PPD_FILE, MOLA_4PPD_W, MOLA_4PPD_H
|
||||
|
||||
path = ensure_cached(url, filename)
|
||||
print(f" loading MOLA: {path} ({w}x{h})")
|
||||
|
||||
# MOLA MEGDR: big-endian int16, metres, no header
|
||||
arr = load_raw_binary(str(path), w, h, dtype=">i2", offset=0)
|
||||
|
||||
# MOLA nodata is typically 32767 or -32768
|
||||
arr[arr > 30000] = 0.0
|
||||
arr[arr < -30000] = 0.0
|
||||
|
||||
print(f" MOLA range: [{arr.min():.0f}, {arr.max():.0f}] m")
|
||||
return arr
|
||||
|
||||
|
||||
def build_terrain(body_def: dict) -> dict:
|
||||
"""Build Mars terrain dict from MOLA data."""
|
||||
print(" Mars: loading MOLA data...")
|
||||
|
||||
# ── 1. Elevation ────────────────────────────────────────────────────
|
||||
mola_raw = _load_mola(use_16ppd=False)
|
||||
|
||||
# MOLA is col 0 = 0° longitude — shift to col 0 = 180°W
|
||||
from sol_data.shared import greenwich_to_dateline
|
||||
mola_shifted = greenwich_to_dateline(mola_raw)
|
||||
|
||||
# Resample to grid
|
||||
elevation_m = resample_to_grid(mola_shifted, GRID_H, GRID_W, order=1)
|
||||
|
||||
# Normalise to [0, 1]
|
||||
elevation = normalize_01(elevation_m, MARS_MIN_ELEV_M, MARS_MAX_ELEV_M)
|
||||
|
||||
print(f" elevation normalised")
|
||||
|
||||
# ── 2. Temperature ──────────────────────────────────────────────────
|
||||
# Analytical: equatorial ~210K, polar ~150K, elevation lapse
|
||||
temperature_K = temperature_grid_analytical(
|
||||
base_T_K=MARS_EQUATORIAL_TEMP_K,
|
||||
elevation=elevation,
|
||||
lapse_rate_K_per_unit=30.0,
|
||||
lat_gradient_K=60.0,
|
||||
)
|
||||
|
||||
# Polar ice caps: very cold at high latitudes
|
||||
v = np.linspace(0, 1, GRID_H, dtype=np.float32)
|
||||
lat_abs = np.abs(v - 0.5) * 2.0
|
||||
polar_rows = lat_abs > 0.75
|
||||
temperature_K[polar_rows, :] = np.minimum(temperature_K[polar_rows, :], 155.0)
|
||||
|
||||
print(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
|
||||
|
||||
# ── 3. Moisture ─────────────────────────────────────────────────────
|
||||
# Mars has almost no moisture — thin atmosphere
|
||||
moisture = np.zeros((GRID_H, GRID_W), dtype=np.float32)
|
||||
# Slight moisture near polar caps (water ice)
|
||||
moisture[polar_rows, :] = 0.1
|
||||
|
||||
# ── 4. Terraformed water bodies ─────────────────────────────────────
|
||||
# Lore: 800 years of partial terraforming. Water pools in the deepest
|
||||
# basins (Hellas, Utopia, Isidis). ~2% of surface is now liquid water.
|
||||
from sol_data.shared import compute_sea_level as _compute_sl
|
||||
from scipy.ndimage import binary_dilation
|
||||
|
||||
TERRAFORM_OCEAN_FRAC = 0.02 # 2% water coverage
|
||||
sea_level = _compute_sl(elevation, TERRAFORM_OCEAN_FRAC)
|
||||
surface_water = elevation < sea_level
|
||||
|
||||
# Don't flood polar regions — those stay as ice caps, not lakes
|
||||
surface_water[polar_rows, :] = False
|
||||
|
||||
n_water = int(surface_water.sum())
|
||||
print(f" terraformed water: {n_water} cells "
|
||||
f"(sea_level={sea_level:.4f})")
|
||||
|
||||
# ── 5. Biome classification ─────────────────────────────────────────
|
||||
# Mars biome is built directly — compute_biome() would classify
|
||||
# everything as ice at these temperatures.
|
||||
biome = np.full((GRID_H, GRID_W), FERRIC_DUST, dtype=np.int8)
|
||||
|
||||
# Elevation-based ferric variation
|
||||
biome[elevation > 0.55] = FERRIC_HIGHLAND # volcanic highlands
|
||||
biome[elevation < 0.25] = FERRIC_LOWLAND # basin floors
|
||||
|
||||
# Polar ice caps
|
||||
biome[polar_rows, :] = 17 # ice/snow
|
||||
|
||||
# Terraformed green fringe around water bodies — vegetation band
|
||||
# where the thicker local atmosphere and water access allow plants.
|
||||
# ~5 cell band around each water body.
|
||||
veg_ring = binary_dilation(surface_water, iterations=5) & ~surface_water
|
||||
# Don't put vegetation at poles
|
||||
veg_ring[polar_rows, :] = False
|
||||
biome[veg_ring] = 12 # shrubland (olive green — sparse terraformed vegetation)
|
||||
|
||||
# Inner vegetation ring (closer to water = lusher)
|
||||
inner_ring = binary_dilation(surface_water, iterations=2) & ~surface_water
|
||||
inner_ring[polar_rows, :] = False
|
||||
biome[inner_ring] = 8 # temperate grassland (greener)
|
||||
|
||||
# Ocean depth bands for water bodies
|
||||
if surface_water.any():
|
||||
depth = np.clip((sea_level - elevation) / (sea_level + 1e-9), 0, 1)
|
||||
biome[surface_water & (depth < 0.15)] = 2 # shallow
|
||||
biome[surface_water & (depth >= 0.15) & (depth < 0.50)] = 1 # mid
|
||||
biome[surface_water & (depth >= 0.50)] = 0 # deep
|
||||
|
||||
n_ice = int((biome == 17).sum())
|
||||
n_ferric = int(((biome >= 34) & (biome <= 36)).sum())
|
||||
n_veg = int(((biome == 8) | (biome == 12)).sum())
|
||||
n_ocean = int(((biome >= 0) & (biome <= 2)).sum())
|
||||
print(f" biomes: {len(np.unique(biome))} classes "
|
||||
f"(ferric={n_ferric}, ice={n_ice}, veg={n_veg}, water={n_ocean})")
|
||||
|
||||
# ── 5. Hillshade ────────────────────────────────────────────────────
|
||||
hillshade = compute_hillshade(elevation)
|
||||
|
||||
# ── 6. Assemble ─────────────────────────────────────────────────────
|
||||
return assemble_terrain(
|
||||
elevation=elevation,
|
||||
temperature_K=temperature_K,
|
||||
moisture=moisture,
|
||||
biome=biome,
|
||||
surface_water=surface_water,
|
||||
hillshade=hillshade,
|
||||
rivers=[], # no rivers on Mars
|
||||
sea_level=sea_level,
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Mercury (GJ0b) terrain builder.
|
||||
|
||||
Data source:
|
||||
- Elevation: MESSENGER DEM from USGS Astrogeology
|
||||
665m/px global DEM, GeoTIFF.
|
||||
|
||||
Mercury properties:
|
||||
- Min elevation: ~-5380 m
|
||||
- Max elevation: ~4480 m
|
||||
- No atmosphere, no water
|
||||
- Extreme temperature range: ~100K (night) to ~700K (day)
|
||||
- body_type: "planet", planet_class: "barren", atmosphere: "none"
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
|
||||
from sol_data.download import ensure_cached
|
||||
from sol_data.shared import (
|
||||
GRID_W, GRID_H,
|
||||
load_tiff_as_array, load_raw_binary,
|
||||
resample_to_grid, normalize_01,
|
||||
compute_hillshade, assemble_terrain,
|
||||
temperature_grid_analytical,
|
||||
)
|
||||
|
||||
# MESSENGER DEM — try PDS binary first (compact), fall back to USGS GeoTIFF
|
||||
MESSENGER_PDS_URL = "https://pds-geosciences.wustl.edu/messenger/mess-h-mdis_mla-6-dem-elevation-v1/messdmdem_1001/data/global_dem_16ppd.img"
|
||||
MESSENGER_PDS_FILE = "messenger_dem_16ppd.img"
|
||||
MESSENGER_PDS_W = 5760
|
||||
MESSENGER_PDS_H = 2880
|
||||
|
||||
# USGS GeoTIFF fallback (~506 MB, but PIL-loadable)
|
||||
MESSENGER_TIFF_URL = "https://planetarymaps.usgs.gov/mosaic/Mercury_Messenger_USGS_DEM_Global_665m_v2.tif"
|
||||
MESSENGER_TIFF_FILE = "Mercury_Messenger_USGS_DEM_Global_665m_v2.tif"
|
||||
|
||||
MERCURY_MIN_ELEV_M = -5380.0
|
||||
MERCURY_MAX_ELEV_M = 4480.0
|
||||
MERCURY_EQUATORIAL_TEMP_K = 440.0 # mean dayside
|
||||
MERCURY_POLAR_TEMP_K = 200.0
|
||||
|
||||
|
||||
def _load_messenger() -> np.ndarray:
|
||||
"""Load MESSENGER DEM, return elevation in metres."""
|
||||
# Try PDS binary first (compact ~33 MB)
|
||||
try:
|
||||
path = ensure_cached(MESSENGER_PDS_URL, MESSENGER_PDS_FILE)
|
||||
print(f" loading MESSENGER PDS: {path}")
|
||||
arr = load_raw_binary(str(path), MESSENGER_PDS_W, MESSENGER_PDS_H,
|
||||
dtype=">i2", offset=0)
|
||||
arr[arr > 20000] = 0.0
|
||||
arr[arr < -20000] = 0.0
|
||||
print(f" MESSENGER range: [{arr.min():.0f}, {arr.max():.0f}] m")
|
||||
return arr
|
||||
except Exception as e:
|
||||
print(f" PDS load failed ({e}), trying USGS GeoTIFF...")
|
||||
|
||||
# Fallback: USGS GeoTIFF (~506 MB)
|
||||
try:
|
||||
path = ensure_cached(MESSENGER_TIFF_URL, MESSENGER_TIFF_FILE)
|
||||
print(f" loading MESSENGER GeoTIFF: {path}")
|
||||
arr = load_tiff_as_array(str(path))
|
||||
arr[arr < -20000] = 0.0
|
||||
print(f" MESSENGER shape: {arr.shape}, range: [{arr.min():.0f}, {arr.max():.0f}] m")
|
||||
return arr
|
||||
except Exception as e2:
|
||||
print(f" GeoTIFF also failed ({e2}), using procedural")
|
||||
return None
|
||||
|
||||
|
||||
def build_terrain(body_def: dict) -> dict:
|
||||
"""Build Mercury terrain dict from MESSENGER data."""
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from planet_simulation import compute_biome
|
||||
|
||||
print(" Mercury: loading MESSENGER data...")
|
||||
|
||||
# ── 1. Elevation ────────────────────────────────────────────────────
|
||||
raw = _load_messenger()
|
||||
if raw is None:
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from planet_simulation import simulate
|
||||
return simulate(body_def)
|
||||
|
||||
from sol_data.shared import greenwich_to_dateline
|
||||
shifted = greenwich_to_dateline(raw)
|
||||
elevation_m = resample_to_grid(shifted, GRID_H, GRID_W, order=1)
|
||||
elevation = normalize_01(elevation_m, MERCURY_MIN_ELEV_M, MERCURY_MAX_ELEV_M)
|
||||
|
||||
sea_level = 0.0
|
||||
surface_water = np.zeros((GRID_H, GRID_W), dtype=bool)
|
||||
|
||||
# ── 2. Temperature ──────────────────────────────────────────────────
|
||||
temperature_K = temperature_grid_analytical(
|
||||
base_T_K=MERCURY_EQUATORIAL_TEMP_K,
|
||||
elevation=elevation,
|
||||
lapse_rate_K_per_unit=20.0,
|
||||
lat_gradient_K=240.0,
|
||||
)
|
||||
temperature_K = np.maximum(temperature_K, 100.0)
|
||||
|
||||
# ── 3. Moisture ─────────────────────────────────────────────────────
|
||||
moisture = np.zeros((GRID_H, GRID_W), dtype=np.float32)
|
||||
|
||||
# ── 4. Biome ────────────────────────────────────────────────────────
|
||||
biome = compute_biome(body_def, elevation, sea_level, surface_water,
|
||||
temperature_K, moisture)
|
||||
|
||||
# ── 5. Hillshade ────────────────────────────────────────────────────
|
||||
hillshade = compute_hillshade(elevation)
|
||||
|
||||
return assemble_terrain(
|
||||
elevation=elevation, temperature_K=temperature_K,
|
||||
moisture=moisture, biome=biome,
|
||||
surface_water=surface_water, hillshade=hillshade,
|
||||
rivers=[], sea_level=sea_level,
|
||||
)
|
||||
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
Shared utilities for loading and processing real-world planetary data.
|
||||
|
||||
All loaders produce arrays compatible with the planet_simulation terrain dict:
|
||||
- Grid size: GRID_H x GRID_W (256 x 512)
|
||||
- Elevation: float32 [0, 1] normalised
|
||||
- Temperature: float32 in absolute Kelvin (normalised to [0,1] later)
|
||||
- Moisture: float32 [0, 1]
|
||||
- Sea level: float elevation threshold
|
||||
"""
|
||||
|
||||
import math
|
||||
import numpy as np
|
||||
from scipy.ndimage import zoom
|
||||
from PIL import Image
|
||||
|
||||
# Planetary DEMs can exceed PIL's default decompression bomb limit
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
# Match planet_simulation grid
|
||||
GRID_W = 512
|
||||
GRID_H = 256
|
||||
|
||||
|
||||
# ─── Loading ────────────────────────────────────────────────────────────────
|
||||
|
||||
def load_tiff_as_array(path: str) -> np.ndarray:
|
||||
"""
|
||||
Load a GeoTIFF/TIFF as a numpy array via PIL.
|
||||
|
||||
PIL handles uncompressed and LZW-compressed TIFFs with 8/16/32-bit
|
||||
integer or float samples. For multi-band, returns (H, W, bands).
|
||||
For single-band, returns (H, W).
|
||||
"""
|
||||
img = Image.open(path)
|
||||
arr = np.array(img, dtype=np.float32)
|
||||
return arr
|
||||
|
||||
|
||||
def load_raw_binary(path: str, width: int, height: int,
|
||||
dtype: str = ">i2", offset: int = 0) -> np.ndarray:
|
||||
"""
|
||||
Load a raw binary raster (PDS IMG, .bin, etc).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : file path
|
||||
width : number of columns
|
||||
height : number of rows
|
||||
dtype : numpy dtype string (e.g. ">i2" for big-endian int16)
|
||||
offset : byte offset to skip (header size)
|
||||
"""
|
||||
dt = np.dtype(dtype)
|
||||
expected_bytes = width * height * dt.itemsize
|
||||
with open(path, "rb") as f:
|
||||
f.seek(offset)
|
||||
raw = f.read(expected_bytes)
|
||||
if len(raw) < expected_bytes:
|
||||
raise ValueError(
|
||||
f"Expected {expected_bytes} bytes, got {len(raw)}. "
|
||||
f"Check width/height/dtype/offset."
|
||||
)
|
||||
arr = np.frombuffer(raw, dtype=dt).reshape(height, width).astype(np.float32)
|
||||
return arr
|
||||
|
||||
|
||||
def load_image_as_elevation(path: str, invert: bool = False) -> np.ndarray:
|
||||
"""
|
||||
Load a greyscale or RGB image and convert to float32 elevation.
|
||||
For RGB, uses luminance. For greyscale, uses the single channel.
|
||||
"""
|
||||
img = Image.open(path).convert("L")
|
||||
arr = np.array(img, dtype=np.float32) / 255.0
|
||||
if invert:
|
||||
arr = 1.0 - arr
|
||||
return arr
|
||||
|
||||
|
||||
# ─── Resampling ─────────────────────────────────────────────────────────────
|
||||
|
||||
def resample_to_grid(arr: np.ndarray, target_h: int = GRID_H,
|
||||
target_w: int = GRID_W,
|
||||
order: int = 1) -> np.ndarray:
|
||||
"""
|
||||
Resample a 2D array to target grid size.
|
||||
|
||||
order: 0=nearest, 1=bilinear, 3=cubic
|
||||
"""
|
||||
if arr.shape == (target_h, target_w):
|
||||
return arr.astype(np.float32)
|
||||
zoom_y = target_h / arr.shape[0]
|
||||
zoom_x = target_w / arr.shape[1]
|
||||
return zoom(arr, (zoom_y, zoom_x), order=order).astype(np.float32)
|
||||
|
||||
|
||||
# ─── Normalisation ──────────────────────────────────────────────────────────
|
||||
|
||||
def normalize_01(arr: np.ndarray, lo: float = None, hi: float = None) -> np.ndarray:
|
||||
"""Normalise array to [0, 1]."""
|
||||
if lo is None:
|
||||
lo = float(arr.min())
|
||||
if hi is None:
|
||||
hi = float(arr.max())
|
||||
if hi - lo < 1e-9:
|
||||
return np.zeros_like(arr, dtype=np.float32)
|
||||
return np.clip((arr - lo) / (hi - lo), 0.0, 1.0).astype(np.float32)
|
||||
|
||||
|
||||
def compute_sea_level(elevation: np.ndarray, ocean_fraction: float) -> float:
|
||||
"""
|
||||
Compute sea_level threshold such that ocean_fraction of cells are below it.
|
||||
"""
|
||||
if ocean_fraction <= 0.0:
|
||||
return 0.0
|
||||
if ocean_fraction >= 1.0:
|
||||
return 1.0
|
||||
return float(np.percentile(elevation, ocean_fraction * 100.0))
|
||||
|
||||
|
||||
# ─── Longitude shift ────────────────────────────────────────────────────────
|
||||
|
||||
def shift_longitude(arr: np.ndarray, shift_cols: int) -> np.ndarray:
|
||||
"""
|
||||
Roll array along the longitude (column) axis.
|
||||
|
||||
The pipeline uses col 0 = 180°W. If source data uses col 0 = 0° (Greenwich),
|
||||
shift by half the width to align.
|
||||
"""
|
||||
return np.roll(arr, shift_cols, axis=1)
|
||||
|
||||
|
||||
def greenwich_to_dateline(arr: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Shift from col 0 = 0° (Greenwich) to col 0 = 180°W (dateline).
|
||||
Standard for most NASA/NOAA global datasets → pipeline convention.
|
||||
"""
|
||||
return shift_longitude(arr, arr.shape[1] // 2)
|
||||
|
||||
|
||||
# ─── Hillshade ──────────────────────────────────────────────────────────────
|
||||
|
||||
def compute_hillshade(elevation: np.ndarray,
|
||||
sun_azimuth_deg: float = 315.0,
|
||||
sun_altitude_deg: float = 45.0) -> np.ndarray:
|
||||
"""
|
||||
Compute hillshade from elevation grid. Matches planet_simulation.compute_hillshade().
|
||||
"""
|
||||
scale = elevation.shape[1] / 8.0
|
||||
gy, gx = np.gradient(elevation * scale)
|
||||
mag = np.sqrt(gx**2 + gy**2 + 1.0)
|
||||
nx = -gx / mag
|
||||
ny = -gy / mag
|
||||
nz = 1.0 / mag
|
||||
|
||||
az = math.radians(sun_azimuth_deg)
|
||||
alt = math.radians(sun_altitude_deg)
|
||||
lx = math.cos(alt) * math.sin(az)
|
||||
ly = -math.cos(alt) * math.cos(az)
|
||||
lz = math.sin(alt)
|
||||
|
||||
shade = np.clip(nx * lx + ny * ly + nz * lz, 0.0, 1.0)
|
||||
return shade.astype(np.float32)
|
||||
|
||||
|
||||
# ─── Analytical temperature models ─────────────────────────────────────────
|
||||
|
||||
def temperature_equilibrium_K(luminosity_solar: float, distance_au: float,
|
||||
albedo: float = 0.3) -> float:
|
||||
"""
|
||||
Stefan-Boltzmann equilibrium temperature in Kelvin.
|
||||
"""
|
||||
L_sun = 3.828e26 # watts
|
||||
sigma = 5.670e-8
|
||||
d_m = distance_au * 1.496e11
|
||||
T_eq = ((luminosity_solar * L_sun * (1 - albedo)) /
|
||||
(16 * math.pi * sigma * d_m**2)) ** 0.25
|
||||
return T_eq
|
||||
|
||||
|
||||
def temperature_grid_analytical(
|
||||
base_T_K: float,
|
||||
grid_h: int = GRID_H,
|
||||
grid_w: int = GRID_W,
|
||||
elevation: np.ndarray = None,
|
||||
lapse_rate_K_per_unit: float = 40.0,
|
||||
lat_gradient_K: float = 60.0,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Analytical temperature grid: equator-to-pole gradient + elevation lapse.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
base_T_K : equatorial temperature in Kelvin
|
||||
elevation : normalised [0,1] elevation grid (optional)
|
||||
lapse_rate_K_per_unit: temperature drop per unit elevation
|
||||
lat_gradient_K : total temperature drop from equator to pole
|
||||
"""
|
||||
v = np.linspace(0, 1, grid_h, dtype=np.float32)
|
||||
lat_frac = np.abs(v - 0.5) * 2.0 # 0 at equator, 1 at poles
|
||||
lat_temp = lat_frac[:, np.newaxis] * lat_gradient_K # broadcast to (H, W)
|
||||
temp = np.full((grid_h, grid_w), base_T_K, dtype=np.float32)
|
||||
temp -= lat_temp
|
||||
if elevation is not None:
|
||||
temp -= elevation * lapse_rate_K_per_unit
|
||||
return temp
|
||||
|
||||
|
||||
# ─── Terrain dict assembly ──────────────────────────────────────────────────
|
||||
|
||||
def assemble_terrain(
|
||||
elevation: np.ndarray,
|
||||
temperature_K: np.ndarray,
|
||||
moisture: np.ndarray,
|
||||
biome: np.ndarray,
|
||||
surface_water: np.ndarray,
|
||||
hillshade: np.ndarray,
|
||||
rivers: list,
|
||||
sea_level: float,
|
||||
) -> dict:
|
||||
"""
|
||||
Assemble the terrain dict in the format expected by render_heightmap
|
||||
and render_globe. Temperature is normalised to [0,1] for the output
|
||||
(matching planet_simulation.simulate() lines 891-893).
|
||||
"""
|
||||
H, W = elevation.shape
|
||||
river_grid = np.zeros((H, W), dtype=bool)
|
||||
for path in rivers:
|
||||
for r, c in path:
|
||||
if 0 <= r < H and 0 <= c < W:
|
||||
river_grid[r, c] = True
|
||||
|
||||
# Normalise temperature to [0,1] for renderer display
|
||||
t_min, t_max = temperature_K.min(), temperature_K.max()
|
||||
temp_norm = ((temperature_K - t_min) / (t_max - t_min + 1e-9)).astype(np.float32)
|
||||
|
||||
return {
|
||||
"elevation": elevation.astype(np.float32),
|
||||
"temperature": temp_norm,
|
||||
"moisture": moisture.astype(np.float32),
|
||||
"biome": biome.astype(np.int8),
|
||||
"surface_water": surface_water.astype(bool),
|
||||
"hillshade": hillshade.astype(np.float32),
|
||||
"river_grid": river_grid,
|
||||
"rivers": rivers,
|
||||
"sea_level": float(sea_level),
|
||||
"_grid_w": W,
|
||||
"_grid_h": H,
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Titan (GJ0g-1) terrain builder.
|
||||
|
||||
Titan is unique: dense nitrogen atmosphere, methane rain cycle,
|
||||
methane/ethane lakes and rivers. Surface temperature ~94K uniform.
|
||||
|
||||
Data source:
|
||||
- Surface: Cassini ISS global mosaic (4km resolution)
|
||||
- Topography: very sparse Cassini radar altimetry (gap-filled)
|
||||
|
||||
Since Cassini topographic data is extremely sparse, we use the ISS
|
||||
mosaic albedo to derive synthetic elevation (similar to ice moons)
|
||||
with special handling for known methane lake regions.
|
||||
|
||||
Properties:
|
||||
- planet_class: "frozen", atmosphere: "dense", hydrosphere: "rivers"
|
||||
- Methane lakes primarily near the north pole (Kraken Mare, Ligeia Mare)
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from scipy.ndimage import gaussian_filter
|
||||
|
||||
from sol_data.download import ensure_cached
|
||||
from sol_data.shared import (
|
||||
GRID_W, GRID_H,
|
||||
load_image_as_elevation, resample_to_grid, normalize_01,
|
||||
compute_hillshade, assemble_terrain,
|
||||
)
|
||||
|
||||
# Cassini ISS global mosaic
|
||||
TITAN_MOSAIC_URL = "https://astrogeology.usgs.gov/cache/images/5e5ba96a58d3b38ee6e7b1e94b8c44e6_titan_iss_p19658_mosaic_global_4km.jpg"
|
||||
TITAN_MOSAIC_FILE = "titan_cassini_iss_mosaic.jpg"
|
||||
|
||||
TITAN_SURFACE_TEMP_K = 94.0 # nearly uniform
|
||||
TITAN_METHANE_LAKE_FRACTION = 0.02 # ~2% of surface is liquid methane
|
||||
|
||||
|
||||
def _load_titan_mosaic() -> np.ndarray:
|
||||
"""Load Titan mosaic and convert to synthetic elevation."""
|
||||
try:
|
||||
path = ensure_cached(TITAN_MOSAIC_URL, TITAN_MOSAIC_FILE)
|
||||
print(f" loading Titan mosaic: {path}")
|
||||
albedo = load_image_as_elevation(str(path), invert=False)
|
||||
albedo = resample_to_grid(albedo, GRID_H, GRID_W, order=1)
|
||||
except Exception as e:
|
||||
print(f" WARNING: Titan mosaic unavailable ({e}), synthetic")
|
||||
albedo = _synthetic_titan_terrain()
|
||||
|
||||
# Dark regions = low (lakes/flat), bright = dunes/highlands
|
||||
elevation = gaussian_filter(albedo, sigma=3.0)
|
||||
return normalize_01(elevation)
|
||||
|
||||
|
||||
def _synthetic_titan_terrain() -> np.ndarray:
|
||||
"""Generate synthetic Titan terrain."""
|
||||
rng = np.random.default_rng(94)
|
||||
base = rng.random((GRID_H, GRID_W)).astype(np.float32)
|
||||
base = gaussian_filter(base, sigma=6.0)
|
||||
# Titan has equatorial dune fields (higher terrain)
|
||||
v = np.linspace(0, 1, GRID_H, dtype=np.float32)
|
||||
equatorial = np.exp(-((v - 0.5) ** 2) / 0.02)
|
||||
base += equatorial[:, np.newaxis] * 0.3
|
||||
return normalize_01(base)
|
||||
|
||||
|
||||
def build_terrain(body_def: dict) -> dict:
|
||||
"""Build Titan terrain dict."""
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from planet_simulation import compute_biome
|
||||
|
||||
print(" Titan: loading data...")
|
||||
|
||||
# ── 1. Elevation ────────────────────────────────────────────────────
|
||||
elevation = _load_titan_mosaic()
|
||||
|
||||
# Titan has methane lakes — set sea level to create them
|
||||
# Lakes are concentrated at north polar regions
|
||||
# Use a low sea level so that only the darkest (lowest) areas become liquid
|
||||
from sol_data.shared import compute_sea_level
|
||||
sea_level = compute_sea_level(elevation, TITAN_METHANE_LAKE_FRACTION)
|
||||
surface_water = elevation < sea_level
|
||||
|
||||
# Concentrate lakes near north pole (real Titan has lakes mostly 60-90°N)
|
||||
v = np.linspace(0, 1, GRID_H, dtype=np.float32)
|
||||
lat_abs = np.abs(v - 0.5) * 2.0 # 0=equator, 1=poles
|
||||
north_mask = v < 0.2 # north polar region (top 20% of grid = 72-90°N)
|
||||
# Allow lakes only in polar regions — mask out equatorial/southern "seas"
|
||||
equatorial_mask = (lat_abs < 0.6)[:, np.newaxis] * np.ones(GRID_W, dtype=bool)
|
||||
surface_water = surface_water & ~equatorial_mask
|
||||
|
||||
print(f" methane lakes: {surface_water.sum()} cells")
|
||||
|
||||
# ── 2. Temperature ──────────────────────────────────────────────────
|
||||
# Titan has nearly uniform surface temp due to dense atmosphere + distance
|
||||
temperature_K = np.full((GRID_H, GRID_W), TITAN_SURFACE_TEMP_K, dtype=np.float32)
|
||||
# Very slight pole-equator gradient (~2K)
|
||||
lat_temp = lat_abs[:, np.newaxis] * 2.0
|
||||
temperature_K -= lat_temp
|
||||
|
||||
# ── 3. Moisture ─────────────────────────────────────────────────────
|
||||
# Titan has a methane humidity cycle — higher moisture near poles
|
||||
moisture = np.zeros((GRID_H, GRID_W), dtype=np.float32)
|
||||
# Polar moisture (methane humidity)
|
||||
polar_humid = np.clip(lat_abs[:, np.newaxis] - 0.5, 0, 1) * 0.6
|
||||
moisture += polar_humid
|
||||
# Some equatorial humidity (methane drizzle)
|
||||
equatorial_humid = np.exp(-((v[:, np.newaxis] - 0.5) ** 2) / 0.05) * 0.2
|
||||
moisture += equatorial_humid
|
||||
|
||||
# ── 4. Biome ────────────────────────────────────────────────────────
|
||||
# Titan at 94K with dense atmosphere goes through Whittaker table
|
||||
# Everything will classify as ice/snow (class 17) — which is correct
|
||||
biome = compute_biome(body_def, elevation, sea_level, surface_water,
|
||||
temperature_K, moisture)
|
||||
|
||||
# Override: methane lakes should be ocean classes, not ice
|
||||
# (The biome function sets ocean depth bands for surface_water, which is
|
||||
# what we want — methane lakes rendered like ocean)
|
||||
print(f" biomes: {len(np.unique(biome))} classes")
|
||||
|
||||
# ── 5. Hillshade ────────────────────────────────────────────────────
|
||||
hillshade = compute_hillshade(elevation)
|
||||
|
||||
# ── 6. Rivers ───────────────────────────────────────────────────────
|
||||
# Titan has methane drainage channels — add synthetic ones near poles
|
||||
rivers = _titan_rivers(elevation, surface_water)
|
||||
|
||||
return assemble_terrain(
|
||||
elevation=elevation, temperature_K=temperature_K,
|
||||
moisture=moisture, biome=biome,
|
||||
surface_water=surface_water, hillshade=hillshade,
|
||||
rivers=rivers, sea_level=sea_level,
|
||||
)
|
||||
|
||||
|
||||
def _titan_rivers(elevation: np.ndarray, surface_water: np.ndarray) -> list:
|
||||
"""
|
||||
Generate synthetic methane drainage channels for Titan.
|
||||
Simple downhill tracing from high-latitude sources to lakes.
|
||||
"""
|
||||
rivers = []
|
||||
rng = np.random.default_rng(94)
|
||||
|
||||
# Start from a few points in the north polar region
|
||||
for _ in range(5):
|
||||
r = int(rng.integers(10, 50)) # north polar zone
|
||||
c = int(rng.integers(0, GRID_W))
|
||||
path = [(r, c)]
|
||||
visited = {(r, c)}
|
||||
|
||||
for _ in range(200):
|
||||
if surface_water[r, c]:
|
||||
break
|
||||
best_r, best_c = r, c
|
||||
best_elev = elevation[r, c]
|
||||
for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1),
|
||||
(-1, -1), (-1, 1), (1, -1), (1, 1)]:
|
||||
nr, nc = r + dr, (c + dc) % GRID_W
|
||||
if 0 <= nr < GRID_H and (nr, nc) not in visited:
|
||||
if elevation[nr, nc] < best_elev:
|
||||
best_elev = elevation[nr, nc]
|
||||
best_r, best_c = nr, nc
|
||||
if (best_r, best_c) == (r, c):
|
||||
break
|
||||
r, c = int(best_r), int(best_c)
|
||||
path.append((r, c))
|
||||
visited.add((r, c))
|
||||
|
||||
if len(path) >= 5:
|
||||
rivers.append(path)
|
||||
|
||||
return rivers
|
||||
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Venus (GJ0c) terrain builder.
|
||||
|
||||
Data source:
|
||||
- Elevation: Magellan radar altimetry from USGS Astrogeology
|
||||
Global topography at ~4.6 km/px, PDS format.
|
||||
|
||||
Venus properties:
|
||||
- Surface: volcanic, extremely hot (~735K), dense CO2 atmosphere
|
||||
- No liquid water, thick clouds
|
||||
- Min elevation: ~-2000 m (lowlands)
|
||||
- Max elevation: ~11000 m (Maxwell Montes on Ishtar Terra)
|
||||
- planet_class: "volcanic", atmosphere: "toxic"
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
|
||||
from sol_data.download import ensure_cached
|
||||
from sol_data.shared import (
|
||||
GRID_W, GRID_H,
|
||||
load_tiff_as_array, load_raw_binary, resample_to_grid, normalize_01,
|
||||
compute_hillshade, assemble_terrain,
|
||||
temperature_grid_analytical,
|
||||
)
|
||||
|
||||
# Magellan topography — USGS GeoTIFF (reliable, PIL-loadable)
|
||||
MAGELLAN_TIFF_URL = "https://planetarymaps.usgs.gov/mosaic/Venus_Magellan_Topography_Global_4641m_v02.tif"
|
||||
MAGELLAN_TIFF_FILE = "Venus_Magellan_Topography_Global_4641m_v02.tif"
|
||||
|
||||
# PDS fallback (raw binary, dimensions may vary)
|
||||
MAGELLAN_PDS_URL = "https://pds-geosciences.wustl.edu/mgn/mgn-v-rdrs-5-dim-v1/mg_3002/gedr/gtdr/gtdr_shtplt.img"
|
||||
MAGELLAN_PDS_FILE = "venus_magellan_gtdr.img"
|
||||
|
||||
VENUS_MIN_ELEV_M = -2000.0
|
||||
VENUS_MAX_ELEV_M = 11000.0
|
||||
VENUS_SURFACE_TEMP_K = 735.0 # nearly uniform due to dense atmosphere
|
||||
|
||||
|
||||
def _load_magellan() -> np.ndarray:
|
||||
"""Load Magellan topography data."""
|
||||
# Try USGS GeoTIFF first (reliable, well-defined format)
|
||||
try:
|
||||
path = ensure_cached(MAGELLAN_TIFF_URL, MAGELLAN_TIFF_FILE)
|
||||
print(f" loading Magellan GeoTIFF: {path}")
|
||||
arr = load_tiff_as_array(str(path))
|
||||
# Handle nodata
|
||||
arr[arr < -20000] = 0.0
|
||||
arr[arr > 20000] = 0.0
|
||||
print(f" Magellan shape: {arr.shape}, "
|
||||
f"range: [{arr.min():.0f}, {arr.max():.0f}] m")
|
||||
return arr
|
||||
except Exception as e:
|
||||
print(f" GeoTIFF failed ({e}), trying PDS binary...")
|
||||
|
||||
# PDS fallback — try common dimension/format combinations
|
||||
try:
|
||||
path = ensure_cached(MAGELLAN_PDS_URL, MAGELLAN_PDS_FILE)
|
||||
print(f" loading Magellan PDS: {path}")
|
||||
for w, h in [(4096, 2048), (2048, 1024), (8192, 4096)]:
|
||||
try:
|
||||
arr = load_raw_binary(str(path), w, h, dtype=">i2", offset=0)
|
||||
arr[arr > 20000] = 0.0
|
||||
arr[arr < -20000] = 0.0
|
||||
print(f" Magellan PDS: {w}x{h}, range: [{arr.min():.0f}, {arr.max():.0f}]")
|
||||
return arr
|
||||
except ValueError:
|
||||
continue
|
||||
except Exception as e3:
|
||||
print(f" PDS also failed ({e3})")
|
||||
|
||||
# All sources failed — fall through to procedural generation
|
||||
print(f" WARNING: all Magellan sources failed, using procedural")
|
||||
return None
|
||||
|
||||
|
||||
def build_terrain(body_def: dict) -> dict:
|
||||
"""Build Venus terrain dict from Magellan data."""
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from planet_simulation import compute_biome
|
||||
|
||||
print(" Venus: loading Magellan data...")
|
||||
|
||||
# ── 1. Elevation ────────────────────────────────────────────────────
|
||||
raw = _load_magellan()
|
||||
if raw is None:
|
||||
# Fall back to procedural simulation
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from planet_simulation import simulate
|
||||
return simulate(body_def)
|
||||
|
||||
from sol_data.shared import greenwich_to_dateline
|
||||
shifted = greenwich_to_dateline(raw)
|
||||
elevation_m = resample_to_grid(shifted, GRID_H, GRID_W, order=1)
|
||||
elevation = normalize_01(elevation_m, VENUS_MIN_ELEV_M, VENUS_MAX_ELEV_M)
|
||||
|
||||
sea_level = 0.0
|
||||
surface_water = np.zeros((GRID_H, GRID_W), dtype=bool)
|
||||
|
||||
# ── 2. Temperature ──────────────────────────────────────────────────
|
||||
# Venus has nearly uniform surface temperature due to dense atmosphere
|
||||
temperature_K = temperature_grid_analytical(
|
||||
base_T_K=VENUS_SURFACE_TEMP_K,
|
||||
elevation=elevation,
|
||||
lapse_rate_K_per_unit=50.0, # slight cooling at altitude
|
||||
lat_gradient_K=5.0, # almost no lat variation (thick atmo)
|
||||
)
|
||||
|
||||
# ── 3. Moisture ─────────────────────────────────────────────────────
|
||||
moisture = np.zeros((GRID_H, GRID_W), dtype=np.float32)
|
||||
|
||||
# ── 4. Biome ────────────────────────────────────────────────────────
|
||||
biome = compute_biome(body_def, elevation, sea_level, surface_water,
|
||||
temperature_K, moisture)
|
||||
|
||||
# ── 5. Hillshade ────────────────────────────────────────────────────
|
||||
hillshade = compute_hillshade(elevation)
|
||||
|
||||
return assemble_terrain(
|
||||
elevation=elevation, temperature_K=temperature_K,
|
||||
moisture=moisture, biome=biome,
|
||||
surface_water=surface_water, hillshade=hillshade,
|
||||
rivers=[], sea_level=sea_level,
|
||||
)
|
||||
@@ -0,0 +1,370 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
sol_import.py — Import real-world data for the Sol system (GJ-0).
|
||||
|
||||
Produces the same output format as generate.py (heightmap.png, globe.png,
|
||||
markers.json, terrain.npz) by constructing terrain dicts from real
|
||||
planetary science data instead of procedural simulation.
|
||||
|
||||
Usage:
|
||||
python3 sol_import.py # All Sol bodies
|
||||
python3 sol_import.py --body GJ0d # Earth only
|
||||
python3 sol_import.py --body GJ0d --body GJ0e # Earth + Mars
|
||||
python3 sol_import.py --download-only # Fetch data, skip rendering
|
||||
python3 sol_import.py --heightmap-size 2048x1024 --globe-size 1024
|
||||
|
||||
Data is cached in tooling/planet-gen/sol_data/.cache/ after first download.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Venv bootstrap — re-exec into .venv/bin/python if not already there.
|
||||
from pathlib import Path
|
||||
TOOLING_DIR = Path(__file__).resolve().parent
|
||||
WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
||||
_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python"
|
||||
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
|
||||
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
|
||||
|
||||
import numpy as np
|
||||
|
||||
from planet_simulation import simulate
|
||||
from render_heightmap import render_heightmap
|
||||
from generate import _build_markers
|
||||
|
||||
# Per-body importers (lazy-loaded)
|
||||
SOL_INDEX = WORKTREE_ROOT / "wiki" / "star-systems" / "GJ-0" / "index.md"
|
||||
SOL_OVERRIDES = TOOLING_DIR / "sol_overrides.json"
|
||||
SOL_BODIES_DIR = WORKTREE_ROOT / "wiki" / "star-systems" / "GJ-0" / "bodies"
|
||||
SOL_MARKERS_DIR = TOOLING_DIR / "sol_markers"
|
||||
|
||||
# Bodies that use real-world data (keyed by body_id → importer module)
|
||||
REAL_DATA_BODIES = {
|
||||
"GJ0b": "mercury",
|
||||
"GJ0c": "venus",
|
||||
"GJ0d": "earth",
|
||||
"GJ0d-1": "luna",
|
||||
"GJ0e": "mars",
|
||||
"GJ0f-1": "io_moon",
|
||||
"GJ0f-2": "ice_moons",
|
||||
"GJ0f-3": "ice_moons",
|
||||
"GJ0f-4": "ice_moons",
|
||||
"GJ0g-1": "titan",
|
||||
"GJ0g-2": "ice_moons",
|
||||
}
|
||||
|
||||
# Bodies that fall through to procedural simulation
|
||||
PROCEDURAL_BODIES = {"GJ0e-1", "GJ0e-2"}
|
||||
|
||||
# Non-renderable body types
|
||||
SKIP_TYPES = {"asteroid_belt", "oort_cloud"}
|
||||
|
||||
|
||||
def _load_importer(module_name: str):
|
||||
"""Lazy-import a sol_data.* module."""
|
||||
import importlib
|
||||
return importlib.import_module(f"sol_data.{module_name}")
|
||||
|
||||
|
||||
def _apply_named_features(markers: dict, body_id: str) -> dict:
|
||||
"""Overlay named features from sol_markers/ onto auto-detected markers."""
|
||||
features_map = {
|
||||
"GJ0d": "earth_features.json",
|
||||
"GJ0e": "mars_features.json",
|
||||
"GJ0d-1": "luna_features.json",
|
||||
}
|
||||
outer_bodies = {"GJ0f-1", "GJ0f-2", "GJ0f-3", "GJ0f-4",
|
||||
"GJ0g-1", "GJ0g-2"}
|
||||
|
||||
filename = features_map.get(body_id)
|
||||
if not filename and body_id in outer_bodies:
|
||||
filename = "outer_features.json"
|
||||
|
||||
if not filename:
|
||||
return markers
|
||||
|
||||
features_path = SOL_MARKERS_DIR / filename
|
||||
if not features_path.exists():
|
||||
return markers
|
||||
|
||||
with open(features_path) as f:
|
||||
features = json.load(f)
|
||||
|
||||
body_features = features.get(body_id, features)
|
||||
|
||||
# Name auto-detected oceans by matching center coordinates
|
||||
if "oceans" in body_features:
|
||||
for named_ocean in body_features["oceans"]:
|
||||
best_match = None
|
||||
best_dist = float("inf")
|
||||
nc = named_ocean["center"]
|
||||
for detected in markers["oceans"]:
|
||||
dc = detected["center"]
|
||||
dist = (dc[0] - nc[0])**2 + (dc[1] - nc[1])**2
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best_match = detected
|
||||
if best_match and best_dist < 2500: # within ~50 cells
|
||||
best_match["name"] = named_ocean["name"]
|
||||
|
||||
# Name auto-detected mountain ranges by matching peak coordinates
|
||||
if "mountain_ranges" in body_features:
|
||||
for named_range in body_features["mountain_ranges"]:
|
||||
best_match = None
|
||||
best_dist = float("inf")
|
||||
nc = named_range.get("peak", named_range.get("center", [0, 0]))
|
||||
for detected in markers["mountain_ranges"]:
|
||||
dp = detected.get("peak", detected.get("center", [0, 0]))
|
||||
dist = (dp[0] - nc[0])**2 + (dp[1] - nc[1])**2
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best_match = detected
|
||||
if best_match and best_dist < 1600: # within ~40 cells
|
||||
best_match["name"] = named_range["name"]
|
||||
|
||||
# Name rivers by matching start/end coordinates
|
||||
if "rivers" in body_features:
|
||||
for named_river in body_features["rivers"]:
|
||||
best_match = None
|
||||
best_dist = float("inf")
|
||||
nc = named_river.get("mouth", named_river.get("center", [0, 0]))
|
||||
for detected in markers["rivers"]:
|
||||
if not detected["path"]:
|
||||
continue
|
||||
# Check last point (mouth) of river path
|
||||
dp = detected["path"][-1]
|
||||
dist = (dp[0] - nc[0])**2 + (dp[1] - nc[1])**2
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best_match = detected
|
||||
if best_match and best_dist < 900:
|
||||
best_match["name"] = named_river["name"]
|
||||
|
||||
# Add cities as POIs
|
||||
if "cities" in body_features:
|
||||
for city in body_features["cities"]:
|
||||
markers["cities"].append({
|
||||
"id": f"city_{city['name'].lower().replace(' ', '_')}",
|
||||
"name": city["name"],
|
||||
"center": city["center"],
|
||||
"population": city.get("population"),
|
||||
})
|
||||
|
||||
# Add POIs
|
||||
if "pois" in body_features:
|
||||
for poi in body_features["pois"]:
|
||||
markers["pois"].append(poi)
|
||||
|
||||
return markers
|
||||
|
||||
|
||||
def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
|
||||
globe_size: int, render_mode: str, output_dir: Path,
|
||||
download_only: bool = False):
|
||||
"""Generate all outputs for a single Sol body."""
|
||||
body_id = body_def["id"]
|
||||
body_type = body_def.get("body_type", "planet")
|
||||
planet_class = body_def.get("planet_class", "unknown")
|
||||
name = body_def.get("name") or body_id
|
||||
|
||||
# Skip non-renderable types
|
||||
if body_type in SKIP_TYPES:
|
||||
print(f"\n {body_id} ({name}) — skipped ({body_type})")
|
||||
return
|
||||
|
||||
body_dir = output_dir / body_id
|
||||
body_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"\n {body_id} ({name}) — {planet_class}")
|
||||
|
||||
t0 = time.time()
|
||||
|
||||
# ── 1. Build terrain ────────────────────────────────────────────────
|
||||
terrain = {}
|
||||
is_gas = planet_class in ("gas_giant",) or body_type == "gas_giant"
|
||||
|
||||
if is_gas:
|
||||
# Gas giants: no terrain, renderer handles bands procedurally
|
||||
terrain = {}
|
||||
print(f" terrain: gas giant (procedural bands)")
|
||||
elif body_id in REAL_DATA_BODIES:
|
||||
# Real-world data import
|
||||
module_name = REAL_DATA_BODIES[body_id]
|
||||
print(f" importing real data via sol_data.{module_name}...")
|
||||
importer = _load_importer(module_name)
|
||||
terrain = importer.build_terrain(body_def)
|
||||
if download_only:
|
||||
print(f" download complete, skipping render")
|
||||
return
|
||||
elif body_id in PROCEDURAL_BODIES:
|
||||
# Fall through to standard procedural simulation
|
||||
print(f" procedural simulation (irregular body)...")
|
||||
terrain = simulate(body_def)
|
||||
else:
|
||||
print(f" WARNING: no importer for {body_id}, using procedural")
|
||||
terrain = simulate(body_def)
|
||||
|
||||
t_terrain = time.time()
|
||||
|
||||
if terrain:
|
||||
print(f" terrain: {t_terrain - t0:.1f}s "
|
||||
f"sea={terrain['sea_level']:.3f} "
|
||||
f"land={int((~terrain['surface_water']).sum())} "
|
||||
f"rivers={len(terrain['rivers'])}")
|
||||
else:
|
||||
print(f" terrain: gas giant ({t_terrain - t0:.1f}s)")
|
||||
|
||||
# ── 2. Render heightmap ─────────────────────────────────────────────
|
||||
t_hmap = t_terrain
|
||||
if terrain:
|
||||
hmap_img = render_heightmap(body_def, terrain,
|
||||
out_w=hmap_w, out_h=hmap_h,
|
||||
render_mode=render_mode, chrome=False)
|
||||
hmap_img.save(str(body_dir / "heightmap.png"))
|
||||
t_hmap = time.time()
|
||||
print(f" heightmap: {t_hmap - t_terrain:.1f}s {hmap_w}x{hmap_h}")
|
||||
|
||||
# ── 3. Render globe ─────────────────────────────────────────────────
|
||||
try:
|
||||
from planet_renderer import render_globe
|
||||
globe_img = render_globe(body_def, terrain, size=globe_size)
|
||||
globe_img.save(str(body_dir / "globe.png"))
|
||||
t_globe = time.time()
|
||||
print(f" globe: {t_globe - t_hmap:.1f}s {globe_size}x{globe_size}")
|
||||
except Exception as e:
|
||||
print(f" globe: FAILED — {e}")
|
||||
t_globe = time.time()
|
||||
|
||||
# ── 4. Write data files ─────────────────────────────────────────────
|
||||
if terrain:
|
||||
# terrain.npz
|
||||
save_dict = {}
|
||||
for key in ("elevation", "temperature", "moisture", "hillshade",
|
||||
"biome", "surface_water", "river_grid"):
|
||||
if key in terrain:
|
||||
save_dict[key] = terrain[key]
|
||||
save_dict["sea_level"] = np.array([terrain["sea_level"]])
|
||||
np.savez_compressed(str(body_dir / "terrain.npz"), **save_dict)
|
||||
|
||||
# markers.json — auto-detected + named features overlay
|
||||
markers = _build_markers(body_def, terrain)
|
||||
markers = _apply_named_features(markers, body_id)
|
||||
with open(body_dir / "markers.json", "w") as f:
|
||||
json.dump(markers, f, indent=2)
|
||||
|
||||
# ── 5. Write index.md frontmatter ───────────────────────────────────
|
||||
_write_index_md(body_def, body_dir)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print(f" total: {elapsed:.1f}s -> {body_dir}/")
|
||||
|
||||
|
||||
def _write_index_md(body_def: dict, body_dir: Path):
|
||||
"""Write body index.md with YAML frontmatter."""
|
||||
import yaml
|
||||
|
||||
# Strip internal fields
|
||||
bd = {k: v for k, v in body_def.items()
|
||||
if not k.startswith("_") and k != "wiki"}
|
||||
|
||||
fm = yaml.dump(bd, default_flow_style=False, sort_keys=False,
|
||||
allow_unicode=True)
|
||||
|
||||
name = body_def.get("name") or body_def["id"]
|
||||
planet_class = body_def.get("planet_class", "unknown")
|
||||
system_link = "[GJ-0](../../index.md)"
|
||||
|
||||
md = f"""---
|
||||
{fm.rstrip()}
|
||||
---
|
||||
|
||||
# {name}
|
||||
|
||||
{planet_class.replace('_', ' ').title()} {'planet' if body_def.get('body_type') == 'planet' else body_def.get('body_type', 'body')}.
|
||||
|
||||
**System:** {system_link}
|
||||
|
||||
## Visual
|
||||
|
||||

|
||||
|
||||

|
||||
"""
|
||||
with open(body_dir / "index.md", "w") as f:
|
||||
f.write(md)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Sol system (GJ-0) real-world terrain importer")
|
||||
|
||||
parser.add_argument("--body", action="append", default=None,
|
||||
help="Specific body ID(s) to generate (repeatable)")
|
||||
parser.add_argument("--download-only", action="store_true",
|
||||
help="Download source data without rendering")
|
||||
parser.add_argument("--output-dir", default=None,
|
||||
help="Override output directory")
|
||||
parser.add_argument("--heightmap-size", default="1024x512",
|
||||
help="Heightmap resolution (WxH)")
|
||||
parser.add_argument("--globe-size", type=int, default=512,
|
||||
help="Globe resolution (square)")
|
||||
parser.add_argument("--render-mode", choices=["cartographic", "photographic"],
|
||||
default="cartographic")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Parse heightmap size
|
||||
try:
|
||||
hw, hh = args.heightmap_size.lower().split("x")
|
||||
hmap_w, hmap_h = int(hw), int(hh)
|
||||
except ValueError:
|
||||
print(f"error: invalid heightmap size '{args.heightmap_size}'",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
output_dir = Path(args.output_dir) if args.output_dir else SOL_BODIES_DIR
|
||||
|
||||
# Parse body definitions from GJ-0 index.md
|
||||
from body_definition_parser import parse_system
|
||||
|
||||
overrides = {}
|
||||
if SOL_OVERRIDES.exists():
|
||||
with open(SOL_OVERRIDES) as f:
|
||||
overrides = json.load(f)
|
||||
|
||||
body_defs = parse_system(str(SOL_INDEX), overrides=overrides)
|
||||
print(f"Sol system: {len(body_defs)} bodies parsed")
|
||||
|
||||
# Filter to requested bodies
|
||||
if args.body:
|
||||
requested = set(args.body)
|
||||
body_defs = [bd for bd in body_defs if bd["id"] in requested]
|
||||
if not body_defs:
|
||||
print(f"error: no matching bodies for {args.body}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Generate
|
||||
t_total = time.time()
|
||||
failed = []
|
||||
for bd in body_defs:
|
||||
try:
|
||||
_generate_body(bd, hmap_w, hmap_h, args.globe_size,
|
||||
args.render_mode, output_dir,
|
||||
download_only=args.download_only)
|
||||
except Exception as e:
|
||||
print(f"\n FAILED: {bd['id']} — {e}")
|
||||
failed.append(bd["id"])
|
||||
|
||||
elapsed = time.time() - t_total
|
||||
n_ok = len(body_defs) - len(failed)
|
||||
print(f"\n Done: {n_ok}/{len(body_defs)} bodies in {elapsed:.1f}s")
|
||||
if failed:
|
||||
print(f" Failed: {', '.join(failed)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"GJ0d": {
|
||||
"oceans": [
|
||||
{"name": "Pacific Ocean", "center": [128, 440]},
|
||||
{"name": "Atlantic Ocean", "center": [128, 170]},
|
||||
{"name": "Indian Ocean", "center": [160, 330]},
|
||||
{"name": "Arctic Ocean", "center": [15, 256]},
|
||||
{"name": "Southern Ocean", "center": [230, 256]}
|
||||
],
|
||||
"mountain_ranges": [
|
||||
{"name": "Himalayas", "peak": [93, 350], "center": [93, 348]},
|
||||
{"name": "Andes", "peak": [118, 140], "center": [140, 140]},
|
||||
{"name": "Rocky Mountains", "peak": [88, 105], "center": [85, 105]},
|
||||
{"name": "Alps", "peak": [82, 264], "center": [82, 264]},
|
||||
{"name": "Urals", "peak": [68, 300], "center": [72, 300]},
|
||||
{"name": "Atlas Mountains", "peak": [93, 254], "center": [94, 254]},
|
||||
{"name": "Great Dividing Range", "peak": [160, 420], "center": [162, 420]}
|
||||
],
|
||||
"rivers": [
|
||||
{"name": "Danube", "mouth": [82, 278]},
|
||||
{"name": "Volga", "mouth": [75, 298]},
|
||||
{"name": "Rhine", "mouth": [79, 264]},
|
||||
{"name": "Mississippi", "mouth": [96, 107]},
|
||||
{"name": "St. Lawrence", "mouth": [80, 132]},
|
||||
{"name": "Amazon", "mouth": [126, 165]},
|
||||
{"name": "Paraná", "mouth": [148, 155]},
|
||||
{"name": "Nile", "mouth": [98, 286]},
|
||||
{"name": "Congo", "mouth": [125, 268]},
|
||||
{"name": "Tigris", "mouth": [96, 303]},
|
||||
{"name": "Yangtze", "mouth": [97, 387]},
|
||||
{"name": "Ganges", "mouth": [102, 351]},
|
||||
{"name": "Mekong", "mouth": [114, 374]},
|
||||
{"name": "Murray", "mouth": [170, 417]}
|
||||
],
|
||||
"cities": [
|
||||
{"name": "London", "center": [79, 260], "population": 9000000, "region": "europe"},
|
||||
{"name": "Istanbul", "center": [83, 279], "population": 15000000, "region": "europe"},
|
||||
{"name": "Moscow", "center": [72, 294], "population": 12700000, "region": "europe"},
|
||||
{"name": "Paris", "center": [80, 261], "population": 11000000, "region": "europe"},
|
||||
{"name": "Berlin", "center": [77, 269], "population": 3700000, "region": "europe"},
|
||||
|
||||
{"name": "Mexico City", "center": [107, 101], "population": 21800000, "region": "north_america"},
|
||||
{"name": "New York", "center": [87, 130], "population": 20100000, "region": "north_america"},
|
||||
{"name": "Los Angeles", "center": [93, 95], "population": 13200000, "region": "north_america"},
|
||||
{"name": "Toronto", "center": [84, 123], "population": 6200000, "region": "north_america"},
|
||||
{"name": "Chicago", "center": [85, 115], "population": 9500000, "region": "north_america"},
|
||||
|
||||
{"name": "São Paulo", "center": [143, 164], "population": 22400000, "region": "south_america"},
|
||||
{"name": "Lima", "center": [133, 131], "population": 10700000, "region": "south_america"},
|
||||
{"name": "Bogotá", "center": [121, 135], "population": 11300000, "region": "south_america"},
|
||||
{"name": "Rio de Janeiro", "center": [142, 168], "population": 13500000, "region": "south_america"},
|
||||
{"name": "Buenos Aires", "center": [151, 153], "population": 15200000, "region": "south_america"},
|
||||
|
||||
{"name": "Lagos", "center": [120, 262], "population": 15400000, "region": "africa"},
|
||||
{"name": "Kinshasa", "center": [124, 270], "population": 15600000, "region": "africa"},
|
||||
{"name": "Cairo", "center": [97, 286], "population": 21300000, "region": "africa"},
|
||||
{"name": "Johannesburg", "center": [156, 279], "population": 6000000, "region": "africa"},
|
||||
{"name": "Nairobi", "center": [128, 293], "population": 5100000, "region": "africa"},
|
||||
|
||||
{"name": "Tehran", "center": [92, 308], "population": 9000000, "region": "west_asia"},
|
||||
{"name": "Baghdad", "center": [94, 303], "population": 8100000, "region": "west_asia"},
|
||||
{"name": "Riyadh", "center": [103, 304], "population": 7700000, "region": "west_asia"},
|
||||
{"name": "Ankara", "center": [87, 284], "population": 5700000, "region": "west_asia"},
|
||||
{"name": "Karachi", "center": [103, 327], "population": 16500000, "region": "west_asia"},
|
||||
|
||||
{"name": "Tokyo", "center": [92, 400], "population": 37400000, "region": "east_asia"},
|
||||
{"name": "Delhi", "center": [99, 339], "population": 32900000, "region": "east_asia"},
|
||||
{"name": "Shanghai", "center": [97, 387], "population": 28500000, "region": "east_asia"},
|
||||
{"name": "Beijing", "center": [87, 383], "population": 21500000, "region": "east_asia"},
|
||||
{"name": "Mumbai", "center": [107, 333], "population": 21700000, "region": "east_asia"},
|
||||
|
||||
{"name": "Jakarta", "center": [120, 374], "population": 34500000, "region": "fill"},
|
||||
{"name": "Dhaka", "center": [103, 351], "population": 23000000, "region": "fill"},
|
||||
{"name": "Manila", "center": [109, 388], "population": 14400000, "region": "fill"},
|
||||
{"name": "Bangkok", "center": [109, 370], "population": 11000000, "region": "fill"},
|
||||
{"name": "Seoul", "center": [90, 393], "population": 9800000, "region": "fill"},
|
||||
{"name": "Osaka", "center": [93, 398], "population": 19300000, "region": "fill"},
|
||||
{"name": "Chongqing", "center": [97, 375], "population": 17000000, "region": "fill"},
|
||||
{"name": "Kolkata", "center": [103, 349], "population": 15100000, "region": "fill"},
|
||||
{"name": "Lahore", "center": [97, 336], "population": 14000000, "region": "fill"},
|
||||
{"name": "Shenzhen", "center": [104, 382], "population": 13400000, "region": "fill"},
|
||||
{"name": "Bangalore", "center": [111, 339], "population": 13200000, "region": "fill"},
|
||||
{"name": "Ho Chi Minh City", "center": [113, 374], "population": 9300000, "region": "fill"},
|
||||
{"name": "Luanda", "center": [132, 268], "population": 9000000, "region": "fill"},
|
||||
{"name": "Addis Ababa", "center": [119, 292], "population": 5500000, "region": "fill"},
|
||||
{"name": "Santiago", "center": [147, 137], "population": 7000000, "region": "fill"},
|
||||
{"name": "Taipei", "center": [103, 388], "population": 7000000, "region": "fill"},
|
||||
{"name": "Hong Kong", "center": [104, 382], "population": 7500000, "region": "fill"},
|
||||
{"name": "Singapore", "center": [119, 372], "population": 5900000, "region": "fill"},
|
||||
{"name": "Sydney", "center": [161, 421], "population": 5300000, "region": "fill"},
|
||||
{"name": "Casablanca", "center": [93, 249], "population": 3800000, "region": "fill"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"GJ0d-1": {
|
||||
"pois": [
|
||||
{"id": "poi_mare_tranquillitatis", "name": "Mare Tranquillitatis", "center": [119, 282], "kind": "mare"},
|
||||
{"id": "poi_mare_imbrium", "name": "Mare Imbrium", "center": [93, 247], "kind": "mare"},
|
||||
{"id": "poi_oceanus_procellarum", "name": "Oceanus Procellarum", "center": [107, 230], "kind": "mare"},
|
||||
{"id": "poi_mare_serenitatis", "name": "Mare Serenitatis", "center": [104, 277], "kind": "mare"},
|
||||
{"id": "poi_mare_crisium", "name": "Mare Crisium", "center": [108, 302], "kind": "mare"},
|
||||
{"id": "poi_mare_nubium", "name": "Mare Nubium", "center": [134, 248], "kind": "mare"},
|
||||
{"id": "poi_mare_fecunditatis", "name": "Mare Fecunditatis", "center": [124, 299], "kind": "mare"},
|
||||
{"id": "poi_south_pole_aitken", "name": "South Pole-Aitken Basin","center": [213, 330], "kind": "basin"},
|
||||
{"id": "poi_tycho", "name": "Tycho", "center": [163, 249], "kind": "crater"},
|
||||
{"id": "poi_copernicus", "name": "Copernicus", "center": [118, 243], "kind": "crater"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"GJ0e": {
|
||||
"mountain_ranges": [
|
||||
{"name": "Olympus Mons", "peak": [107, 358], "center": [107, 358]},
|
||||
{"name": "Tharsis Bulge", "peak": [115, 365], "center": [118, 362]},
|
||||
{"name": "Elysium Mons", "peak": [103, 413], "center": [103, 413]},
|
||||
{"name": "Ascraeus Mons", "peak": [108, 367], "center": [108, 367]},
|
||||
{"name": "Arsia Mons", "peak": [118, 363], "center": [118, 363]}
|
||||
],
|
||||
"oceans": [
|
||||
{"name": "Hellas Basin", "center": [148, 329]},
|
||||
{"name": "Utopia Planitia", "center": [80, 385]},
|
||||
{"name": "Isidis Planitia", "center": [112, 343]}
|
||||
],
|
||||
"pois": [
|
||||
{"id": "poi_valles_marineris", "name": "Valles Marineris", "center": [118, 380], "kind": "canyon"},
|
||||
{"id": "poi_north_polar_cap", "name": "North Polar Cap", "center": [10, 256], "kind": "ice_cap"},
|
||||
{"id": "poi_south_polar_cap", "name": "South Polar Cap", "center": [245, 256], "kind": "ice_cap"},
|
||||
{"id": "poi_chryse_planitia", "name": "Chryse Planitia", "center": [100, 392], "kind": "plain"},
|
||||
{"id": "poi_acidalia_planitia","name": "Acidalia Planitia","center": [80, 395], "kind": "plain"}
|
||||
]
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user